@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/README.md +11 -3
- package/docs/agent-tools.md +9 -4
- package/docs/configuration.md +9 -0
- package/package.json +1 -1
- package/skills/lossless-claw/SKILL.md +3 -2
- package/skills/lossless-claw/references/architecture.md +12 -0
- package/skills/lossless-claw/references/diagnostics.md +13 -0
- package/src/assembler.ts +12 -4
- package/src/compaction.ts +12 -15
- package/src/db/connection.ts +15 -5
- package/src/db/features.ts +24 -5
- package/src/db/migration.ts +201 -79
- package/src/engine.ts +199 -19
- package/src/estimate-tokens.ts +80 -0
- package/src/plugin/index.ts +95 -18
- package/src/plugin/lcm-command.ts +278 -3
- package/src/plugin/lcm-doctor-apply.ts +1 -3
- package/src/plugin/lcm-doctor-cleaners.ts +655 -0
- package/src/retrieval.ts +1 -4
- package/src/summarize.ts +1 -4
- package/src/tools/lcm-expand-query-tool.ts +598 -194
- package/src/tools/lcm-grep-tool.ts +2 -2
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, {
|
|
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
|
-
|
|
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>(
|
|
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
|
|
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
|
-
...(
|
|
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
|
+
}
|