@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/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 0). */
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 (just a file path, no text): content is replaced
1090
- * with "[Media attachment]" or "[Image attachment]" etc.
1091
- * - Media-mostly messages (any real text + attachment): content is annotated
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
- // Strip MEDIA:/... paths to see how much actual text remains
1108
- const textWithoutPaths = content
1109
- .split("\n")
1110
- .filter((line) => !MEDIA_PATH_RE.test(line.trim()))
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 (textWithoutPaths.length === 0) {
1115
- // Media-only: replace with descriptive annotation
1313
+ if (!meaningfulText) {
1116
1314
  return "[Media attachment]";
1117
1315
  }
1118
-
1119
- // Media-mostly: keep the text, add annotation
1120
- return `${textWithoutPaths} [with media attachment]`;
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
- autocompactDisabled: boolean;
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 !== undefined ? parseFloat(env.LCM_CONTEXT_THRESHOLD) : undefined)
173
+ parseFiniteNumber(env.LCM_CONTEXT_THRESHOLD)
134
174
  ?? toNumber(pc.contextThreshold) ?? 0.75,
135
175
  freshTailCount:
136
- (env.LCM_FRESH_TAIL_COUNT !== undefined ? parseInt(env.LCM_FRESH_TAIL_COUNT, 10) : undefined)
137
- ?? toNumber(pc.freshTailCount) ?? 32,
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 !== undefined ? parseInt(env.LCM_LEAF_MIN_FANOUT, 10) : undefined)
182
+ parseFiniteInt(env.LCM_LEAF_MIN_FANOUT)
140
183
  ?? toNumber(pc.leafMinFanout) ?? 8,
141
184
  condensedMinFanout:
142
- (env.LCM_CONDENSED_MIN_FANOUT !== undefined ? parseInt(env.LCM_CONDENSED_MIN_FANOUT, 10) : undefined)
185
+ parseFiniteInt(env.LCM_CONDENSED_MIN_FANOUT)
143
186
  ?? toNumber(pc.condensedMinFanout) ?? 4,
144
187
  condensedMinFanoutHard:
145
- (env.LCM_CONDENSED_MIN_FANOUT_HARD !== undefined ? parseInt(env.LCM_CONDENSED_MIN_FANOUT_HARD, 10) : undefined)
188
+ parseFiniteInt(env.LCM_CONDENSED_MIN_FANOUT_HARD)
146
189
  ?? toNumber(pc.condensedMinFanoutHard) ?? 2,
147
190
  incrementalMaxDepth:
148
- (env.LCM_INCREMENTAL_MAX_DEPTH !== undefined ? parseInt(env.LCM_INCREMENTAL_MAX_DEPTH, 10) : undefined)
149
- ?? toNumber(pc.incrementalMaxDepth) ?? 0,
150
- leafChunkTokens:
151
- (env.LCM_LEAF_CHUNK_TOKENS !== undefined ? parseInt(env.LCM_LEAF_CHUNK_TOKENS, 10) : undefined)
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 !== undefined ? parseInt(env.LCM_LEAF_TARGET_TOKENS, 10) : undefined)
155
- ?? toNumber(pc.leafTargetTokens) ?? 1200,
196
+ parseFiniteInt(env.LCM_LEAF_TARGET_TOKENS)
197
+ ?? toNumber(pc.leafTargetTokens) ?? 2400,
156
198
  condensedTargetTokens:
157
- (env.LCM_CONDENSED_TARGET_TOKENS !== undefined ? parseInt(env.LCM_CONDENSED_TARGET_TOKENS, 10) : undefined)
199
+ parseFiniteInt(env.LCM_CONDENSED_TARGET_TOKENS)
158
200
  ?? toNumber(pc.condensedTargetTokens) ?? 2000,
159
201
  maxExpandTokens:
160
- (env.LCM_MAX_EXPAND_TOKENS !== undefined ? parseInt(env.LCM_MAX_EXPAND_TOKENS, 10) : undefined)
202
+ parseFiniteInt(env.LCM_MAX_EXPAND_TOKENS)
161
203
  ?? toNumber(pc.maxExpandTokens) ?? 4000,
162
204
  largeFileTokenThreshold:
163
- (env.LCM_LARGE_FILE_TOKEN_THRESHOLD !== undefined ? parseInt(env.LCM_LARGE_FILE_TOKEN_THRESHOLD, 10) : undefined)
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
- autocompactDisabled:
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
  }
@@ -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
  }