@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/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 !== undefined ? parseFloat(env.LCM_CONTEXT_THRESHOLD) : undefined)
173
+ parseFiniteNumber(env.LCM_CONTEXT_THRESHOLD)
146
174
  ?? toNumber(pc.contextThreshold) ?? 0.75,
147
175
  freshTailCount:
148
- (env.LCM_FRESH_TAIL_COUNT !== undefined ? parseInt(env.LCM_FRESH_TAIL_COUNT, 10) : undefined)
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 !== undefined ? parseInt(env.LCM_LEAF_MIN_FANOUT, 10) : undefined)
182
+ parseFiniteInt(env.LCM_LEAF_MIN_FANOUT)
152
183
  ?? toNumber(pc.leafMinFanout) ?? 8,
153
184
  condensedMinFanout:
154
- (env.LCM_CONDENSED_MIN_FANOUT !== undefined ? parseInt(env.LCM_CONDENSED_MIN_FANOUT, 10) : undefined)
185
+ parseFiniteInt(env.LCM_CONDENSED_MIN_FANOUT)
155
186
  ?? toNumber(pc.condensedMinFanout) ?? 4,
156
187
  condensedMinFanoutHard:
157
- (env.LCM_CONDENSED_MIN_FANOUT_HARD !== undefined ? parseInt(env.LCM_CONDENSED_MIN_FANOUT_HARD, 10) : undefined)
188
+ parseFiniteInt(env.LCM_CONDENSED_MIN_FANOUT_HARD)
158
189
  ?? toNumber(pc.condensedMinFanoutHard) ?? 2,
159
190
  incrementalMaxDepth:
160
- (env.LCM_INCREMENTAL_MAX_DEPTH !== undefined ? parseInt(env.LCM_INCREMENTAL_MAX_DEPTH, 10) : undefined)
191
+ parseFiniteInt(env.LCM_INCREMENTAL_MAX_DEPTH)
161
192
  ?? toNumber(pc.incrementalMaxDepth) ?? 1,
162
- leafChunkTokens:
163
- (env.LCM_LEAF_CHUNK_TOKENS !== undefined ? parseInt(env.LCM_LEAF_CHUNK_TOKENS, 10) : undefined)
164
- ?? toNumber(pc.leafChunkTokens) ?? 20000,
193
+ leafChunkTokens: resolvedLeafChunkTokens,
194
+ bootstrapMaxTokens: resolvedBootstrapMaxTokens,
165
195
  leafTargetTokens:
166
- (env.LCM_LEAF_TARGET_TOKENS !== undefined ? parseInt(env.LCM_LEAF_TARGET_TOKENS, 10) : undefined)
196
+ parseFiniteInt(env.LCM_LEAF_TARGET_TOKENS)
167
197
  ?? toNumber(pc.leafTargetTokens) ?? 2400,
168
198
  condensedTargetTokens:
169
- (env.LCM_CONDENSED_TARGET_TOKENS !== undefined ? parseInt(env.LCM_CONDENSED_TARGET_TOKENS, 10) : undefined)
199
+ parseFiniteInt(env.LCM_CONDENSED_TARGET_TOKENS)
170
200
  ?? toNumber(pc.condensedTargetTokens) ?? 2000,
171
201
  maxExpandTokens:
172
- (env.LCM_MAX_EXPAND_TOKENS !== undefined ? parseInt(env.LCM_MAX_EXPAND_TOKENS, 10) : undefined)
202
+ parseFiniteInt(env.LCM_MAX_EXPAND_TOKENS)
173
203
  ?? toNumber(pc.maxExpandTokens) ?? 4000,
174
204
  largeFileTokenThreshold:
175
- (env.LCM_LARGE_FILE_TOKEN_THRESHOLD !== undefined ? parseInt(env.LCM_LARGE_FILE_TOKEN_THRESHOLD, 10) : undefined)
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 !== undefined ? parseInt(env.LCM_MAX_ASSEMBLY_TOKEN_BUDGET, 10) : undefined)
228
+ parseFiniteInt(env.LCM_MAX_ASSEMBLY_TOKEN_BUDGET)
203
229
  ?? toNumber(pc.maxAssemblyTokenBudget) ?? undefined,
204
230
  summaryMaxOverageFactor:
205
- (env.LCM_SUMMARY_MAX_OVERAGE_FACTOR !== undefined ? parseFloat(env.LCM_SUMMARY_MAX_OVERAGE_FACTOR) : undefined)
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
  }
@@ -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
- if (typeof value !== "string" || !value.trim()) {
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
- db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS conversations_session_key_idx ON conversations (session_key)`);
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
  }