@martian-engineering/lossless-claw 0.5.3 → 0.6.1
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 +31 -1
- package/docs/configuration.md +23 -0
- package/openclaw.plugin.json +75 -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 +132 -36
- package/src/compaction.ts +22 -46
- package/src/db/config.ts +52 -20
- package/src/db/migration.ts +50 -13
- package/src/engine.ts +781 -172
- package/src/plugin/index.ts +45 -0
- 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 +380 -11
- package/src/summarize.ts +107 -20
- package/src/tools/lcm-expand-query-tool.ts +58 -25
- package/src/tools/lcm-expansion-recursion-guard.ts +87 -0
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;
|
|
@@ -39,7 +42,6 @@ export type LcmConfig = {
|
|
|
39
42
|
expansionModel: string;
|
|
40
43
|
/** Max time to wait for delegated lcm_expand_query sub-agent completion. */
|
|
41
44
|
delegationTimeoutMs: number;
|
|
42
|
-
autocompactDisabled: boolean;
|
|
43
45
|
/** IANA timezone for timestamps in summaries (from TZ env or system default) */
|
|
44
46
|
timezone: string;
|
|
45
47
|
/** When true, retroactively delete HEARTBEAT_OK turn cycles from LCM storage. */
|
|
@@ -50,6 +52,10 @@ export type LcmConfig = {
|
|
|
50
52
|
summaryMaxOverageFactor: number;
|
|
51
53
|
/** Custom instructions injected into all summarization prompts. */
|
|
52
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;
|
|
53
59
|
};
|
|
54
60
|
|
|
55
61
|
/** Safely coerce an unknown value to a finite number, or return undefined. */
|
|
@@ -62,6 +68,21 @@ function toNumber(value: unknown): number | undefined {
|
|
|
62
68
|
return undefined;
|
|
63
69
|
}
|
|
64
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
|
+
|
|
65
86
|
/** Safely coerce an unknown value to a boolean, or return undefined. */
|
|
66
87
|
function toBool(value: unknown): boolean | undefined {
|
|
67
88
|
if (typeof value === "boolean") return value;
|
|
@@ -108,6 +129,13 @@ export function resolveLcmConfig(
|
|
|
108
129
|
pluginConfig?: Record<string, unknown>,
|
|
109
130
|
): LcmConfig {
|
|
110
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));
|
|
111
139
|
const envDelegationTimeoutMs =
|
|
112
140
|
env.LCM_DELEGATION_TIMEOUT_MS !== undefined
|
|
113
141
|
? toNumber(env.LCM_DELEGATION_TIMEOUT_MS)
|
|
@@ -142,37 +170,39 @@ export function resolveLcmConfig(
|
|
|
142
170
|
? env.LCM_SKIP_STATELESS_SESSIONS === "true"
|
|
143
171
|
: toBool(pc.skipStatelessSessions) ?? true,
|
|
144
172
|
contextThreshold:
|
|
145
|
-
(env.LCM_CONTEXT_THRESHOLD
|
|
173
|
+
parseFiniteNumber(env.LCM_CONTEXT_THRESHOLD)
|
|
146
174
|
?? toNumber(pc.contextThreshold) ?? 0.75,
|
|
147
175
|
freshTailCount:
|
|
148
|
-
(env.LCM_FRESH_TAIL_COUNT
|
|
176
|
+
parseFiniteInt(env.LCM_FRESH_TAIL_COUNT)
|
|
149
177
|
?? toNumber(pc.freshTailCount) ?? 64,
|
|
178
|
+
newSessionRetainDepth:
|
|
179
|
+
parseFiniteInt(env.LCM_NEW_SESSION_RETAIN_DEPTH)
|
|
180
|
+
?? toNumber(pc.newSessionRetainDepth) ?? 2,
|
|
150
181
|
leafMinFanout:
|
|
151
|
-
(env.LCM_LEAF_MIN_FANOUT
|
|
182
|
+
parseFiniteInt(env.LCM_LEAF_MIN_FANOUT)
|
|
152
183
|
?? toNumber(pc.leafMinFanout) ?? 8,
|
|
153
184
|
condensedMinFanout:
|
|
154
|
-
(env.LCM_CONDENSED_MIN_FANOUT
|
|
185
|
+
parseFiniteInt(env.LCM_CONDENSED_MIN_FANOUT)
|
|
155
186
|
?? toNumber(pc.condensedMinFanout) ?? 4,
|
|
156
187
|
condensedMinFanoutHard:
|
|
157
|
-
(env.LCM_CONDENSED_MIN_FANOUT_HARD
|
|
188
|
+
parseFiniteInt(env.LCM_CONDENSED_MIN_FANOUT_HARD)
|
|
158
189
|
?? toNumber(pc.condensedMinFanoutHard) ?? 2,
|
|
159
190
|
incrementalMaxDepth:
|
|
160
|
-
(env.LCM_INCREMENTAL_MAX_DEPTH
|
|
191
|
+
parseFiniteInt(env.LCM_INCREMENTAL_MAX_DEPTH)
|
|
161
192
|
?? toNumber(pc.incrementalMaxDepth) ?? 1,
|
|
162
|
-
leafChunkTokens:
|
|
163
|
-
|
|
164
|
-
?? toNumber(pc.leafChunkTokens) ?? 20000,
|
|
193
|
+
leafChunkTokens: resolvedLeafChunkTokens,
|
|
194
|
+
bootstrapMaxTokens: resolvedBootstrapMaxTokens,
|
|
165
195
|
leafTargetTokens:
|
|
166
|
-
(env.LCM_LEAF_TARGET_TOKENS
|
|
196
|
+
parseFiniteInt(env.LCM_LEAF_TARGET_TOKENS)
|
|
167
197
|
?? toNumber(pc.leafTargetTokens) ?? 2400,
|
|
168
198
|
condensedTargetTokens:
|
|
169
|
-
(env.LCM_CONDENSED_TARGET_TOKENS
|
|
199
|
+
parseFiniteInt(env.LCM_CONDENSED_TARGET_TOKENS)
|
|
170
200
|
?? toNumber(pc.condensedTargetTokens) ?? 2000,
|
|
171
201
|
maxExpandTokens:
|
|
172
|
-
(env.LCM_MAX_EXPAND_TOKENS
|
|
202
|
+
parseFiniteInt(env.LCM_MAX_EXPAND_TOKENS)
|
|
173
203
|
?? toNumber(pc.maxExpandTokens) ?? 4000,
|
|
174
204
|
largeFileTokenThreshold:
|
|
175
|
-
(env.LCM_LARGE_FILE_TOKEN_THRESHOLD
|
|
205
|
+
parseFiniteInt(env.LCM_LARGE_FILE_TOKEN_THRESHOLD)
|
|
176
206
|
?? toNumber(pc.largeFileThresholdTokens)
|
|
177
207
|
?? toNumber(pc.largeFileTokenThreshold)
|
|
178
208
|
?? 25000,
|
|
@@ -189,22 +219,24 @@ export function resolveLcmConfig(
|
|
|
189
219
|
expansionModel:
|
|
190
220
|
env.LCM_EXPANSION_MODEL?.trim() ?? toStr(pc.expansionModel) ?? "",
|
|
191
221
|
delegationTimeoutMs: envDelegationTimeoutMs ?? toNumber(pc.delegationTimeoutMs) ?? 120000,
|
|
192
|
-
autocompactDisabled:
|
|
193
|
-
env.LCM_AUTOCOMPACT_DISABLED !== undefined
|
|
194
|
-
? env.LCM_AUTOCOMPACT_DISABLED === "true"
|
|
195
|
-
: toBool(pc.autocompactDisabled) ?? false,
|
|
196
222
|
timezone: env.TZ ?? toStr(pc.timezone) ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
197
223
|
pruneHeartbeatOk:
|
|
198
224
|
env.LCM_PRUNE_HEARTBEAT_OK !== undefined
|
|
199
225
|
? env.LCM_PRUNE_HEARTBEAT_OK === "true"
|
|
200
226
|
: toBool(pc.pruneHeartbeatOk) ?? false,
|
|
201
227
|
maxAssemblyTokenBudget:
|
|
202
|
-
(env.LCM_MAX_ASSEMBLY_TOKEN_BUDGET
|
|
228
|
+
parseFiniteInt(env.LCM_MAX_ASSEMBLY_TOKEN_BUDGET)
|
|
203
229
|
?? toNumber(pc.maxAssemblyTokenBudget) ?? undefined,
|
|
204
230
|
summaryMaxOverageFactor:
|
|
205
|
-
(env.LCM_SUMMARY_MAX_OVERAGE_FACTOR
|
|
231
|
+
parseFiniteNumber(env.LCM_SUMMARY_MAX_OVERAGE_FACTOR)
|
|
206
232
|
?? toNumber(pc.summaryMaxOverageFactor) ?? 3,
|
|
207
233
|
customInstructions:
|
|
208
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,
|
|
209
241
|
};
|
|
210
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
|
}
|