@martian-engineering/lossless-claw 0.7.0 → 0.8.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/engine.ts CHANGED
@@ -63,6 +63,7 @@ import {
63
63
  import { SummaryStore } from "./store/summary-store.js";
64
64
  import { createLcmSummarizeFromLegacyParams, LcmProviderAuthError } from "./summarize.js";
65
65
  import type { LcmDependencies } from "./types.js";
66
+ import { estimateTokens } from "./estimate-tokens.js";
66
67
 
67
68
  type AgentMessage = Parameters<ContextEngine["ingest"]>[0]["message"];
68
69
  type AssembleResultWithSystemPrompt = AssembleResult & { systemPromptAddition?: string };
@@ -124,11 +125,6 @@ const DYNAMIC_ACTIVITY_HIGH_DOWNSHIFT_FACTOR = 0.75;
124
125
 
125
126
  // ── Helpers ──────────────────────────────────────────────────────────────────
126
127
 
127
- /** Rough token estimate: ~4 chars per token. */
128
- function estimateTokens(text: string): number {
129
- return Math.ceil(text.length / 4);
130
- }
131
-
132
128
  function toJson(value: unknown): string {
133
129
  const encoded = JSON.stringify(value);
134
130
  return typeof encoded === "string" ? encoded : "";
@@ -138,6 +134,10 @@ function safeString(value: unknown): string | undefined {
138
134
  return typeof value === "string" ? value : undefined;
139
135
  }
140
136
 
137
+ function formatDurationMs(durationMs: number): string {
138
+ return `${durationMs}ms`;
139
+ }
140
+
141
141
  function asRecord(value: unknown): Record<string, unknown> | undefined {
142
142
  return value && typeof value === "object" && !Array.isArray(value)
143
143
  ? (value as Record<string, unknown>)
@@ -1235,13 +1235,14 @@ export class LcmContextEngine implements ContextEngine {
1235
1235
  this.statelessSessionPatterns = compileSessionPatterns(this.config.statelessSessionPatterns);
1236
1236
  this.db = database;
1237
1237
 
1238
- this.fts5Available = getLcmDbFeatures(this.db).fts5Available;
1239
-
1240
1238
  // Run migrations eagerly at construction time so the schema exists
1241
1239
  // before any lifecycle hook fires.
1242
1240
  let migrationOk = false;
1241
+ const migrationStartedAt = Date.now();
1243
1242
  try {
1244
- runLcmMigrations(this.db, { fts5Available: this.fts5Available });
1243
+ runLcmMigrations(this.db, {
1244
+ log: this.deps.log,
1245
+ });
1245
1246
  this.migrated = true;
1246
1247
 
1247
1248
  // Verify tables were actually created
@@ -1254,16 +1255,21 @@ export class LcmContextEngine implements ContextEngine {
1254
1255
  );
1255
1256
  } else {
1256
1257
  migrationOk = true;
1258
+ this.deps.log.info(
1259
+ `[lcm] Migration run completed during engine init: duration=${formatDurationMs(Date.now() - migrationStartedAt)} fts5=${this.fts5Available}`,
1260
+ );
1257
1261
  this.deps.log.debug(
1258
1262
  `[lcm] Migration successful — ${tables.length} tables: ${tables.map((t) => t.name).join(", ")}`,
1259
1263
  );
1260
1264
  }
1261
1265
  } catch (err) {
1262
1266
  this.deps.log.error(
1263
- `[lcm] Migration failed: ${err instanceof Error ? err.message : String(err)}`,
1267
+ `[lcm] Migration failed after ${formatDurationMs(Date.now() - migrationStartedAt)}: ${err instanceof Error ? err.message : String(err)}`,
1264
1268
  );
1265
1269
  }
1266
1270
 
1271
+ this.fts5Available = getLcmDbFeatures(this.db).fts5Available;
1272
+
1267
1273
  // Only claim ownership of compaction when the DB is operational.
1268
1274
  // Without a working schema, ownsCompaction would disable the runtime's
1269
1275
  // built-in compaction safeguard and inflate the context budget.
@@ -1428,17 +1434,29 @@ export class LcmContextEngine implements ContextEngine {
1428
1434
  if (this.migrated) {
1429
1435
  return;
1430
1436
  }
1431
- runLcmMigrations(this.db, { fts5Available: this.fts5Available });
1437
+ const migrationStartedAt = Date.now();
1438
+ this.deps.log.info("[lcm] ensureMigrated: running migrations lazily");
1439
+ runLcmMigrations(this.db, {
1440
+ log: this.deps.log,
1441
+ });
1432
1442
  this.migrated = true;
1443
+ this.deps.log.info(
1444
+ `[lcm] ensureMigrated: completed in ${formatDurationMs(Date.now() - migrationStartedAt)}`,
1445
+ );
1433
1446
  }
1434
1447
 
1435
1448
  /**
1436
1449
  * Serialize mutating operations per stable session identity to prevent
1437
1450
  * ingest/compaction races across runtime UUID recycling.
1438
1451
  */
1439
- private async withSessionQueue<T>(queueKey: string, operation: () => Promise<T>): Promise<T> {
1452
+ private async withSessionQueue<T>(
1453
+ queueKey: string,
1454
+ operation: () => Promise<T>,
1455
+ options?: { operationName?: string; context?: string },
1456
+ ): Promise<T> {
1440
1457
  const entry = this.sessionOperationQueues.get(queueKey);
1441
1458
  const previous = entry?.promise ?? Promise.resolve();
1459
+ const queuedAhead = entry?.refCount ?? 0;
1442
1460
  let releaseQueue: () => void = () => {};
1443
1461
  const current = new Promise<void>((resolve) => {
1444
1462
  releaseQueue = resolve;
@@ -1452,7 +1470,15 @@ export class LcmContextEngine implements ContextEngine {
1452
1470
  this.sessionOperationQueues.set(queueKey, { promise: next, refCount: 1 });
1453
1471
  }
1454
1472
 
1473
+ const waitStartedAt = Date.now();
1455
1474
  await previous.catch(() => {});
1475
+ const waitMs = Date.now() - waitStartedAt;
1476
+ if (options?.operationName) {
1477
+ const detail = options.context ? ` ${options.context}` : "";
1478
+ this.deps.log.info(
1479
+ `[lcm] ${options.operationName}: session queue acquired queueKey=${queueKey} queuedAhead=${queuedAhead} wait=${formatDurationMs(waitMs)}${detail}`,
1480
+ );
1481
+ }
1456
1482
  try {
1457
1483
  return await operation();
1458
1484
  } finally {
@@ -2420,12 +2446,24 @@ export class LcmContextEngine implements ContextEngine {
2420
2446
  hasOverlap: boolean;
2421
2447
  }> {
2422
2448
  const { sessionId, conversationId, historicalMessages } = params;
2449
+ const startedAt = Date.now();
2450
+ const sessionContext = this.formatSessionLogContext({
2451
+ conversationId,
2452
+ sessionId,
2453
+ sessionKey: params.sessionKey,
2454
+ });
2423
2455
  if (historicalMessages.length === 0) {
2456
+ this.deps.log.info(
2457
+ `[lcm] reconcileSessionTail: skipped for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=0 reason=empty-history`,
2458
+ );
2424
2459
  return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
2425
2460
  }
2426
2461
 
2427
2462
  const latestDbMessage = await this.conversationStore.getLastMessage(conversationId);
2428
2463
  if (!latestDbMessage) {
2464
+ this.deps.log.info(
2465
+ `[lcm] reconcileSessionTail: skipped for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} reason=no-db-tail`,
2466
+ );
2429
2467
  return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
2430
2468
  }
2431
2469
 
@@ -2447,6 +2485,9 @@ export class LcmContextEngine implements ContextEngine {
2447
2485
  }
2448
2486
  }
2449
2487
  if (dbOccurrences === historicalOccurrences) {
2488
+ this.deps.log.info(
2489
+ `[lcm] reconcileSessionTail: fast path for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} importedMessages=0 overlap=true`,
2490
+ );
2450
2491
  return { blockedByImportCap: false, importedMessages: 0, hasOverlap: true };
2451
2492
  }
2452
2493
  }
@@ -2499,9 +2540,15 @@ export class LcmContextEngine implements ContextEngine {
2499
2540
  }
2500
2541
 
2501
2542
  if (anchorIndex < 0) {
2543
+ this.deps.log.info(
2544
+ `[lcm] reconcileSessionTail: no anchor for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} importedMessages=0 overlap=false`,
2545
+ );
2502
2546
  return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
2503
2547
  }
2504
2548
  if (anchorIndex >= historicalMessages.length - 1) {
2549
+ this.deps.log.info(
2550
+ `[lcm] reconcileSessionTail: anchor at tip for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} importedMessages=0 overlap=true`,
2551
+ );
2505
2552
  return { blockedByImportCap: false, importedMessages: 0, hasOverlap: true };
2506
2553
  }
2507
2554
 
@@ -2509,14 +2556,12 @@ export class LcmContextEngine implements ContextEngine {
2509
2556
 
2510
2557
  const existingDbCount = await this.conversationStore.getMessageCount(conversationId);
2511
2558
  if (existingDbCount > 0 && missingTail.length > Math.max(existingDbCount * 0.2, 50)) {
2512
- const sessionContext = this.formatSessionLogContext({
2513
- conversationId,
2514
- sessionId,
2515
- sessionKey: params.sessionKey,
2516
- });
2517
2559
  this.deps.log.warn(
2518
2560
  `[lcm] reconcileSessionTail: import cap exceeded for ${sessionContext} — would import ${missingTail.length} messages (existing: ${existingDbCount}). Aborting to prevent flood.`,
2519
2561
  );
2562
+ this.deps.log.info(
2563
+ `[lcm] reconcileSessionTail: blocked for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} missingTail=${missingTail.length} existingDbCount=${existingDbCount}`,
2564
+ );
2520
2565
  return { blockedByImportCap: true, importedMessages: 0, hasOverlap: true };
2521
2566
  }
2522
2567
 
@@ -2528,6 +2573,9 @@ export class LcmContextEngine implements ContextEngine {
2528
2573
  }
2529
2574
  }
2530
2575
 
2576
+ this.deps.log.info(
2577
+ `[lcm] reconcileSessionTail: slow path for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} anchorIndex=${anchorIndex} missingTail=${missingTail.length} importedMessages=${importedMessages}`,
2578
+ );
2531
2579
  return { blockedByImportCap: false, importedMessages, hasOverlap: true };
2532
2580
  }
2533
2581
 
@@ -2582,6 +2630,11 @@ export class LcmContextEngine implements ContextEngine {
2582
2630
  };
2583
2631
  }
2584
2632
  this.ensureMigrated();
2633
+ const startedAt = Date.now();
2634
+ const sessionLabel = [
2635
+ `session=${params.sessionId}`,
2636
+ ...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
2637
+ ].join(" ");
2585
2638
  const sessionFileStats = statSync(params.sessionFile);
2586
2639
  const sessionFileSize = sessionFileStats.size;
2587
2640
  const sessionFileMtimeMs = Math.trunc(sessionFileStats.mtimeMs);
@@ -2624,6 +2677,9 @@ export class LcmContextEngine implements ContextEngine {
2624
2677
  if (!conversation.bootstrappedAt) {
2625
2678
  await this.conversationStore.markConversationBootstrapped(conversationId);
2626
2679
  }
2680
+ this.deps.log.info(
2681
+ `[lcm] bootstrap: checkpoint hit conversation=${conversationId} ${sessionLabel} existingCount=${existingCount} duration=${formatDurationMs(Date.now() - startedAt)}`,
2682
+ );
2627
2683
  return {
2628
2684
  bootstrapped: false,
2629
2685
  importedMessages: 0,
@@ -2685,6 +2741,9 @@ export class LcmContextEngine implements ContextEngine {
2685
2741
  }
2686
2742
 
2687
2743
  await persistBootstrapState(conversationId);
2744
+ this.deps.log.info(
2745
+ `[lcm] bootstrap: append-only conversation=${conversationId} ${sessionLabel} existingCount=${existingCount} appendedMessages=${appended.messages.length} importedMessages=${importedMessages} duration=${formatDurationMs(Date.now() - startedAt)}`,
2746
+ );
2688
2747
 
2689
2748
  if (importedMessages > 0) {
2690
2749
  return {
@@ -2704,6 +2763,9 @@ export class LcmContextEngine implements ContextEngine {
2704
2763
  }
2705
2764
 
2706
2765
  const historicalMessages = await readLeafPathMessages(params.sessionFile);
2766
+ this.deps.log.info(
2767
+ `[lcm] bootstrap: full transcript read conversation=${conversationId} ${sessionLabel} existingCount=${existingCount} historicalMessages=${historicalMessages.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
2768
+ );
2707
2769
 
2708
2770
  // First-time import path: no LCM rows yet, so seed directly from the
2709
2771
  // active leaf context snapshot.
@@ -2753,6 +2815,9 @@ export class LcmContextEngine implements ContextEngine {
2753
2815
  }
2754
2816
 
2755
2817
  await persistBootstrapState(conversationId);
2818
+ this.deps.log.info(
2819
+ `[lcm] bootstrap: initial import conversation=${conversationId} ${sessionLabel} importedMessages=${inserted.length} sourceMessages=${historicalMessages.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
2820
+ );
2756
2821
 
2757
2822
  return {
2758
2823
  bootstrapped: true,
@@ -2768,6 +2833,9 @@ export class LcmContextEngine implements ContextEngine {
2768
2833
  conversationId,
2769
2834
  historicalMessages,
2770
2835
  });
2836
+ this.deps.log.info(
2837
+ `[lcm] bootstrap: reconcile finished conversation=${conversationId} ${sessionLabel} importedMessages=${reconcile.importedMessages} overlap=${reconcile.hasOverlap} blockedByImportCap=${reconcile.blockedByImportCap} duration=${formatDurationMs(Date.now() - startedAt)}`,
2838
+ );
2771
2839
 
2772
2840
  if (reconcile.blockedByImportCap) {
2773
2841
  return {
@@ -2810,6 +2878,7 @@ export class LcmContextEngine implements ContextEngine {
2810
2878
  : "conversation already has messages",
2811
2879
  };
2812
2880
  }),
2881
+ { operationName: "bootstrap", context: sessionLabel },
2813
2882
  );
2814
2883
 
2815
2884
  // Post-bootstrap pruning: clean HEARTBEAT_OK turns that were already
@@ -2839,6 +2908,9 @@ export class LcmContextEngine implements ContextEngine {
2839
2908
  }
2840
2909
  }
2841
2910
 
2911
+ this.deps.log.info(
2912
+ `[lcm] bootstrap: done ${sessionLabel} bootstrapped=${result.bootstrapped} importedMessages=${result.importedMessages} reason=${result.reason ?? "none"} duration=${formatDurationMs(Date.now() - startedAt)}`,
2913
+ );
2842
2914
  return result;
2843
2915
  }
2844
2916
 
@@ -2978,6 +3050,12 @@ export class LcmContextEngine implements ContextEngine {
2978
3050
  };
2979
3051
  }
2980
3052
 
3053
+ const rewriteTranscriptEntries = params.runtimeContext.rewriteTranscriptEntries;
3054
+ const startedAt = Date.now();
3055
+ const sessionLabel = [
3056
+ `session=${params.sessionId}`,
3057
+ ...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
3058
+ ].join(" ");
2981
3059
  return this.withSessionQueue(
2982
3060
  this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
2983
3061
  async () => {
@@ -2999,6 +3077,9 @@ export class LcmContextEngine implements ContextEngine {
2999
3077
  { limit: TRANSCRIPT_GC_BATCH_SIZE },
3000
3078
  );
3001
3079
  if (candidates.length === 0) {
3080
+ this.deps.log.info(
3081
+ `[lcm] maintain: no transcript GC candidates conversation=${conversation.conversationId} ${sessionLabel} duration=${formatDurationMs(Date.now() - startedAt)}`,
3082
+ );
3002
3083
  return {
3003
3084
  changed: false,
3004
3085
  bytesFreed: 0,
@@ -3034,6 +3115,9 @@ export class LcmContextEngine implements ContextEngine {
3034
3115
  }
3035
3116
 
3036
3117
  if (replacements.length === 0) {
3118
+ this.deps.log.info(
3119
+ `[lcm] maintain: no matching transcript entries conversation=${conversation.conversationId} ${sessionLabel} candidates=${candidates.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
3120
+ );
3037
3121
  return {
3038
3122
  changed: false,
3039
3123
  bytesFreed: 0,
@@ -3042,7 +3126,7 @@ export class LcmContextEngine implements ContextEngine {
3042
3126
  };
3043
3127
  }
3044
3128
 
3045
- const result = await params.runtimeContext.rewriteTranscriptEntries({
3129
+ const result = await rewriteTranscriptEntries({
3046
3130
  replacements,
3047
3131
  });
3048
3132
 
@@ -3059,8 +3143,12 @@ export class LcmContextEngine implements ContextEngine {
3059
3143
  }
3060
3144
  }
3061
3145
 
3146
+ this.deps.log.info(
3147
+ `[lcm] maintain: done conversation=${conversation.conversationId} ${sessionLabel} candidates=${candidates.length} replacements=${replacements.length} changed=${result.changed} rewrittenEntries=${result.rewrittenEntries} bytesFreed=${result.bytesFreed} duration=${formatDurationMs(Date.now() - startedAt)}`,
3148
+ );
3062
3149
  return result;
3063
3150
  },
3151
+ { operationName: "maintain", context: sessionLabel },
3064
3152
  );
3065
3153
  }
3066
3154
  private async ingestSingle(params: {
@@ -3073,6 +3161,34 @@ export class LcmContextEngine implements ContextEngine {
3073
3161
  if (isHeartbeat) {
3074
3162
  return { ingested: false };
3075
3163
  }
3164
+
3165
+ // Skip assistant messages that failed with an error and have no useful content.
3166
+ // These occur when an API call returns a 500 or similar transient error.
3167
+ // Ingesting them pollutes the LCM database: on retry, the error messages
3168
+ // accumulate and get assembled into context, creating a positive feedback
3169
+ // loop where each retry sends an increasingly large (and malformed) payload
3170
+ // that continues to fail.
3171
+ if (message.role === "assistant") {
3172
+ const topLevel = message as unknown as Record<string, unknown>;
3173
+ const stopReason =
3174
+ typeof topLevel.stopReason === "string"
3175
+ ? topLevel.stopReason
3176
+ : typeof topLevel.stop_reason === "string"
3177
+ ? topLevel.stop_reason
3178
+ : undefined;
3179
+ if (stopReason === "error" || stopReason === "aborted") {
3180
+ const content = topLevel.content;
3181
+ const isEmpty =
3182
+ content === undefined ||
3183
+ content === null ||
3184
+ content === "" ||
3185
+ (Array.isArray(content) && content.length === 0);
3186
+ if (isEmpty) {
3187
+ return { ingested: false };
3188
+ }
3189
+ }
3190
+ }
3191
+
3076
3192
  const stored = toStoredMessage(message);
3077
3193
 
3078
3194
  // Get or create conversation for this session
@@ -3153,6 +3269,13 @@ export class LcmContextEngine implements ContextEngine {
3153
3269
  return this.withSessionQueue(
3154
3270
  this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
3155
3271
  () => this.ingestSingle(params),
3272
+ {
3273
+ operationName: "ingest",
3274
+ context: [
3275
+ `session=${params.sessionId}`,
3276
+ ...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
3277
+ ].join(" "),
3278
+ },
3156
3279
  );
3157
3280
  }
3158
3281
 
@@ -3189,6 +3312,14 @@ export class LcmContextEngine implements ContextEngine {
3189
3312
  }
3190
3313
  return { ingestedCount };
3191
3314
  },
3315
+ {
3316
+ operationName: "ingestBatch",
3317
+ context: [
3318
+ `session=${params.sessionId}`,
3319
+ ...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
3320
+ `messages=${params.messages.length}`,
3321
+ ].join(" "),
3322
+ },
3192
3323
  );
3193
3324
  }
3194
3325
 
@@ -3213,6 +3344,11 @@ export class LcmContextEngine implements ContextEngine {
3213
3344
  return;
3214
3345
  }
3215
3346
  this.ensureMigrated();
3347
+ const startedAt = Date.now();
3348
+ const sessionLabel = [
3349
+ `session=${params.sessionId}`,
3350
+ ...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
3351
+ ].join(" ");
3216
3352
 
3217
3353
  // Dedup guard: prevent duplicate ingestion when gateway restart replays
3218
3354
  // full history. Run on newMessages BEFORE prepending autoCompactionSummary
@@ -3234,6 +3370,9 @@ export class LcmContextEngine implements ContextEngine {
3234
3370
 
3235
3371
  ingestBatch.push(...dedupedNewMessages);
3236
3372
  if (ingestBatch.length === 0) {
3373
+ this.deps.log.info(
3374
+ `[lcm] afterTurn: nothing to ingest ${sessionLabel} newMessages=${newMessages.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
3375
+ );
3237
3376
  return;
3238
3377
  }
3239
3378
 
@@ -3309,6 +3448,9 @@ export class LcmContextEngine implements ContextEngine {
3309
3448
  sessionKey: params.sessionKey,
3310
3449
  });
3311
3450
  if (!conversation) {
3451
+ this.deps.log.info(
3452
+ `[lcm] afterTurn: conversation lookup missed ${sessionLabel} ingestBatch=${ingestBatch.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
3453
+ );
3312
3454
  return;
3313
3455
  }
3314
3456
 
@@ -3366,6 +3508,10 @@ export class LcmContextEngine implements ContextEngine {
3366
3508
  } catch {
3367
3509
  // Proactive compaction is best-effort in the post-turn lifecycle.
3368
3510
  }
3511
+
3512
+ this.deps.log.info(
3513
+ `[lcm] afterTurn: done conversation=${conversation.conversationId} ${sessionLabel} newMessages=${newMessages.length} dedupedMessages=${dedupedNewMessages.length} ingestedMessages=${ingestBatch.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
3514
+ );
3369
3515
  }
3370
3516
 
3371
3517
  async assemble(params: {
@@ -3384,12 +3530,20 @@ export class LcmContextEngine implements ContextEngine {
3384
3530
  }
3385
3531
  try {
3386
3532
  this.ensureMigrated();
3533
+ const startedAt = Date.now();
3534
+ const sessionLabel = [
3535
+ `session=${params.sessionId}`,
3536
+ ...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
3537
+ ].join(" ");
3387
3538
 
3388
3539
  const conversation = await this.conversationStore.getConversationForSession({
3389
3540
  sessionId: params.sessionId,
3390
3541
  sessionKey: params.sessionKey,
3391
3542
  });
3392
3543
  if (!conversation) {
3544
+ this.deps.log.info(
3545
+ `[lcm] assemble: conversation lookup missed ${sessionLabel} duration=${formatDurationMs(Date.now() - startedAt)}`,
3546
+ );
3393
3547
  return {
3394
3548
  messages: params.messages,
3395
3549
  estimatedTokens: 0,
@@ -3398,6 +3552,9 @@ export class LcmContextEngine implements ContextEngine {
3398
3552
 
3399
3553
  const contextItems = await this.summaryStore.getContextItems(conversation.conversationId);
3400
3554
  if (contextItems.length === 0) {
3555
+ this.deps.log.info(
3556
+ `[lcm] assemble: no context items conversation=${conversation.conversationId} ${sessionLabel} duration=${formatDurationMs(Date.now() - startedAt)}`,
3557
+ );
3401
3558
  return {
3402
3559
  messages: params.messages,
3403
3560
  estimatedTokens: 0,
@@ -3409,6 +3566,9 @@ export class LcmContextEngine implements ContextEngine {
3409
3566
  // the live path to avoid dropping prompt context.
3410
3567
  const hasSummaryItems = contextItems.some((item) => item.itemType === "summary");
3411
3568
  if (!hasSummaryItems && contextItems.length < params.messages.length) {
3569
+ this.deps.log.info(
3570
+ `[lcm] assemble: falling back to live context conversation=${conversation.conversationId} ${sessionLabel} contextItems=${contextItems.length} liveMessages=${params.messages.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
3571
+ );
3412
3572
  return {
3413
3573
  messages: params.messages,
3414
3574
  estimatedTokens: 0,
@@ -3433,12 +3593,19 @@ export class LcmContextEngine implements ContextEngine {
3433
3593
  // If assembly produced no messages for a non-empty live session,
3434
3594
  // fail safe to the live context.
3435
3595
  if (assembled.messages.length === 0 && params.messages.length > 0) {
3596
+ this.deps.log.info(
3597
+ `[lcm] assemble: empty assembled output, using live context conversation=${conversation.conversationId} ${sessionLabel} contextItems=${contextItems.length} tokenBudget=${tokenBudget} duration=${formatDurationMs(Date.now() - startedAt)}`,
3598
+ );
3436
3599
  return {
3437
3600
  messages: params.messages,
3438
3601
  estimatedTokens: 0,
3439
3602
  };
3440
3603
  }
3441
3604
 
3605
+ this.deps.log.info(
3606
+ `[lcm] assemble: done conversation=${conversation.conversationId} ${sessionLabel} contextItems=${contextItems.length} hasSummaryItems=${hasSummaryItems} inputMessages=${params.messages.length} outputMessages=${assembled.messages.length} tokenBudget=${tokenBudget} estimatedTokens=${assembled.estimatedTokens} duration=${formatDurationMs(Date.now() - startedAt)}`,
3607
+ );
3608
+
3442
3609
  const result: AssembleResultWithSystemPrompt = {
3443
3610
  messages: assembled.messages,
3444
3611
  estimatedTokens: assembled.estimatedTokens,
@@ -3447,7 +3614,10 @@ export class LcmContextEngine implements ContextEngine {
3447
3614
  : {}),
3448
3615
  };
3449
3616
  return result;
3450
- } catch {
3617
+ } catch (err) {
3618
+ this.deps.log.info(
3619
+ `[lcm] assemble: failed for session=${params.sessionId}${params.sessionKey?.trim() ? ` sessionKey=${params.sessionKey.trim()}` : ""} error=${describeLogError(err)}`,
3620
+ );
3451
3621
  return {
3452
3622
  messages: params.messages,
3453
3623
  estimatedTokens: 0,
@@ -3850,11 +4020,21 @@ export class LcmContextEngine implements ContextEngine {
3850
4020
  ? decision.threshold
3851
4021
  : tokenBudget;
3852
4022
 
4023
+ // When forced (overflow recovery) and the caller did not supply an
4024
+ // observed token count, assume we are at least at the token budget so
4025
+ // compactUntilUnder does not bail with "already under target" while the
4026
+ // live context is actually overflowing.
4027
+ const effectiveCurrentTokens =
4028
+ observedTokens !== undefined
4029
+ ? observedTokens
4030
+ : forceCompaction
4031
+ ? tokenBudget
4032
+ : undefined;
3853
4033
  const compactResult = await this.compaction.compactUntilUnder({
3854
4034
  conversationId,
3855
4035
  tokenBudget,
3856
4036
  targetTokens: convergenceTargetTokens,
3857
- ...(observedTokens !== undefined ? { currentTokens: observedTokens } : {}),
4037
+ ...(effectiveCurrentTokens !== undefined ? { currentTokens: effectiveCurrentTokens } : {}),
3858
4038
  summarize,
3859
4039
  summaryModel,
3860
4040
  });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Shared token estimation utility.
3
+ *
4
+ * Uses code-point-aware weighting instead of `text.length / 4`:
5
+ * - CJK (Chinese/Japanese/Korean) characters: ~1.5 tokens/char
6
+ * - Emoji / Supplementary Plane: ~2 tokens/char
7
+ * - ASCII / Latin: ~0.25 tokens/char (≈ 4 chars/token)
8
+ *
9
+ * Why not `text.length / 4`?
10
+ * JavaScript `String.length` counts UTF-16 code units, not Unicode code points.
11
+ * CJK characters are 1 UTF-16 unit but ~1.5 tokens; emoji are 2 UTF-16 units
12
+ * (surrogate pairs) but ~2-4 tokens. The naive formula underestimates CJK by
13
+ * ~6× and emoji by ~2-4×, causing compaction to trigger far too late for
14
+ * non-English conversations.
15
+ */
16
+
17
+ /** Detect CJK code points across all relevant Unicode ranges. */
18
+ function isCjkCodePoint(cp: number): boolean {
19
+ return (
20
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
21
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
22
+ (cp >= 0x20000 && cp <= 0x2a6df) || // CJK Extension B
23
+ (cp >= 0x2a700 && cp <= 0x2b73f) || // CJK Extension C
24
+ (cp >= 0x2b740 && cp <= 0x2b81f) || // CJK Extension D
25
+ (cp >= 0x2b820 && cp <= 0x2ceaf) || // CJK Extension E
26
+ (cp >= 0x2ceb0 && cp <= 0x2ebef) || // CJK Extension F
27
+ (cp >= 0x3000 && cp <= 0x303f) || // CJK Symbols and Punctuation
28
+ (cp >= 0x3040 && cp <= 0x30ff) || // Hiragana + Katakana
29
+ (cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
30
+ (cp >= 0xff00 && cp <= 0xffef) // Fullwidth Forms
31
+ );
32
+ }
33
+
34
+ /** Estimate token cost for a single Unicode code point. */
35
+ function estimateCodePointTokens(cp: number): number {
36
+ if (isCjkCodePoint(cp)) {
37
+ return 1.5;
38
+ }
39
+ if (cp > 0xffff) {
40
+ return 2;
41
+ }
42
+ return 0.25;
43
+ }
44
+
45
+ /** Estimate text tokens using Unicode-aware character weighting. */
46
+ export function estimateTokens(text: string): number {
47
+ let tokens = 0;
48
+ for (const char of text) {
49
+ const cp = char.codePointAt(0) ?? 0;
50
+ tokens += estimateCodePointTokens(cp);
51
+ }
52
+ return Math.ceil(tokens);
53
+ }
54
+
55
+ /**
56
+ * Truncate text so the estimated token count stays within `maxTokens`.
57
+ *
58
+ * Iterates by Unicode code point to avoid splitting surrogate pairs while
59
+ * preserving the same weighting model as `estimateTokens()`.
60
+ */
61
+ export function truncateTextToEstimatedTokens(text: string, maxTokens: number): string {
62
+ if (maxTokens <= 0 || !text) {
63
+ return "";
64
+ }
65
+
66
+ let tokens = 0;
67
+ let end = 0;
68
+
69
+ for (const char of text) {
70
+ const cp = char.codePointAt(0) ?? 0;
71
+ const nextTokens = tokens + estimateCodePointTokens(cp);
72
+ if (Math.ceil(nextTokens) > maxTokens) {
73
+ break;
74
+ }
75
+ tokens = nextTokens;
76
+ end += char.length;
77
+ }
78
+
79
+ return text.slice(0, end);
80
+ }