@martian-engineering/lossless-claw 0.5.2 → 0.6.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 +49 -11
- package/docs/configuration.md +44 -0
- package/openclaw.plugin.json +114 -0
- package/package.json +2 -1
- package/skills/lossless-claw/SKILL.md +33 -0
- package/skills/lossless-claw/references/architecture.md +52 -0
- package/skills/lossless-claw/references/config.md +263 -0
- package/skills/lossless-claw/references/diagnostics.md +79 -0
- package/skills/lossless-claw/references/recall-tools.md +55 -0
- package/skills/lossless-claw/references/session-lifecycle.md +59 -0
- package/src/assembler.ts +321 -34
- package/src/compaction.ts +220 -19
- package/src/db/config.ts +74 -21
- package/src/db/migration.ts +50 -13
- package/src/engine.ts +742 -133
- package/src/plugin/index.ts +156 -73
- package/src/plugin/lcm-command.ts +759 -0
- package/src/plugin/lcm-doctor-apply.ts +546 -0
- package/src/plugin/lcm-doctor-shared.ts +210 -0
- package/src/store/conversation-store.ts +60 -21
- package/src/store/parse-utc-timestamp.ts +25 -0
- package/src/store/summary-store.ts +460 -11
- package/src/summarize.ts +553 -224
- package/src/tools/lcm-expand-query-tool.ts +195 -59
- package/src/tools/lcm-expansion-recursion-guard.ts +87 -0
- package/src/types.ts +1 -0
package/src/compaction.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface CompactionResult {
|
|
|
25
25
|
condensed: boolean;
|
|
26
26
|
/** Escalation level used: "normal" | "aggressive" | "fallback" */
|
|
27
27
|
level?: CompactionLevel;
|
|
28
|
+
/** Whether compaction was blocked by a provider auth failure */
|
|
29
|
+
authFailure?: boolean;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export interface CompactionConfig {
|
|
@@ -38,7 +40,7 @@ export interface CompactionConfig {
|
|
|
38
40
|
condensedMinFanout: number;
|
|
39
41
|
/** Relaxed minimum fanout for hard-trigger sweeps. */
|
|
40
42
|
condensedMinFanoutHard: number;
|
|
41
|
-
/** Incremental depth passes to run after each leaf compaction (default
|
|
43
|
+
/** Incremental depth passes to run after each leaf compaction (default 1). */
|
|
42
44
|
incrementalMaxDepth: number;
|
|
43
45
|
/** Max source tokens to compact per leaf/condensed chunk (default 20000) */
|
|
44
46
|
leafChunkTokens?: number;
|
|
@@ -50,9 +52,11 @@ export interface CompactionConfig {
|
|
|
50
52
|
maxRounds: number;
|
|
51
53
|
/** IANA timezone for timestamps in summaries (default: UTC) */
|
|
52
54
|
timezone?: string;
|
|
55
|
+
/** Maximum allowed overage factor for summaries relative to target tokens (default 3). */
|
|
56
|
+
summaryMaxOverageFactor: number;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
type CompactionLevel = "normal" | "aggressive" | "fallback";
|
|
59
|
+
type CompactionLevel = "normal" | "aggressive" | "fallback" | "capped";
|
|
56
60
|
type CompactionPass = "leaf" | "condensed";
|
|
57
61
|
type CompactionSummarizeOptions = {
|
|
58
62
|
previousSummary?: string;
|
|
@@ -86,6 +90,30 @@ function estimateTokens(content: string): number {
|
|
|
86
90
|
return Math.ceil(content.length / 4);
|
|
87
91
|
}
|
|
88
92
|
|
|
93
|
+
/** Deterministically cap summary text so the persisted output stays within maxTokens. */
|
|
94
|
+
function capSummaryText(
|
|
95
|
+
content: string,
|
|
96
|
+
originalTokens: number,
|
|
97
|
+
maxTokens: number,
|
|
98
|
+
): string {
|
|
99
|
+
const suffixes = [
|
|
100
|
+
`\n[Capped from ${originalTokens} tokens to ~${maxTokens}]`,
|
|
101
|
+
`\n[Capped to ~${maxTokens}]`,
|
|
102
|
+
"\n[Capped]",
|
|
103
|
+
"",
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
for (const suffix of suffixes) {
|
|
107
|
+
const maxChars = Math.max(0, maxTokens * 4 - suffix.length);
|
|
108
|
+
const capped = `${content.slice(0, maxChars)}${suffix}`;
|
|
109
|
+
if (estimateTokens(capped) <= maxTokens) {
|
|
110
|
+
return capped;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return content.slice(0, Math.max(0, maxTokens * 4));
|
|
115
|
+
}
|
|
116
|
+
|
|
89
117
|
/** Format a timestamp as `YYYY-MM-DD HH:mm TZ` for prompt source text. */
|
|
90
118
|
export function formatTimestamp(value: Date, timezone: string = "UTC"): string {
|
|
91
119
|
try {
|
|
@@ -150,6 +178,11 @@ const DEFAULT_LEAF_CHUNK_TOKENS = 20_000;
|
|
|
150
178
|
* with no meaningful text.
|
|
151
179
|
*/
|
|
152
180
|
const MEDIA_PATH_RE = /^MEDIA:\/.+$/;
|
|
181
|
+
const EMBEDDED_DATA_URL_RE = /data:[^;\s"'`]+;base64,[A-Za-z0-9+/=\s]+/gi;
|
|
182
|
+
const MEDIA_ATTACHMENT_PART_TYPES = new Set(["file", "snapshot"]);
|
|
183
|
+
const MEDIA_ATTACHMENT_RAW_TYPES = new Set(["file", "image", "snapshot"]);
|
|
184
|
+
const STRUCTURED_MEDIA_TEXT_KEYS = ["text", "caption", "alt", "title", "summary"] as const;
|
|
185
|
+
const STRUCTURED_MEDIA_NESTED_KEYS = ["content", "parts", "items", "message", "messages"] as const;
|
|
153
186
|
|
|
154
187
|
const CONDENSED_MIN_INPUT_RATIO = 0.1;
|
|
155
188
|
|
|
@@ -165,6 +198,140 @@ function dedupeOrderedIds(ids: Iterable<string>): string[] {
|
|
|
165
198
|
return ordered;
|
|
166
199
|
}
|
|
167
200
|
|
|
201
|
+
/** Parse message-part metadata without throwing on malformed JSON. */
|
|
202
|
+
function parseMessagePartMetadata(part: CreateMessagePartInput | { metadata: string | null }): Record<string, unknown> {
|
|
203
|
+
if (typeof part.metadata !== "string" || !part.metadata.trim()) {
|
|
204
|
+
return {};
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const parsed = JSON.parse(part.metadata) as unknown;
|
|
208
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
209
|
+
? (parsed as Record<string, unknown>)
|
|
210
|
+
: {};
|
|
211
|
+
} catch {
|
|
212
|
+
return {};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Detect whether a string is mostly binary/base64 payload and not meaningful prose. */
|
|
217
|
+
function looksLikeBinaryPayload(value: string): boolean {
|
|
218
|
+
const trimmed = value.trim();
|
|
219
|
+
if (!trimmed) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
if (/^data:[^;\s"'`]+;base64,/i.test(trimmed)) {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
const compact = trimmed.replace(/\s+/g, "");
|
|
226
|
+
if (compact.length < 256 || compact.length % 4 !== 0) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
if (!/^[A-Za-z0-9+/=]+$/.test(compact)) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
return !/[ .,:;!?()[\]{}]/.test(trimmed);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Strip attachment payloads from plain strings before they reach the summarizer. */
|
|
236
|
+
function stripEmbeddedMediaPayloads(content: string): string {
|
|
237
|
+
const withoutDataUrls = content.replace(EMBEDDED_DATA_URL_RE, "[embedded media omitted]");
|
|
238
|
+
const sanitizedLines = withoutDataUrls
|
|
239
|
+
.split(/\r?\n/)
|
|
240
|
+
.map((line) => line.trimEnd())
|
|
241
|
+
.filter((line) => {
|
|
242
|
+
const trimmed = line.trim();
|
|
243
|
+
if (!trimmed) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
if (MEDIA_PATH_RE.test(trimmed)) {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
if (looksLikeBinaryPayload(trimmed)) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
return true;
|
|
253
|
+
});
|
|
254
|
+
return sanitizedLines.join("\n").trim();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Extract human-readable text from structured content while ignoring attachment payload fields. */
|
|
258
|
+
function extractSanitizedStructuredText(value: unknown, depth = 0): string[] {
|
|
259
|
+
if (depth >= 4 || value == null) {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
if (typeof value === "string") {
|
|
263
|
+
const sanitized = stripEmbeddedMediaPayloads(value);
|
|
264
|
+
return sanitized ? [sanitized] : [];
|
|
265
|
+
}
|
|
266
|
+
if (Array.isArray(value)) {
|
|
267
|
+
return value.flatMap((entry) => extractSanitizedStructuredText(entry, depth + 1));
|
|
268
|
+
}
|
|
269
|
+
if (typeof value !== "object") {
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const record = value as Record<string, unknown>;
|
|
274
|
+
const rawType = typeof record.type === "string" ? record.type.trim().toLowerCase() : "";
|
|
275
|
+
const textFragments: string[] = [];
|
|
276
|
+
|
|
277
|
+
for (const key of STRUCTURED_MEDIA_TEXT_KEYS) {
|
|
278
|
+
const candidate = record[key];
|
|
279
|
+
if (typeof candidate !== "string") {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const sanitized = stripEmbeddedMediaPayloads(candidate);
|
|
283
|
+
if (sanitized) {
|
|
284
|
+
textFragments.push(sanitized);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (MEDIA_ATTACHMENT_RAW_TYPES.has(rawType)) {
|
|
289
|
+
return textFragments;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
for (const key of STRUCTURED_MEDIA_NESTED_KEYS) {
|
|
293
|
+
textFragments.push(...extractSanitizedStructuredText(record[key], depth + 1));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return textFragments;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Normalize message content down to human-readable text, excluding binary/media payloads. */
|
|
300
|
+
function extractMeaningfulMessageText(content: string): string {
|
|
301
|
+
const trimmed = content.trim();
|
|
302
|
+
if (!trimmed) {
|
|
303
|
+
return "";
|
|
304
|
+
}
|
|
305
|
+
if ((trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("{") && trimmed.endsWith("}"))) {
|
|
306
|
+
try {
|
|
307
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
308
|
+
const extracted = extractSanitizedStructuredText(parsed)
|
|
309
|
+
.map((fragment) => fragment.trim())
|
|
310
|
+
.filter(Boolean);
|
|
311
|
+
return extracted.join("\n").trim();
|
|
312
|
+
} catch {
|
|
313
|
+
// Fall back to plain-text sanitation below.
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return stripEmbeddedMediaPayloads(content);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Identify whether a stored message part represents a media attachment. */
|
|
320
|
+
function isMediaAttachmentPart(part: CreateMessagePartInput | { partType: string; metadata: string | null }): boolean {
|
|
321
|
+
if (MEDIA_ATTACHMENT_PART_TYPES.has(part.partType)) {
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
const metadata = parseMessagePartMetadata(part);
|
|
325
|
+
const rawType =
|
|
326
|
+
typeof metadata.rawType === "string"
|
|
327
|
+
? metadata.rawType.trim().toLowerCase()
|
|
328
|
+
: metadata.raw && typeof metadata.raw === "object" && !Array.isArray(metadata.raw) &&
|
|
329
|
+
typeof (metadata.raw as Record<string, unknown>).type === "string"
|
|
330
|
+
? ((metadata.raw as Record<string, unknown>).type as string).trim().toLowerCase()
|
|
331
|
+
: "";
|
|
332
|
+
return MEDIA_ATTACHMENT_RAW_TYPES.has(rawType);
|
|
333
|
+
}
|
|
334
|
+
|
|
168
335
|
// ── CompactionEngine ─────────────────────────────────────────────────────────
|
|
169
336
|
|
|
170
337
|
export class CompactionEngine {
|
|
@@ -300,6 +467,7 @@ export class CompactionEngine {
|
|
|
300
467
|
tokensBefore,
|
|
301
468
|
tokensAfter: tokensBefore,
|
|
302
469
|
condensed: false,
|
|
470
|
+
authFailure: true,
|
|
303
471
|
};
|
|
304
472
|
}
|
|
305
473
|
const tokensAfterLeaf = await this.summaryStore.getContextTokenCount(conversationId);
|
|
@@ -416,6 +584,7 @@ export class CompactionEngine {
|
|
|
416
584
|
let level: CompactionLevel | undefined;
|
|
417
585
|
let previousSummaryContent: string | undefined;
|
|
418
586
|
let previousTokens = tokensBefore;
|
|
587
|
+
let hadAuthFailure = false;
|
|
419
588
|
|
|
420
589
|
// Phase 1: leaf passes over oldest raw chunks outside the protected tail.
|
|
421
590
|
while (true) {
|
|
@@ -433,6 +602,7 @@ export class CompactionEngine {
|
|
|
433
602
|
input.summaryModel,
|
|
434
603
|
);
|
|
435
604
|
if (!leafResult) {
|
|
605
|
+
hadAuthFailure = true;
|
|
436
606
|
break;
|
|
437
607
|
}
|
|
438
608
|
const passTokensAfter = await this.summaryStore.getContextTokenCount(conversationId);
|
|
@@ -479,6 +649,7 @@ export class CompactionEngine {
|
|
|
479
649
|
input.summaryModel,
|
|
480
650
|
);
|
|
481
651
|
if (!condenseResult) {
|
|
652
|
+
hadAuthFailure = true;
|
|
482
653
|
break;
|
|
483
654
|
}
|
|
484
655
|
const passTokensAfter = await this.summaryStore.getContextTokenCount(conversationId);
|
|
@@ -515,6 +686,7 @@ export class CompactionEngine {
|
|
|
515
686
|
createdSummaryId,
|
|
516
687
|
condensed,
|
|
517
688
|
level,
|
|
689
|
+
...(hadAuthFailure ? { authFailure: true } : {}),
|
|
518
690
|
};
|
|
519
691
|
}
|
|
520
692
|
|
|
@@ -528,7 +700,7 @@ export class CompactionEngine {
|
|
|
528
700
|
currentTokens?: number;
|
|
529
701
|
summarize: CompactionSummarizeFn;
|
|
530
702
|
summaryModel?: string;
|
|
531
|
-
}): Promise<{ success: boolean; rounds: number; finalTokens: number }> {
|
|
703
|
+
}): Promise<{ success: boolean; rounds: number; finalTokens: number; authFailure?: boolean }> {
|
|
532
704
|
const { conversationId, tokenBudget, summarize } = input;
|
|
533
705
|
const targetTokens =
|
|
534
706
|
typeof input.targetTokens === "number" &&
|
|
@@ -562,6 +734,15 @@ export class CompactionEngine {
|
|
|
562
734
|
summaryModel: input.summaryModel,
|
|
563
735
|
});
|
|
564
736
|
|
|
737
|
+
if (result.authFailure) {
|
|
738
|
+
return {
|
|
739
|
+
success: false,
|
|
740
|
+
rounds: round,
|
|
741
|
+
finalTokens: result.tokensAfter,
|
|
742
|
+
authFailure: true,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
565
746
|
if (result.tokensAfter <= targetTokens) {
|
|
566
747
|
return {
|
|
567
748
|
success: true,
|
|
@@ -1010,6 +1191,8 @@ export class CompactionEngine {
|
|
|
1010
1191
|
sourceText: string;
|
|
1011
1192
|
summarize: CompactionSummarizeFn;
|
|
1012
1193
|
options?: CompactionSummarizeOptions;
|
|
1194
|
+
/** Target token count for this summary kind (leaf or condensed). Used for hard-cap enforcement. */
|
|
1195
|
+
targetTokens: number;
|
|
1013
1196
|
}): Promise<{ content: string; level: CompactionLevel } | null> {
|
|
1014
1197
|
const sourceText = params.sourceText.trim();
|
|
1015
1198
|
if (!sourceText) {
|
|
@@ -1076,6 +1259,21 @@ export class CompactionEngine {
|
|
|
1076
1259
|
}
|
|
1077
1260
|
}
|
|
1078
1261
|
|
|
1262
|
+
// Hard cap: enforce maximum summary size relative to the kind-appropriate target.
|
|
1263
|
+
const summaryTokens = estimateTokens(summaryText);
|
|
1264
|
+
const maxTokens = Math.ceil(params.targetTokens * this.config.summaryMaxOverageFactor);
|
|
1265
|
+
|
|
1266
|
+
if (summaryTokens > Math.ceil(params.targetTokens * 1.5)) {
|
|
1267
|
+
console.warn(
|
|
1268
|
+
`[lcm] summary exceeds target by ${Math.round((summaryTokens / params.targetTokens - 1) * 100)}%: ${summaryTokens} tokens vs target ${params.targetTokens}`,
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
if (summaryTokens > maxTokens) {
|
|
1273
|
+
summaryText = capSummaryText(summaryText, summaryTokens, maxTokens);
|
|
1274
|
+
level = "capped";
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1079
1277
|
return { content: summaryText, level };
|
|
1080
1278
|
}
|
|
1081
1279
|
|
|
@@ -1086,10 +1284,9 @@ export class CompactionEngine {
|
|
|
1086
1284
|
* attachments. This gives the summarizer enough context to produce a
|
|
1087
1285
|
* meaningful summary instead of trying to compress raw file paths.
|
|
1088
1286
|
*
|
|
1089
|
-
* - Media-only messages
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1092
|
-
* with " [with media attachment]" suffix.
|
|
1287
|
+
* - Media-only messages: content is replaced with "[Media attachment]".
|
|
1288
|
+
* - Media-mostly messages: text is preserved and annotated with
|
|
1289
|
+
* " [with media attachment]".
|
|
1093
1290
|
* - Text-only messages: returned unchanged.
|
|
1094
1291
|
*/
|
|
1095
1292
|
private async annotateMediaContent(
|
|
@@ -1097,27 +1294,29 @@ export class CompactionEngine {
|
|
|
1097
1294
|
content: string,
|
|
1098
1295
|
): Promise<string> {
|
|
1099
1296
|
const parts = await this.conversationStore.getMessageParts(messageId);
|
|
1100
|
-
const hasMediaParts = parts.some(
|
|
1101
|
-
(p) => p.partType === "file" || p.partType === "snapshot",
|
|
1102
|
-
);
|
|
1297
|
+
const hasMediaParts = parts.some((part) => isMediaAttachmentPart(part));
|
|
1103
1298
|
if (!hasMediaParts) {
|
|
1104
1299
|
return content;
|
|
1105
1300
|
}
|
|
1106
1301
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
.
|
|
1110
|
-
.
|
|
1302
|
+
const partText = parts
|
|
1303
|
+
.filter((part) => !isMediaAttachmentPart(part))
|
|
1304
|
+
.map((part) => (typeof part.textContent === "string" ? part.textContent : ""))
|
|
1305
|
+
.map((text) => stripEmbeddedMediaPayloads(text))
|
|
1306
|
+
.map((text) => text.trim())
|
|
1307
|
+
.filter(Boolean)
|
|
1111
1308
|
.join("\n")
|
|
1112
1309
|
.trim();
|
|
1310
|
+
const fallbackText = extractMeaningfulMessageText(content);
|
|
1311
|
+
const meaningfulText = (partText || fallbackText).trim();
|
|
1113
1312
|
|
|
1114
|
-
if (
|
|
1115
|
-
// Media-only: replace with descriptive annotation
|
|
1313
|
+
if (!meaningfulText) {
|
|
1116
1314
|
return "[Media attachment]";
|
|
1117
1315
|
}
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1316
|
+
if (meaningfulText.includes("[with media attachment]")) {
|
|
1317
|
+
return meaningfulText;
|
|
1318
|
+
}
|
|
1319
|
+
return `${meaningfulText} [with media attachment]`;
|
|
1121
1320
|
}
|
|
1122
1321
|
|
|
1123
1322
|
// ── Private: Leaf Pass ───────────────────────────────────────────────────
|
|
@@ -1167,6 +1366,7 @@ export class CompactionEngine {
|
|
|
1167
1366
|
previousSummary: previousSummaryContent,
|
|
1168
1367
|
isCondensed: false,
|
|
1169
1368
|
},
|
|
1369
|
+
targetTokens: this.config.leafTargetTokens,
|
|
1170
1370
|
});
|
|
1171
1371
|
if (!summary) {
|
|
1172
1372
|
console.warn(
|
|
@@ -1274,6 +1474,7 @@ export class CompactionEngine {
|
|
|
1274
1474
|
isCondensed: true,
|
|
1275
1475
|
depth: targetDepth + 1,
|
|
1276
1476
|
},
|
|
1477
|
+
targetTokens: this.config.condensedTargetTokens,
|
|
1277
1478
|
});
|
|
1278
1479
|
if (!condensed) {
|
|
1279
1480
|
console.warn(
|
package/src/db/config.ts
CHANGED
|
@@ -12,11 +12,14 @@ export type LcmConfig = {
|
|
|
12
12
|
skipStatelessSessions: boolean;
|
|
13
13
|
contextThreshold: number;
|
|
14
14
|
freshTailCount: number;
|
|
15
|
+
newSessionRetainDepth: number;
|
|
15
16
|
leafMinFanout: number;
|
|
16
17
|
condensedMinFanout: number;
|
|
17
18
|
condensedMinFanoutHard: number;
|
|
18
19
|
incrementalMaxDepth: number;
|
|
19
20
|
leafChunkTokens: number;
|
|
21
|
+
/** Maximum raw parent-history tokens imported during first-time bootstrap. */
|
|
22
|
+
bootstrapMaxTokens?: number;
|
|
20
23
|
leafTargetTokens: number;
|
|
21
24
|
condensedTargetTokens: number;
|
|
22
25
|
maxExpandTokens: number;
|
|
@@ -37,11 +40,22 @@ export type LcmConfig = {
|
|
|
37
40
|
expansionProvider: string;
|
|
38
41
|
/** Model override for lcm_expand_query sub-agent. */
|
|
39
42
|
expansionModel: string;
|
|
40
|
-
|
|
43
|
+
/** Max time to wait for delegated lcm_expand_query sub-agent completion. */
|
|
44
|
+
delegationTimeoutMs: number;
|
|
41
45
|
/** IANA timezone for timestamps in summaries (from TZ env or system default) */
|
|
42
46
|
timezone: string;
|
|
43
47
|
/** When true, retroactively delete HEARTBEAT_OK turn cycles from LCM storage. */
|
|
44
48
|
pruneHeartbeatOk: boolean;
|
|
49
|
+
/** Hard ceiling for assembly token budget — caps runtime-provided and fallback budgets. */
|
|
50
|
+
maxAssemblyTokenBudget?: number;
|
|
51
|
+
/** Maximum allowed overage factor for summaries relative to target tokens (default 3). */
|
|
52
|
+
summaryMaxOverageFactor: number;
|
|
53
|
+
/** Custom instructions injected into all summarization prompts. */
|
|
54
|
+
customInstructions: string;
|
|
55
|
+
/** Consecutive auth failures before the compaction circuit breaker trips (default 5). */
|
|
56
|
+
circuitBreakerThreshold: number;
|
|
57
|
+
/** Cooldown in milliseconds before the circuit breaker auto-resets (default 30 min). */
|
|
58
|
+
circuitBreakerCooldownMs: number;
|
|
45
59
|
};
|
|
46
60
|
|
|
47
61
|
/** Safely coerce an unknown value to a finite number, or return undefined. */
|
|
@@ -54,6 +68,21 @@ function toNumber(value: unknown): number | undefined {
|
|
|
54
68
|
return undefined;
|
|
55
69
|
}
|
|
56
70
|
|
|
71
|
+
/** Safely parse a finite integer from an environment string, or return undefined.
|
|
72
|
+
* Unlike raw parseInt(), this returns undefined for NaN so ?? fallback works. */
|
|
73
|
+
function parseFiniteInt(value: string | undefined): number | undefined {
|
|
74
|
+
if (value === undefined) return undefined;
|
|
75
|
+
const parsed = parseInt(value, 10);
|
|
76
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Safely parse a finite float from an environment string, or return undefined. */
|
|
80
|
+
function parseFiniteNumber(value: string | undefined): number | undefined {
|
|
81
|
+
if (value === undefined) return undefined;
|
|
82
|
+
const parsed = parseFloat(value);
|
|
83
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
57
86
|
/** Safely coerce an unknown value to a boolean, or return undefined. */
|
|
58
87
|
function toBool(value: unknown): boolean | undefined {
|
|
59
88
|
if (typeof value === "boolean") return value;
|
|
@@ -100,6 +129,17 @@ export function resolveLcmConfig(
|
|
|
100
129
|
pluginConfig?: Record<string, unknown>,
|
|
101
130
|
): LcmConfig {
|
|
102
131
|
const pc = pluginConfig ?? {};
|
|
132
|
+
const resolvedLeafChunkTokens =
|
|
133
|
+
parseFiniteInt(env.LCM_LEAF_CHUNK_TOKENS)
|
|
134
|
+
?? toNumber(pc.leafChunkTokens) ?? 20000;
|
|
135
|
+
const resolvedBootstrapMaxTokens =
|
|
136
|
+
parseFiniteInt(env.LCM_BOOTSTRAP_MAX_TOKENS)
|
|
137
|
+
?? toNumber(pc.bootstrapMaxTokens)
|
|
138
|
+
?? Math.max(6000, Math.floor(resolvedLeafChunkTokens * 0.3));
|
|
139
|
+
const envDelegationTimeoutMs =
|
|
140
|
+
env.LCM_DELEGATION_TIMEOUT_MS !== undefined
|
|
141
|
+
? toNumber(env.LCM_DELEGATION_TIMEOUT_MS)
|
|
142
|
+
: undefined;
|
|
103
143
|
|
|
104
144
|
return {
|
|
105
145
|
enabled:
|
|
@@ -130,37 +170,39 @@ export function resolveLcmConfig(
|
|
|
130
170
|
? env.LCM_SKIP_STATELESS_SESSIONS === "true"
|
|
131
171
|
: toBool(pc.skipStatelessSessions) ?? true,
|
|
132
172
|
contextThreshold:
|
|
133
|
-
(env.LCM_CONTEXT_THRESHOLD
|
|
173
|
+
parseFiniteNumber(env.LCM_CONTEXT_THRESHOLD)
|
|
134
174
|
?? toNumber(pc.contextThreshold) ?? 0.75,
|
|
135
175
|
freshTailCount:
|
|
136
|
-
(env.LCM_FRESH_TAIL_COUNT
|
|
137
|
-
?? toNumber(pc.freshTailCount) ??
|
|
176
|
+
parseFiniteInt(env.LCM_FRESH_TAIL_COUNT)
|
|
177
|
+
?? toNumber(pc.freshTailCount) ?? 64,
|
|
178
|
+
newSessionRetainDepth:
|
|
179
|
+
parseFiniteInt(env.LCM_NEW_SESSION_RETAIN_DEPTH)
|
|
180
|
+
?? toNumber(pc.newSessionRetainDepth) ?? 2,
|
|
138
181
|
leafMinFanout:
|
|
139
|
-
(env.LCM_LEAF_MIN_FANOUT
|
|
182
|
+
parseFiniteInt(env.LCM_LEAF_MIN_FANOUT)
|
|
140
183
|
?? toNumber(pc.leafMinFanout) ?? 8,
|
|
141
184
|
condensedMinFanout:
|
|
142
|
-
(env.LCM_CONDENSED_MIN_FANOUT
|
|
185
|
+
parseFiniteInt(env.LCM_CONDENSED_MIN_FANOUT)
|
|
143
186
|
?? toNumber(pc.condensedMinFanout) ?? 4,
|
|
144
187
|
condensedMinFanoutHard:
|
|
145
|
-
(env.LCM_CONDENSED_MIN_FANOUT_HARD
|
|
188
|
+
parseFiniteInt(env.LCM_CONDENSED_MIN_FANOUT_HARD)
|
|
146
189
|
?? toNumber(pc.condensedMinFanoutHard) ?? 2,
|
|
147
190
|
incrementalMaxDepth:
|
|
148
|
-
(env.LCM_INCREMENTAL_MAX_DEPTH
|
|
149
|
-
?? toNumber(pc.incrementalMaxDepth) ??
|
|
150
|
-
leafChunkTokens:
|
|
151
|
-
|
|
152
|
-
?? toNumber(pc.leafChunkTokens) ?? 20000,
|
|
191
|
+
parseFiniteInt(env.LCM_INCREMENTAL_MAX_DEPTH)
|
|
192
|
+
?? toNumber(pc.incrementalMaxDepth) ?? 1,
|
|
193
|
+
leafChunkTokens: resolvedLeafChunkTokens,
|
|
194
|
+
bootstrapMaxTokens: resolvedBootstrapMaxTokens,
|
|
153
195
|
leafTargetTokens:
|
|
154
|
-
(env.LCM_LEAF_TARGET_TOKENS
|
|
155
|
-
?? toNumber(pc.leafTargetTokens) ??
|
|
196
|
+
parseFiniteInt(env.LCM_LEAF_TARGET_TOKENS)
|
|
197
|
+
?? toNumber(pc.leafTargetTokens) ?? 2400,
|
|
156
198
|
condensedTargetTokens:
|
|
157
|
-
(env.LCM_CONDENSED_TARGET_TOKENS
|
|
199
|
+
parseFiniteInt(env.LCM_CONDENSED_TARGET_TOKENS)
|
|
158
200
|
?? toNumber(pc.condensedTargetTokens) ?? 2000,
|
|
159
201
|
maxExpandTokens:
|
|
160
|
-
(env.LCM_MAX_EXPAND_TOKENS
|
|
202
|
+
parseFiniteInt(env.LCM_MAX_EXPAND_TOKENS)
|
|
161
203
|
?? toNumber(pc.maxExpandTokens) ?? 4000,
|
|
162
204
|
largeFileTokenThreshold:
|
|
163
|
-
(env.LCM_LARGE_FILE_TOKEN_THRESHOLD
|
|
205
|
+
parseFiniteInt(env.LCM_LARGE_FILE_TOKEN_THRESHOLD)
|
|
164
206
|
?? toNumber(pc.largeFileThresholdTokens)
|
|
165
207
|
?? toNumber(pc.largeFileTokenThreshold)
|
|
166
208
|
?? 25000,
|
|
@@ -176,14 +218,25 @@ export function resolveLcmConfig(
|
|
|
176
218
|
env.LCM_EXPANSION_PROVIDER?.trim() ?? toStr(pc.expansionProvider) ?? "",
|
|
177
219
|
expansionModel:
|
|
178
220
|
env.LCM_EXPANSION_MODEL?.trim() ?? toStr(pc.expansionModel) ?? "",
|
|
179
|
-
|
|
180
|
-
env.LCM_AUTOCOMPACT_DISABLED !== undefined
|
|
181
|
-
? env.LCM_AUTOCOMPACT_DISABLED === "true"
|
|
182
|
-
: toBool(pc.autocompactDisabled) ?? false,
|
|
221
|
+
delegationTimeoutMs: envDelegationTimeoutMs ?? toNumber(pc.delegationTimeoutMs) ?? 120000,
|
|
183
222
|
timezone: env.TZ ?? toStr(pc.timezone) ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
184
223
|
pruneHeartbeatOk:
|
|
185
224
|
env.LCM_PRUNE_HEARTBEAT_OK !== undefined
|
|
186
225
|
? env.LCM_PRUNE_HEARTBEAT_OK === "true"
|
|
187
226
|
: toBool(pc.pruneHeartbeatOk) ?? false,
|
|
227
|
+
maxAssemblyTokenBudget:
|
|
228
|
+
parseFiniteInt(env.LCM_MAX_ASSEMBLY_TOKEN_BUDGET)
|
|
229
|
+
?? toNumber(pc.maxAssemblyTokenBudget) ?? undefined,
|
|
230
|
+
summaryMaxOverageFactor:
|
|
231
|
+
parseFiniteNumber(env.LCM_SUMMARY_MAX_OVERAGE_FACTOR)
|
|
232
|
+
?? toNumber(pc.summaryMaxOverageFactor) ?? 3,
|
|
233
|
+
customInstructions:
|
|
234
|
+
env.LCM_CUSTOM_INSTRUCTIONS?.trim() ?? toStr(pc.customInstructions) ?? "",
|
|
235
|
+
circuitBreakerThreshold:
|
|
236
|
+
parseFiniteInt(env.LCM_CIRCUIT_BREAKER_THRESHOLD)
|
|
237
|
+
?? toNumber(pc.circuitBreakerThreshold) ?? 5,
|
|
238
|
+
circuitBreakerCooldownMs:
|
|
239
|
+
parseFiniteInt(env.LCM_CIRCUIT_BREAKER_COOLDOWN_MS)
|
|
240
|
+
?? toNumber(pc.circuitBreakerCooldownMs) ?? 1_800_000,
|
|
188
241
|
};
|
|
189
242
|
}
|
package/src/db/migration.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import { getLcmDbFeatures } from "./features.js";
|
|
3
|
+
import { parseUtcTimestampOrNull } from "../store/parse-utc-timestamp.js";
|
|
3
4
|
|
|
4
5
|
type SummaryColumnInfo = {
|
|
5
6
|
name?: string;
|
|
@@ -62,18 +63,7 @@ function ensureSummaryMetadataColumns(db: DatabaseSync): void {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
function parseTimestamp(value: string | null | undefined): Date | null {
|
|
65
|
-
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const direct = new Date(value);
|
|
70
|
-
if (!Number.isNaN(direct.getTime())) {
|
|
71
|
-
return direct;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const normalized = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
|
|
75
|
-
const parsed = new Date(normalized);
|
|
76
|
-
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
66
|
+
return parseUtcTimestampOrNull(value);
|
|
77
67
|
}
|
|
78
68
|
|
|
79
69
|
function isoStringOrNull(value: Date | null): string | null {
|
|
@@ -434,6 +424,8 @@ export function runLcmMigrations(
|
|
|
434
424
|
conversation_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
435
425
|
session_id TEXT NOT NULL,
|
|
436
426
|
session_key TEXT,
|
|
427
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
428
|
+
archived_at TEXT,
|
|
437
429
|
title TEXT,
|
|
438
430
|
bootstrapped_at TEXT,
|
|
439
431
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
@@ -579,7 +571,27 @@ export function runLcmMigrations(
|
|
|
579
571
|
db.exec(`ALTER TABLE conversations ADD COLUMN session_key TEXT`);
|
|
580
572
|
}
|
|
581
573
|
|
|
582
|
-
|
|
574
|
+
const hasActive = conversationColumns.some((col) => col.name === "active");
|
|
575
|
+
if (!hasActive) {
|
|
576
|
+
db.exec(`ALTER TABLE conversations ADD COLUMN active INTEGER NOT NULL DEFAULT 1`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const hasArchivedAt = conversationColumns.some((col) => col.name === "archived_at");
|
|
580
|
+
if (!hasArchivedAt) {
|
|
581
|
+
db.exec(`ALTER TABLE conversations ADD COLUMN archived_at TEXT`);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
db.exec(`UPDATE conversations SET active = 1 WHERE active IS NULL`);
|
|
585
|
+
db.exec(`
|
|
586
|
+
CREATE UNIQUE INDEX IF NOT EXISTS conversations_active_session_key_idx
|
|
587
|
+
ON conversations (session_key)
|
|
588
|
+
WHERE session_key IS NOT NULL AND active = 1
|
|
589
|
+
`);
|
|
590
|
+
db.exec(`
|
|
591
|
+
CREATE INDEX IF NOT EXISTS conversations_session_key_active_created_idx
|
|
592
|
+
ON conversations (session_key, active, created_at)
|
|
593
|
+
`);
|
|
594
|
+
db.exec(`DROP INDEX IF EXISTS conversations_session_key_idx`);
|
|
583
595
|
ensureSummaryDepthColumn(db);
|
|
584
596
|
ensureSummaryMetadataColumns(db);
|
|
585
597
|
ensureSummaryModelColumn(db);
|
|
@@ -649,4 +661,29 @@ export function runLcmMigrations(
|
|
|
649
661
|
SELECT summary_id, content FROM summaries;
|
|
650
662
|
`);
|
|
651
663
|
}
|
|
664
|
+
|
|
665
|
+
// ── CJK trigram FTS table ────────────────────────────────────────────────
|
|
666
|
+
// FTS5 unicode61 (porter) tokenizer cannot segment CJK ideographs, so CJK
|
|
667
|
+
// queries currently fall back to a LIKE path with AND logic. When the user's
|
|
668
|
+
// phrasing doesn't match the summary verbatim (e.g. "端到端测试结果" vs
|
|
669
|
+
// "端到端测试"), ALL terms must match and the query returns 0 candidates.
|
|
670
|
+
//
|
|
671
|
+
// A trigram-tokenized table indexes every 3-character substring, enabling
|
|
672
|
+
// native CJK substring matching via FTS5 MATCH with OR semantics.
|
|
673
|
+
const cjkTableExists = db
|
|
674
|
+
.prepare(
|
|
675
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='summaries_fts_cjk'",
|
|
676
|
+
)
|
|
677
|
+
.get();
|
|
678
|
+
if (!cjkTableExists) {
|
|
679
|
+
db.exec(`
|
|
680
|
+
CREATE VIRTUAL TABLE summaries_fts_cjk USING fts5(
|
|
681
|
+
summary_id UNINDEXED,
|
|
682
|
+
content,
|
|
683
|
+
tokenize='trigram'
|
|
684
|
+
);
|
|
685
|
+
INSERT INTO summaries_fts_cjk(summary_id, content)
|
|
686
|
+
SELECT summary_id, content FROM summaries;
|
|
687
|
+
`);
|
|
688
|
+
}
|
|
652
689
|
}
|