openclaw-topic-shift-reset 0.1.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/index.ts ADDED
@@ -0,0 +1,1405 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { OpenClawPluginApi, PluginHookMessageReceivedEvent } from "openclaw/plugin-sdk";
5
+ import {
6
+ readJsonFileWithFallback,
7
+ withFileLock,
8
+ writeJsonFileAtomically,
9
+ } from "openclaw/plugin-sdk";
10
+
11
+ type PresetName = "conservative" | "balanced" | "aggressive";
12
+ type EmbeddingProvider = "auto" | "none" | "openai" | "ollama";
13
+ type HandoffMode = "none" | "summary" | "verbatim_last_n";
14
+ type HandoffPreference = "none" | "summary" | "verbatim";
15
+
16
+ type EmbeddingConfig = {
17
+ provider?: EmbeddingProvider;
18
+ model?: string;
19
+ baseUrl?: string;
20
+ apiKey?: string;
21
+ timeoutMs?: number;
22
+ };
23
+
24
+ type TopicShiftResetAdvancedConfig = {
25
+ historyWindow?: number;
26
+ minHistoryMessages?: number;
27
+ minMeaningfulTokens?: number;
28
+ minTokenLength?: number;
29
+ minSignalChars?: number;
30
+ minSignalTokenCount?: number;
31
+ minSignalEntropy?: number;
32
+ stripEnvelope?: boolean;
33
+ softConsecutiveSignals?: number;
34
+ cooldownMinutes?: number;
35
+ ignoredProviders?: string[];
36
+ softScoreThreshold?: number;
37
+ hardScoreThreshold?: number;
38
+ softSimilarityThreshold?: number;
39
+ hardSimilarityThreshold?: number;
40
+ softNoveltyThreshold?: number;
41
+ hardNoveltyThreshold?: number;
42
+ handoff?: HandoffPreference | HandoffMode;
43
+ handoffLastN?: number;
44
+ handoffMaxChars?: number;
45
+ embeddings?: EmbeddingProvider;
46
+ embedding?: EmbeddingConfig;
47
+ };
48
+
49
+ type TopicShiftResetConfig = {
50
+ enabled?: boolean;
51
+ preset?: PresetName;
52
+ embeddings?: EmbeddingProvider;
53
+ handoff?: HandoffPreference;
54
+ dryRun?: boolean;
55
+ debug?: boolean;
56
+ advanced?: TopicShiftResetAdvancedConfig;
57
+ };
58
+
59
+ type ResolvedConfig = {
60
+ enabled: boolean;
61
+ historyWindow: number;
62
+ minHistoryMessages: number;
63
+ minMeaningfulTokens: number;
64
+ minTokenLength: number;
65
+ minSignalChars: number;
66
+ minSignalTokenCount: number;
67
+ minSignalEntropy: number;
68
+ stripEnvelope: boolean;
69
+ softConsecutiveSignals: number;
70
+ cooldownMinutes: number;
71
+ ignoredProviders: Set<string>;
72
+ softScoreThreshold: number;
73
+ hardScoreThreshold: number;
74
+ softSimilarityThreshold: number;
75
+ hardSimilarityThreshold: number;
76
+ softNoveltyThreshold: number;
77
+ hardNoveltyThreshold: number;
78
+ handoffMode: HandoffMode;
79
+ handoffLastN: number;
80
+ handoffMaxChars: number;
81
+ embedding: {
82
+ provider: EmbeddingProvider;
83
+ model?: string;
84
+ baseUrl?: string;
85
+ apiKey?: string;
86
+ timeoutMs: number;
87
+ };
88
+ dryRun: boolean;
89
+ debug: boolean;
90
+ };
91
+
92
+ type SessionEntryLike = {
93
+ sessionId?: string;
94
+ updatedAt?: number;
95
+ systemSent?: boolean;
96
+ abortedLastRun?: boolean;
97
+ inputTokens?: number;
98
+ outputTokens?: number;
99
+ totalTokens?: number;
100
+ totalTokensFresh?: boolean;
101
+ sessionFile?: string;
102
+ [key: string]: unknown;
103
+ };
104
+
105
+ type HistoryEntry = {
106
+ tokens: Set<string>;
107
+ embedding?: number[];
108
+ at: number;
109
+ };
110
+
111
+ type SessionState = {
112
+ history: HistoryEntry[];
113
+ pendingSoftSignals: number;
114
+ pendingEntries: HistoryEntry[];
115
+ lastResetAt?: number;
116
+ lastSeenAt: number;
117
+ };
118
+
119
+ type ClassifierMetrics = {
120
+ score: number;
121
+ novelty: number;
122
+ lexicalDistance: number;
123
+ similarity?: number;
124
+ usedEmbedding: boolean;
125
+ pendingSoftSignals: number;
126
+ };
127
+
128
+ type ClassificationDecision =
129
+ | { kind: "warmup" | "stable" | "suspect"; metrics: ClassifierMetrics; reason: string }
130
+ | { kind: "rotate-hard" | "rotate-soft"; metrics: ClassifierMetrics; reason: string };
131
+
132
+ type EmbeddingBackend = {
133
+ name: string;
134
+ embed: (text: string) => Promise<number[] | null>;
135
+ };
136
+
137
+ type ResolvedFastSession = {
138
+ sessionKey: string;
139
+ routeKind: "direct" | "group" | "thread";
140
+ };
141
+
142
+ type TranscriptMessage = {
143
+ role: string;
144
+ text: string;
145
+ };
146
+
147
+ type PresetConfig = {
148
+ historyWindow: number;
149
+ minHistoryMessages: number;
150
+ minMeaningfulTokens: number;
151
+ minTokenLength: number;
152
+ softConsecutiveSignals: number;
153
+ cooldownMinutes: number;
154
+ softScoreThreshold: number;
155
+ hardScoreThreshold: number;
156
+ softSimilarityThreshold: number;
157
+ hardSimilarityThreshold: number;
158
+ softNoveltyThreshold: number;
159
+ hardNoveltyThreshold: number;
160
+ };
161
+
162
+ const PRESETS = {
163
+ conservative: {
164
+ historyWindow: 12,
165
+ minHistoryMessages: 4,
166
+ minMeaningfulTokens: 7,
167
+ minTokenLength: 2,
168
+ softConsecutiveSignals: 3,
169
+ cooldownMinutes: 10,
170
+ softScoreThreshold: 0.8,
171
+ hardScoreThreshold: 0.92,
172
+ softSimilarityThreshold: 0.3,
173
+ hardSimilarityThreshold: 0.18,
174
+ softNoveltyThreshold: 0.66,
175
+ hardNoveltyThreshold: 0.8,
176
+ },
177
+ balanced: {
178
+ historyWindow: 10,
179
+ minHistoryMessages: 3,
180
+ minMeaningfulTokens: 6,
181
+ minTokenLength: 2,
182
+ softConsecutiveSignals: 2,
183
+ cooldownMinutes: 5,
184
+ softScoreThreshold: 0.72,
185
+ hardScoreThreshold: 0.86,
186
+ softSimilarityThreshold: 0.36,
187
+ hardSimilarityThreshold: 0.24,
188
+ softNoveltyThreshold: 0.58,
189
+ hardNoveltyThreshold: 0.74,
190
+ },
191
+ aggressive: {
192
+ historyWindow: 8,
193
+ minHistoryMessages: 2,
194
+ minMeaningfulTokens: 5,
195
+ minTokenLength: 2,
196
+ softConsecutiveSignals: 1,
197
+ cooldownMinutes: 2,
198
+ softScoreThreshold: 0.64,
199
+ hardScoreThreshold: 0.78,
200
+ softSimilarityThreshold: 0.46,
201
+ hardSimilarityThreshold: 0.34,
202
+ softNoveltyThreshold: 0.48,
203
+ hardNoveltyThreshold: 0.6,
204
+ },
205
+ } satisfies Record<PresetName, PresetConfig>;
206
+
207
+ const DEFAULTS = {
208
+ enabled: true,
209
+ preset: "balanced" as PresetName,
210
+ handoff: "summary" as HandoffPreference,
211
+ handoffLastN: 6,
212
+ handoffMaxChars: 220,
213
+ embeddingProvider: "auto" as EmbeddingProvider,
214
+ embeddingTimeoutMs: 7000,
215
+ minSignalChars: 20,
216
+ minSignalTokenCount: 3,
217
+ minSignalEntropy: 1.2,
218
+ stripEnvelope: true,
219
+ dryRun: false,
220
+ debug: false,
221
+ } as const;
222
+
223
+ const LOCK_OPTIONS = {
224
+ retries: {
225
+ retries: 8,
226
+ factor: 1.35,
227
+ minTimeout: 20,
228
+ maxTimeout: 250,
229
+ randomize: true,
230
+ },
231
+ stale: 45_000,
232
+ } as const;
233
+
234
+ const MAX_TRACKED_SESSIONS = 10_000;
235
+ const STALE_SESSION_STATE_MS = 4 * 60 * 60 * 1000;
236
+ const MAX_RECENT_FAST_EVENTS = 20_000;
237
+ const FAST_EVENT_TTL_MS = 5 * 60 * 1000;
238
+ const ROTATION_DEDUPE_MS = 25_000;
239
+
240
+ function clampInt(value: unknown, fallback: number, min: number, max: number): number {
241
+ if (typeof value !== "number" || !Number.isFinite(value)) {
242
+ return fallback;
243
+ }
244
+ const n = Math.floor(value);
245
+ if (n < min) {
246
+ return min;
247
+ }
248
+ if (n > max) {
249
+ return max;
250
+ }
251
+ return n;
252
+ }
253
+
254
+ function clampFloat(value: unknown, fallback: number, min: number, max: number): number {
255
+ if (typeof value !== "number" || !Number.isFinite(value)) {
256
+ return fallback;
257
+ }
258
+ if (value < min) {
259
+ return min;
260
+ }
261
+ if (value > max) {
262
+ return max;
263
+ }
264
+ return value;
265
+ }
266
+
267
+ function pickDefined<T>(...values: Array<T | undefined>): T | undefined {
268
+ for (const value of values) {
269
+ if (value !== undefined) {
270
+ return value;
271
+ }
272
+ }
273
+ return undefined;
274
+ }
275
+
276
+ function normalizePreset(value: unknown): PresetName {
277
+ if (typeof value !== "string") {
278
+ return DEFAULTS.preset;
279
+ }
280
+ const normalized = value.trim().toLowerCase();
281
+ if (normalized === "conservative" || normalized === "balanced" || normalized === "aggressive") {
282
+ return normalized;
283
+ }
284
+ return DEFAULTS.preset;
285
+ }
286
+
287
+ function normalizeEmbeddingProvider(value: unknown): EmbeddingProvider {
288
+ if (typeof value !== "string") {
289
+ return DEFAULTS.embeddingProvider;
290
+ }
291
+ const normalized = value.trim().toLowerCase();
292
+ if (
293
+ normalized === "auto" ||
294
+ normalized === "none" ||
295
+ normalized === "openai" ||
296
+ normalized === "ollama"
297
+ ) {
298
+ return normalized;
299
+ }
300
+ return DEFAULTS.embeddingProvider;
301
+ }
302
+
303
+ function normalizeHandoffPreference(value: unknown): HandoffPreference {
304
+ if (typeof value !== "string") {
305
+ return DEFAULTS.handoff;
306
+ }
307
+ const normalized = value.trim().toLowerCase();
308
+ if (normalized === "none" || normalized === "summary") {
309
+ return normalized;
310
+ }
311
+ if (normalized === "verbatim" || normalized === "verbatim_last_n") {
312
+ return "verbatim";
313
+ }
314
+ return DEFAULTS.handoff;
315
+ }
316
+
317
+ function resolveConfig(raw: unknown): ResolvedConfig {
318
+ const obj = raw && typeof raw === "object" ? (raw as TopicShiftResetConfig) : {};
319
+ const advanced =
320
+ obj.advanced && typeof obj.advanced === "object"
321
+ ? (obj.advanced as TopicShiftResetAdvancedConfig)
322
+ : {};
323
+ const advancedEmbedding =
324
+ advanced.embedding && typeof advanced.embedding === "object"
325
+ ? (advanced.embedding as EmbeddingConfig)
326
+ : {};
327
+
328
+ const preset = normalizePreset(obj.preset);
329
+ const presetConfig = PRESETS[preset];
330
+
331
+ const ignoredProviders = new Set(
332
+ Array.isArray(advanced.ignoredProviders)
333
+ ? advanced.ignoredProviders
334
+ .map((value) => (typeof value === "string" ? value.trim().toLowerCase() : ""))
335
+ .filter(Boolean)
336
+ : [],
337
+ );
338
+
339
+ const handoffPreference = normalizeHandoffPreference(pickDefined(advanced.handoff, obj.handoff));
340
+ const handoffMode: HandoffMode =
341
+ handoffPreference === "verbatim" ? "verbatim_last_n" : handoffPreference;
342
+
343
+ return {
344
+ enabled: obj.enabled ?? DEFAULTS.enabled,
345
+ historyWindow: clampInt(advanced.historyWindow, presetConfig.historyWindow, 2, 40),
346
+ minHistoryMessages: clampInt(advanced.minHistoryMessages, presetConfig.minHistoryMessages, 1, 30),
347
+ minMeaningfulTokens: clampInt(
348
+ advanced.minMeaningfulTokens,
349
+ presetConfig.minMeaningfulTokens,
350
+ 2,
351
+ 60,
352
+ ),
353
+ minTokenLength: clampInt(advanced.minTokenLength, presetConfig.minTokenLength, 1, 8),
354
+ minSignalChars: clampInt(
355
+ advanced.minSignalChars,
356
+ DEFAULTS.minSignalChars,
357
+ 1,
358
+ 500,
359
+ ),
360
+ minSignalTokenCount: clampInt(
361
+ advanced.minSignalTokenCount,
362
+ DEFAULTS.minSignalTokenCount,
363
+ 1,
364
+ 60,
365
+ ),
366
+ minSignalEntropy: clampFloat(
367
+ advanced.minSignalEntropy,
368
+ DEFAULTS.minSignalEntropy,
369
+ 0,
370
+ 8,
371
+ ),
372
+ stripEnvelope: advanced.stripEnvelope ?? DEFAULTS.stripEnvelope,
373
+ softConsecutiveSignals: clampInt(
374
+ advanced.softConsecutiveSignals,
375
+ presetConfig.softConsecutiveSignals,
376
+ 1,
377
+ 4,
378
+ ),
379
+ cooldownMinutes: clampInt(advanced.cooldownMinutes, presetConfig.cooldownMinutes, 0, 240),
380
+ ignoredProviders,
381
+ softScoreThreshold: clampFloat(advanced.softScoreThreshold, presetConfig.softScoreThreshold, 0, 1),
382
+ hardScoreThreshold: clampFloat(advanced.hardScoreThreshold, presetConfig.hardScoreThreshold, 0, 1),
383
+ softSimilarityThreshold: clampFloat(
384
+ advanced.softSimilarityThreshold,
385
+ presetConfig.softSimilarityThreshold,
386
+ 0,
387
+ 1,
388
+ ),
389
+ hardSimilarityThreshold: clampFloat(
390
+ advanced.hardSimilarityThreshold,
391
+ presetConfig.hardSimilarityThreshold,
392
+ 0,
393
+ 1,
394
+ ),
395
+ softNoveltyThreshold: clampFloat(
396
+ advanced.softNoveltyThreshold,
397
+ presetConfig.softNoveltyThreshold,
398
+ 0,
399
+ 1,
400
+ ),
401
+ hardNoveltyThreshold: clampFloat(
402
+ advanced.hardNoveltyThreshold,
403
+ presetConfig.hardNoveltyThreshold,
404
+ 0,
405
+ 1,
406
+ ),
407
+ handoffMode,
408
+ handoffLastN: clampInt(advanced.handoffLastN, DEFAULTS.handoffLastN, 1, 20),
409
+ handoffMaxChars: clampInt(advanced.handoffMaxChars, DEFAULTS.handoffMaxChars, 60, 800),
410
+ embedding: {
411
+ provider: normalizeEmbeddingProvider(
412
+ pickDefined(advanced.embeddings, advancedEmbedding.provider, obj.embeddings),
413
+ ),
414
+ model: (() => {
415
+ const rawModel = advancedEmbedding.model;
416
+ return typeof rawModel === "string" ? rawModel.trim() : undefined;
417
+ })(),
418
+ baseUrl: (() => {
419
+ const rawBaseUrl = advancedEmbedding.baseUrl;
420
+ return typeof rawBaseUrl === "string" ? rawBaseUrl.trim() : undefined;
421
+ })(),
422
+ apiKey: (() => {
423
+ const rawApiKey = advancedEmbedding.apiKey;
424
+ return typeof rawApiKey === "string" ? rawApiKey.trim() : undefined;
425
+ })(),
426
+ timeoutMs: clampInt(advancedEmbedding.timeoutMs, DEFAULTS.embeddingTimeoutMs, 1000, 30_000),
427
+ },
428
+ dryRun: obj.dryRun ?? DEFAULTS.dryRun,
429
+ debug: obj.debug ?? DEFAULTS.debug,
430
+ };
431
+ }
432
+
433
+
434
+ function hashString(input: string): string {
435
+ let h1 = 0x811c9dc5;
436
+ for (let i = 0; i < input.length; i += 1) {
437
+ h1 ^= input.charCodeAt(i);
438
+ h1 = Math.imul(h1, 0x01000193);
439
+ }
440
+ return (h1 >>> 0).toString(16);
441
+ }
442
+
443
+ function normalizeTextForHash(text: string): string {
444
+ return text
445
+ .normalize("NFKC")
446
+ .toLowerCase()
447
+ .replace(/\s+/g, " ")
448
+ .trim();
449
+ }
450
+
451
+ function tokenizeList(text: string, minTokenLength: number): string[] {
452
+ const normalized = text
453
+ .normalize("NFKC")
454
+ .toLowerCase()
455
+ .replace(/https?:\/\/\S+/g, " ");
456
+ const out: string[] = [];
457
+ for (const token of normalized.match(/[\p{L}\p{N}][\p{L}\p{N}_-]*/gu) ?? []) {
458
+ if (token.length < minTokenLength) {
459
+ continue;
460
+ }
461
+ out.push(token);
462
+ }
463
+ return out;
464
+ }
465
+
466
+ function tokenize(text: string, minTokenLength: number): Set<string> {
467
+ return new Set(tokenizeList(text, minTokenLength));
468
+ }
469
+
470
+ function tokenEntropy(tokens: string[]): number {
471
+ if (tokens.length === 0) {
472
+ return 0;
473
+ }
474
+ const counts = new Map<string, number>();
475
+ for (const token of tokens) {
476
+ counts.set(token, (counts.get(token) ?? 0) + 1);
477
+ }
478
+ const total = tokens.length;
479
+ let entropy = 0;
480
+ for (const count of counts.values()) {
481
+ const p = count / total;
482
+ entropy -= p * Math.log2(p);
483
+ }
484
+ return entropy;
485
+ }
486
+
487
+ function stripClassifierEnvelope(text: string): string {
488
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
489
+ const kept: string[] = [];
490
+ let skipFence = false;
491
+ let expectingMetadataFence = false;
492
+
493
+ for (const line of lines) {
494
+ const trimmed = line.trim();
495
+ if (skipFence) {
496
+ if (trimmed.startsWith("```")) {
497
+ skipFence = false;
498
+ }
499
+ continue;
500
+ }
501
+
502
+ if (
503
+ trimmed === "Conversation info (untrusted metadata):" ||
504
+ trimmed === "Replied message (untrusted, for context):"
505
+ ) {
506
+ expectingMetadataFence = true;
507
+ continue;
508
+ }
509
+
510
+ if (expectingMetadataFence && trimmed.startsWith("```")) {
511
+ skipFence = true;
512
+ expectingMetadataFence = false;
513
+ continue;
514
+ }
515
+
516
+ expectingMetadataFence = false;
517
+
518
+ if (
519
+ trimmed.startsWith("System: [") ||
520
+ trimmed.startsWith("Current time:") ||
521
+ trimmed.startsWith("Read HEARTBEAT.md if it exists") ||
522
+ trimmed.startsWith("To send an image back, prefer the message tool") ||
523
+ trimmed.startsWith("[media attached:")
524
+ ) {
525
+ continue;
526
+ }
527
+
528
+ kept.push(line);
529
+ }
530
+
531
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
532
+ }
533
+
534
+ function unionTokens(entries: HistoryEntry[]): Set<string> {
535
+ const combined = new Set<string>();
536
+ for (const entry of entries) {
537
+ for (const token of entry.tokens) {
538
+ combined.add(token);
539
+ }
540
+ }
541
+ return combined;
542
+ }
543
+
544
+ function jaccardSimilarity(left: Set<string>, right: Set<string>): number {
545
+ if (left.size === 0 && right.size === 0) {
546
+ return 1;
547
+ }
548
+ if (left.size === 0 || right.size === 0) {
549
+ return 0;
550
+ }
551
+ let overlap = 0;
552
+ for (const token of left) {
553
+ if (right.has(token)) {
554
+ overlap += 1;
555
+ }
556
+ }
557
+ const unionSize = left.size + right.size - overlap;
558
+ if (unionSize <= 0) {
559
+ return 0;
560
+ }
561
+ return overlap / unionSize;
562
+ }
563
+
564
+ function noveltyRatio(current: Set<string>, baseline: Set<string>): number {
565
+ if (current.size === 0) {
566
+ return 0;
567
+ }
568
+ let unseen = 0;
569
+ for (const token of current) {
570
+ if (!baseline.has(token)) {
571
+ unseen += 1;
572
+ }
573
+ }
574
+ return unseen / current.size;
575
+ }
576
+
577
+ function cosineSimilarity(a: number[], b: number[]): number | undefined {
578
+ if (a.length === 0 || b.length === 0 || a.length !== b.length) {
579
+ return undefined;
580
+ }
581
+ let dot = 0;
582
+ let normA = 0;
583
+ let normB = 0;
584
+ for (let i = 0; i < a.length; i += 1) {
585
+ dot += a[i] * b[i];
586
+ normA += a[i] * a[i];
587
+ normB += b[i] * b[i];
588
+ }
589
+ if (normA === 0 || normB === 0) {
590
+ return undefined;
591
+ }
592
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
593
+ }
594
+
595
+ function centroid(vectors: number[][]): number[] | undefined {
596
+ if (vectors.length === 0) {
597
+ return undefined;
598
+ }
599
+ const dim = vectors[0]?.length ?? 0;
600
+ if (!dim) {
601
+ return undefined;
602
+ }
603
+ for (const vector of vectors) {
604
+ if (vector.length !== dim) {
605
+ return undefined;
606
+ }
607
+ }
608
+ const out = new Array<number>(dim).fill(0);
609
+ for (const vector of vectors) {
610
+ for (let i = 0; i < dim; i += 1) {
611
+ out[i] += vector[i];
612
+ }
613
+ }
614
+ for (let i = 0; i < dim; i += 1) {
615
+ out[i] /= vectors.length;
616
+ }
617
+ return out;
618
+ }
619
+
620
+ function trimHistory(entries: HistoryEntry[], limit: number): HistoryEntry[] {
621
+ if (entries.length <= limit) {
622
+ return entries;
623
+ }
624
+ return entries.slice(entries.length - limit);
625
+ }
626
+
627
+ function findStoreKey(store: Record<string, SessionEntryLike>, sessionKey: string): string | undefined {
628
+ if (store[sessionKey]) {
629
+ return sessionKey;
630
+ }
631
+ const normalized = sessionKey.toLowerCase();
632
+ return Object.keys(store).find((key) => key.toLowerCase() === normalized);
633
+ }
634
+
635
+ function looksLikeGroup(value?: string): boolean {
636
+ const candidate = (value ?? "").toLowerCase();
637
+ if (!candidate) {
638
+ return false;
639
+ }
640
+ return (
641
+ candidate.includes(":group:") ||
642
+ candidate.includes(":channel:") ||
643
+ candidate.endsWith("@g.us") ||
644
+ candidate.includes("thread")
645
+ );
646
+ }
647
+
648
+ function inferFastPeer(event: PluginHookMessageReceivedEvent, ctx: { conversationId?: string }) {
649
+ const from = event.from?.trim() ?? "";
650
+ const conversationId = ctx.conversationId?.trim() || from;
651
+ const metadata = (event.metadata ?? {}) as Record<string, unknown>;
652
+ const threadIdRaw = metadata.threadId;
653
+ const threadId =
654
+ typeof threadIdRaw === "string"
655
+ ? threadIdRaw.trim()
656
+ : typeof threadIdRaw === "number"
657
+ ? String(threadIdRaw)
658
+ : "";
659
+
660
+ if (threadId) {
661
+ return {
662
+ kind: "thread" as const,
663
+ id: `${conversationId || from}:thread:${threadId}`,
664
+ };
665
+ }
666
+
667
+ const kind = looksLikeGroup(conversationId) || looksLikeGroup(from) ? "group" : "direct";
668
+ return {
669
+ kind,
670
+ id: conversationId || from || "unknown",
671
+ };
672
+ }
673
+
674
+ function maybeJson(value: unknown): string {
675
+ try {
676
+ return JSON.stringify(value);
677
+ } catch {
678
+ return String(value);
679
+ }
680
+ }
681
+
682
+ function createOpenAiBackend(cfg: ResolvedConfig): EmbeddingBackend | null {
683
+ const apiKey = cfg.embedding.apiKey || process.env.OPENAI_API_KEY;
684
+ if (!apiKey) {
685
+ return null;
686
+ }
687
+ const model = cfg.embedding.model || "text-embedding-3-small";
688
+ const baseUrl = (cfg.embedding.baseUrl || process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").replace(/\/$/, "");
689
+ const endpoint = `${baseUrl}/embeddings`;
690
+
691
+ return {
692
+ name: `openai:${model}`,
693
+ embed: async (text: string) => {
694
+ const response = await fetch(endpoint, {
695
+ method: "POST",
696
+ headers: {
697
+ "Content-Type": "application/json",
698
+ Authorization: `Bearer ${apiKey}`,
699
+ },
700
+ body: JSON.stringify({ model, input: text }),
701
+ signal: AbortSignal.timeout(cfg.embedding.timeoutMs),
702
+ });
703
+ if (!response.ok) {
704
+ const body = await response.text().catch(() => "");
705
+ throw new Error(`openai embeddings failed (${response.status}): ${body.slice(0, 240)}`);
706
+ }
707
+ const payload = (await response.json()) as {
708
+ data?: Array<{ embedding?: number[] }>;
709
+ };
710
+ const vector = payload.data?.[0]?.embedding;
711
+ if (!Array.isArray(vector) || vector.length === 0) {
712
+ throw new Error("openai embeddings returned empty vector");
713
+ }
714
+ return vector;
715
+ },
716
+ };
717
+ }
718
+
719
+ function createOllamaBackend(cfg: ResolvedConfig): EmbeddingBackend {
720
+ const baseUrl = (cfg.embedding.baseUrl || process.env.OLLAMA_HOST || "http://127.0.0.1:11434").replace(/\/$/, "");
721
+ const model = cfg.embedding.model || "nomic-embed-text";
722
+ const endpoint = `${baseUrl}/api/embeddings`;
723
+
724
+ return {
725
+ name: `ollama:${model}`,
726
+ embed: async (text: string) => {
727
+ const response = await fetch(endpoint, {
728
+ method: "POST",
729
+ headers: {
730
+ "Content-Type": "application/json",
731
+ },
732
+ body: JSON.stringify({ model, prompt: text }),
733
+ signal: AbortSignal.timeout(cfg.embedding.timeoutMs),
734
+ });
735
+ if (!response.ok) {
736
+ const body = await response.text().catch(() => "");
737
+ throw new Error(`ollama embeddings failed (${response.status}): ${body.slice(0, 240)}`);
738
+ }
739
+ const payload = (await response.json()) as { embedding?: number[] };
740
+ if (!Array.isArray(payload.embedding) || payload.embedding.length === 0) {
741
+ throw new Error("ollama embeddings returned empty vector");
742
+ }
743
+ return payload.embedding;
744
+ },
745
+ };
746
+ }
747
+
748
+ function resolveEmbeddingBackend(cfg: ResolvedConfig): EmbeddingBackend | null {
749
+ if (cfg.embedding.provider === "none") {
750
+ return null;
751
+ }
752
+ if (cfg.embedding.provider === "openai") {
753
+ return createOpenAiBackend(cfg);
754
+ }
755
+ if (cfg.embedding.provider === "ollama") {
756
+ return createOllamaBackend(cfg);
757
+ }
758
+
759
+ const openai = createOpenAiBackend(cfg);
760
+ if (openai) {
761
+ return openai;
762
+ }
763
+ return createOllamaBackend(cfg);
764
+ }
765
+
766
+ function classifyMessage(params: {
767
+ cfg: ResolvedConfig;
768
+ state: SessionState;
769
+ entry: HistoryEntry;
770
+ now: number;
771
+ }): ClassificationDecision {
772
+ const { cfg, state, entry, now } = params;
773
+ const baselineEntries = state.history;
774
+ const baselineTokens = unionTokens(baselineEntries);
775
+
776
+ const lexicalSimilarity = jaccardSimilarity(entry.tokens, baselineTokens);
777
+ const lexicalDistance = 1 - lexicalSimilarity;
778
+ const novelty = noveltyRatio(entry.tokens, baselineTokens);
779
+
780
+ const baseVectors = baselineEntries
781
+ .map((item) => item.embedding)
782
+ .filter((vector): vector is number[] => Array.isArray(vector) && vector.length > 0);
783
+ const baseCentroid = centroid(baseVectors);
784
+ const similarity =
785
+ entry.embedding && baseCentroid ? cosineSimilarity(entry.embedding, baseCentroid) : undefined;
786
+ const usedEmbedding = typeof similarity === "number";
787
+
788
+ const score = usedEmbedding
789
+ ? 0.7 * (1 - similarity) + 0.15 * lexicalDistance + 0.15 * novelty
790
+ : 0.55 * lexicalDistance + 0.45 * novelty;
791
+
792
+ const metrics = {
793
+ score,
794
+ novelty,
795
+ lexicalDistance,
796
+ similarity,
797
+ usedEmbedding,
798
+ pendingSoftSignals: state.pendingSoftSignals,
799
+ } satisfies ClassifierMetrics;
800
+
801
+ if (
802
+ baselineEntries.length < cfg.minHistoryMessages ||
803
+ baselineTokens.size < cfg.minMeaningfulTokens
804
+ ) {
805
+ return { kind: "warmup", metrics, reason: "warmup" };
806
+ }
807
+
808
+ const cooldownMs = cfg.cooldownMinutes * 60_000;
809
+ const cooldownActive =
810
+ cooldownMs > 0 && typeof state.lastResetAt === "number" && now - state.lastResetAt < cooldownMs;
811
+ if (cooldownActive) {
812
+ return { kind: "stable", metrics, reason: "cooldown" };
813
+ }
814
+
815
+ const hardSignal =
816
+ score >= cfg.hardScoreThreshold ||
817
+ ((typeof similarity === "number" ? similarity <= cfg.hardSimilarityThreshold : false) &&
818
+ novelty >= cfg.hardNoveltyThreshold);
819
+
820
+ if (hardSignal) {
821
+ return { kind: "rotate-hard", metrics, reason: "hard-threshold" };
822
+ }
823
+
824
+ const softSignal =
825
+ score >= cfg.softScoreThreshold ||
826
+ ((typeof similarity === "number" ? similarity <= cfg.softSimilarityThreshold : false) &&
827
+ novelty >= cfg.softNoveltyThreshold) ||
828
+ (!usedEmbedding && novelty >= cfg.softNoveltyThreshold && lexicalDistance >= 0.45);
829
+
830
+ if (!softSignal) {
831
+ return { kind: "stable", metrics, reason: "stable" };
832
+ }
833
+
834
+ const nextPending = state.pendingSoftSignals + 1;
835
+ if (nextPending >= cfg.softConsecutiveSignals) {
836
+ return { kind: "rotate-soft", metrics, reason: "soft-confirmed" };
837
+ }
838
+
839
+ return { kind: "suspect", metrics, reason: "soft-suspect" };
840
+ }
841
+
842
+ function extractTextFromMessageContent(content: unknown): string {
843
+ if (typeof content === "string") {
844
+ return content.trim();
845
+ }
846
+ if (!Array.isArray(content)) {
847
+ return "";
848
+ }
849
+ const chunks: string[] = [];
850
+ for (const item of content) {
851
+ if (!item || typeof item !== "object") {
852
+ continue;
853
+ }
854
+ const record = item as Record<string, unknown>;
855
+ const text =
856
+ typeof record.text === "string"
857
+ ? record.text
858
+ : typeof record.input_text === "string"
859
+ ? record.input_text
860
+ : "";
861
+ if (text.trim()) {
862
+ chunks.push(text.trim());
863
+ }
864
+ }
865
+ return chunks.join("\n").trim();
866
+ }
867
+
868
+ function resolveSessionFilePathFromEntry(params: {
869
+ storePath: string;
870
+ entry?: SessionEntryLike;
871
+ }): string | null {
872
+ const entry = params.entry;
873
+ if (!entry || typeof entry !== "object") {
874
+ return null;
875
+ }
876
+ const sessionsDir = path.dirname(params.storePath);
877
+ const sessionFile = typeof entry.sessionFile === "string" ? entry.sessionFile.trim() : "";
878
+ if (sessionFile) {
879
+ return path.isAbsolute(sessionFile) ? sessionFile : path.resolve(sessionsDir, sessionFile);
880
+ }
881
+ const sessionId = typeof entry.sessionId === "string" ? entry.sessionId.trim() : "";
882
+ if (!sessionId) {
883
+ return null;
884
+ }
885
+ return path.resolve(sessionsDir, `${sessionId}.jsonl`);
886
+ }
887
+
888
+ async function readTranscriptTail(params: {
889
+ sessionFile: string;
890
+ takeLast: number;
891
+ }): Promise<TranscriptMessage[]> {
892
+ const raw = await fs.readFile(params.sessionFile, "utf-8");
893
+ const lines = raw.split("\n");
894
+ const messages: TranscriptMessage[] = [];
895
+
896
+ for (const line of lines) {
897
+ const trimmed = line.trim();
898
+ if (!trimmed) {
899
+ continue;
900
+ }
901
+ let parsed: unknown;
902
+ try {
903
+ parsed = JSON.parse(trimmed);
904
+ } catch {
905
+ continue;
906
+ }
907
+ if (!parsed || typeof parsed !== "object") {
908
+ continue;
909
+ }
910
+ const record = parsed as Record<string, unknown>;
911
+ if (record.type !== "message") {
912
+ continue;
913
+ }
914
+ const message = record.message as Record<string, unknown> | undefined;
915
+ if (!message || typeof message !== "object") {
916
+ continue;
917
+ }
918
+ const role = typeof message.role === "string" ? message.role.trim().toLowerCase() : "";
919
+ if (role !== "user" && role !== "assistant") {
920
+ continue;
921
+ }
922
+ const text = extractTextFromMessageContent(message.content);
923
+ if (!text) {
924
+ continue;
925
+ }
926
+ messages.push({ role, text });
927
+ }
928
+
929
+ if (messages.length <= params.takeLast) {
930
+ return messages;
931
+ }
932
+ return messages.slice(messages.length - params.takeLast);
933
+ }
934
+
935
+ function truncateText(text: string, maxChars: number): string {
936
+ const compact = text.replace(/\s+/g, " ").trim();
937
+ if (compact.length <= maxChars) {
938
+ return compact;
939
+ }
940
+ return `${compact.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
941
+ }
942
+
943
+ function formatHandoffEventText(params: {
944
+ mode: HandoffMode;
945
+ messages: TranscriptMessage[];
946
+ maxChars: number;
947
+ }): string | null {
948
+ if (params.mode === "none" || params.messages.length === 0) {
949
+ return null;
950
+ }
951
+
952
+ const lines = params.messages.map((message) => {
953
+ const role = message.role === "assistant" ? "assistant" : "user";
954
+ return `${role}: ${truncateText(message.text, params.maxChars)}`;
955
+ });
956
+
957
+ const header =
958
+ params.mode === "verbatim_last_n"
959
+ ? "Topic-shift handoff (last messages from previous session):"
960
+ : "Topic-shift handoff (compact context from previous session):";
961
+
962
+ return `${header}\n${lines.map((line) => `- ${line}`).join("\n")}`;
963
+ }
964
+
965
+ async function buildHandoffEventFromPreviousSession(params: {
966
+ cfg: ResolvedConfig;
967
+ storePath: string;
968
+ previousEntry?: SessionEntryLike;
969
+ logger: OpenClawPluginApi["logger"];
970
+ }): Promise<string | null> {
971
+ if (params.cfg.handoffMode === "none") {
972
+ return null;
973
+ }
974
+ const sessionFile = resolveSessionFilePathFromEntry({
975
+ storePath: params.storePath,
976
+ entry: params.previousEntry,
977
+ });
978
+ if (!sessionFile) {
979
+ return null;
980
+ }
981
+
982
+ try {
983
+ const tail = await readTranscriptTail({
984
+ sessionFile,
985
+ takeLast: params.cfg.handoffLastN,
986
+ });
987
+ return formatHandoffEventText({
988
+ mode: params.cfg.handoffMode,
989
+ messages: tail,
990
+ maxChars: params.cfg.handoffMaxChars,
991
+ });
992
+ } catch (error) {
993
+ params.logger.warn(
994
+ `topic-shift-reset: handoff read failed file=${sessionFile} err=${String(error)}`,
995
+ );
996
+ return null;
997
+ }
998
+ }
999
+
1000
+ function pruneStateMaps(stateBySession: Map<string, SessionState>): void {
1001
+ const now = Date.now();
1002
+ for (const [sessionKey, state] of stateBySession) {
1003
+ if (now - state.lastSeenAt > STALE_SESSION_STATE_MS) {
1004
+ stateBySession.delete(sessionKey);
1005
+ }
1006
+ }
1007
+ if (stateBySession.size <= MAX_TRACKED_SESSIONS) {
1008
+ return;
1009
+ }
1010
+ const ordered = [...stateBySession.entries()].sort((a, b) => a[1].lastSeenAt - b[1].lastSeenAt);
1011
+ const toDrop = stateBySession.size - MAX_TRACKED_SESSIONS;
1012
+ for (let i = 0; i < toDrop; i += 1) {
1013
+ const sessionKey = ordered[i]?.[0];
1014
+ if (sessionKey) {
1015
+ stateBySession.delete(sessionKey);
1016
+ }
1017
+ }
1018
+ }
1019
+
1020
+ function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number): void {
1021
+ const now = Date.now();
1022
+ for (const [key, ts] of map) {
1023
+ if (now - ts > ttlMs) {
1024
+ map.delete(key);
1025
+ }
1026
+ }
1027
+ if (map.size <= maxSize) {
1028
+ return;
1029
+ }
1030
+ const ordered = [...map.entries()].sort((a, b) => a[1] - b[1]);
1031
+ const toDrop = map.size - maxSize;
1032
+ for (let i = 0; i < toDrop; i += 1) {
1033
+ const key = ordered[i]?.[0];
1034
+ if (key) {
1035
+ map.delete(key);
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ async function rotateSessionEntry(params: {
1041
+ api: OpenClawPluginApi;
1042
+ cfg: ResolvedConfig;
1043
+ sessionKey: string;
1044
+ agentId?: string;
1045
+ source: "fast" | "fallback";
1046
+ reason: string;
1047
+ metrics: ClassifierMetrics;
1048
+ entry: HistoryEntry;
1049
+ contentHash: string;
1050
+ state: SessionState;
1051
+ }): Promise<boolean> {
1052
+ const storePath = params.api.runtime.channel.session.resolveStorePath(params.api.config.session?.store, {
1053
+ agentId: params.agentId,
1054
+ });
1055
+
1056
+ if (params.cfg.dryRun) {
1057
+ params.state.lastResetAt = Date.now();
1058
+ params.state.pendingSoftSignals = 0;
1059
+ params.state.pendingEntries = [];
1060
+ params.state.history = trimHistory([params.entry], params.cfg.historyWindow);
1061
+ params.api.logger.info(
1062
+ [
1063
+ `topic-shift-reset: dry-run rotate`,
1064
+ `source=${params.source}`,
1065
+ `reason=${params.reason}`,
1066
+ `session=${params.sessionKey}`,
1067
+ `score=${params.metrics.score.toFixed(3)}`,
1068
+ `novelty=${params.metrics.novelty.toFixed(3)}`,
1069
+ `lex=${params.metrics.lexicalDistance.toFixed(3)}`,
1070
+ `sim=${typeof params.metrics.similarity === "number" ? params.metrics.similarity.toFixed(3) : "n/a"}`,
1071
+ ].join(" "),
1072
+ );
1073
+ return true;
1074
+ }
1075
+
1076
+ let rotated = false;
1077
+ let previousEntry: SessionEntryLike | undefined;
1078
+
1079
+ await withFileLock(storePath, LOCK_OPTIONS, async () => {
1080
+ const loaded = await readJsonFileWithFallback<Record<string, SessionEntryLike>>(storePath, {});
1081
+ const store = loaded.value;
1082
+ const storeKey = findStoreKey(store, params.sessionKey);
1083
+ if (!storeKey) {
1084
+ return;
1085
+ }
1086
+ const current = store[storeKey];
1087
+ if (!current || typeof current !== "object") {
1088
+ return;
1089
+ }
1090
+
1091
+ previousEntry = { ...current };
1092
+ const next: SessionEntryLike = {
1093
+ ...current,
1094
+ sessionId: randomUUID(),
1095
+ updatedAt: Date.now(),
1096
+ systemSent: false,
1097
+ abortedLastRun: false,
1098
+ inputTokens: 0,
1099
+ outputTokens: 0,
1100
+ totalTokens: 0,
1101
+ totalTokensFresh: true,
1102
+ };
1103
+ delete next.sessionFile;
1104
+
1105
+ store[storeKey] = next;
1106
+ await writeJsonFileAtomically(storePath, store);
1107
+ rotated = true;
1108
+ });
1109
+
1110
+ if (!rotated) {
1111
+ params.api.logger.warn(`topic-shift-reset: rotate failed no-session-entry session=${params.sessionKey}`);
1112
+ return false;
1113
+ }
1114
+
1115
+ const handoff = await buildHandoffEventFromPreviousSession({
1116
+ cfg: params.cfg,
1117
+ storePath,
1118
+ previousEntry,
1119
+ logger: params.api.logger,
1120
+ });
1121
+ if (handoff) {
1122
+ params.api.runtime.system.enqueueSystemEvent(handoff, {
1123
+ sessionKey: params.sessionKey,
1124
+ contextKey: `topic-shift-reset:${params.contentHash}`,
1125
+ });
1126
+ }
1127
+
1128
+ params.state.lastResetAt = Date.now();
1129
+ params.state.pendingSoftSignals = 0;
1130
+ params.state.pendingEntries = [];
1131
+ params.state.history = trimHistory([params.entry], params.cfg.historyWindow);
1132
+
1133
+ params.api.logger.warn(
1134
+ [
1135
+ `topic-shift-reset: rotated`,
1136
+ `source=${params.source}`,
1137
+ `reason=${params.reason}`,
1138
+ `session=${params.sessionKey}`,
1139
+ `score=${params.metrics.score.toFixed(3)}`,
1140
+ `novelty=${params.metrics.novelty.toFixed(3)}`,
1141
+ `lex=${params.metrics.lexicalDistance.toFixed(3)}`,
1142
+ `sim=${typeof params.metrics.similarity === "number" ? params.metrics.similarity.toFixed(3) : "n/a"}`,
1143
+ `handoff=${handoff ? "1" : "0"}`,
1144
+ ].join(" "),
1145
+ );
1146
+
1147
+ return true;
1148
+ }
1149
+
1150
+ export default function register(api: OpenClawPluginApi): void {
1151
+ const cfg = resolveConfig(api.pluginConfig);
1152
+ const sessionState = new Map<string, SessionState>();
1153
+ const recentFastEvents = new Map<string, number>();
1154
+ const recentRotationBySession = new Map<string, number>();
1155
+
1156
+ let embeddingBackend: EmbeddingBackend | null = null;
1157
+ let embeddingInitError: string | null = null;
1158
+ try {
1159
+ embeddingBackend = resolveEmbeddingBackend(cfg);
1160
+ } catch (error) {
1161
+ embeddingInitError = String(error);
1162
+ }
1163
+
1164
+ if (embeddingInitError) {
1165
+ api.logger.warn(`topic-shift-reset: embedding backend init failed: ${embeddingInitError}`);
1166
+ } else if (!embeddingBackend) {
1167
+ api.logger.warn("topic-shift-reset: embedding backend unavailable, using lexical-only mode");
1168
+ } else {
1169
+ api.logger.info(`topic-shift-reset: embedding backend ${embeddingBackend.name}`);
1170
+ }
1171
+
1172
+ const classifyAndMaybeRotate = async (params: {
1173
+ source: "fast" | "fallback";
1174
+ sessionKey: string;
1175
+ text: string;
1176
+ messageProvider?: string;
1177
+ agentId?: string;
1178
+ dedupeKey?: string;
1179
+ }) => {
1180
+ if (!cfg.enabled) {
1181
+ return;
1182
+ }
1183
+ const sessionKey = params.sessionKey.trim();
1184
+ if (!sessionKey) {
1185
+ return;
1186
+ }
1187
+
1188
+ const provider = params.messageProvider?.trim().toLowerCase();
1189
+ if (provider && cfg.ignoredProviders.has(provider)) {
1190
+ return;
1191
+ }
1192
+
1193
+ const rawText = params.text.trim();
1194
+ const text = cfg.stripEnvelope ? stripClassifierEnvelope(rawText) : rawText;
1195
+ if (!text || text.startsWith("/")) {
1196
+ return;
1197
+ }
1198
+
1199
+ const tokenList = tokenizeList(text, cfg.minTokenLength);
1200
+ const signalEntropy = tokenEntropy(tokenList);
1201
+ if (
1202
+ text.length < cfg.minSignalChars ||
1203
+ tokenList.length < cfg.minSignalTokenCount ||
1204
+ signalEntropy < cfg.minSignalEntropy
1205
+ ) {
1206
+ if (cfg.debug) {
1207
+ api.logger.info(
1208
+ [
1209
+ `topic-shift-reset: skip-low-signal`,
1210
+ `source=${params.source}`,
1211
+ `session=${sessionKey}`,
1212
+ `chars=${text.length}`,
1213
+ `tokens=${tokenList.length}`,
1214
+ `entropy=${signalEntropy.toFixed(3)}`,
1215
+ ].join(" "),
1216
+ );
1217
+ }
1218
+ return;
1219
+ }
1220
+
1221
+ const tokens = new Set(tokenList);
1222
+ if (tokens.size < cfg.minMeaningfulTokens) {
1223
+ return;
1224
+ }
1225
+
1226
+ const contentHash = hashString(normalizeTextForHash(text));
1227
+ const lastRotationAt = recentRotationBySession.get(`${sessionKey}:${contentHash}`);
1228
+ if (typeof lastRotationAt === "number" && Date.now() - lastRotationAt < ROTATION_DEDUPE_MS) {
1229
+ return;
1230
+ }
1231
+
1232
+ let embedding: number[] | undefined;
1233
+ if (embeddingBackend) {
1234
+ try {
1235
+ const vector = await embeddingBackend.embed(text);
1236
+ if (Array.isArray(vector) && vector.length > 0) {
1237
+ embedding = vector;
1238
+ }
1239
+ } catch (error) {
1240
+ api.logger.warn(`topic-shift-reset: embeddings error backend=${embeddingBackend.name} err=${String(error)}`);
1241
+ }
1242
+ }
1243
+
1244
+ const now = Date.now();
1245
+ const state =
1246
+ sessionState.get(sessionKey) ??
1247
+ ({
1248
+ history: [],
1249
+ pendingSoftSignals: 0,
1250
+ pendingEntries: [],
1251
+ lastResetAt: undefined,
1252
+ lastSeenAt: now,
1253
+ } satisfies SessionState);
1254
+ state.lastSeenAt = now;
1255
+
1256
+ const entry: HistoryEntry = { tokens, embedding, at: now };
1257
+ const decision = classifyMessage({ cfg, state, entry, now });
1258
+
1259
+ if (cfg.debug) {
1260
+ api.logger.info(
1261
+ [
1262
+ `topic-shift-reset: classify`,
1263
+ `source=${params.source}`,
1264
+ `kind=${decision.kind}`,
1265
+ `reason=${decision.reason}`,
1266
+ `session=${sessionKey}`,
1267
+ `score=${decision.metrics.score.toFixed(3)}`,
1268
+ `novelty=${decision.metrics.novelty.toFixed(3)}`,
1269
+ `lex=${decision.metrics.lexicalDistance.toFixed(3)}`,
1270
+ `sim=${typeof decision.metrics.similarity === "number" ? decision.metrics.similarity.toFixed(3) : "n/a"}`,
1271
+ `embed=${decision.metrics.usedEmbedding ? "1" : "0"}`,
1272
+ `pending=${state.pendingSoftSignals}`,
1273
+ ].join(" "),
1274
+ );
1275
+ }
1276
+
1277
+ if (decision.kind === "warmup") {
1278
+ state.pendingSoftSignals = 0;
1279
+ state.pendingEntries = [];
1280
+ state.history = trimHistory([...state.history, entry], cfg.historyWindow);
1281
+ sessionState.set(sessionKey, state);
1282
+ pruneStateMaps(sessionState);
1283
+ return;
1284
+ }
1285
+
1286
+ if (decision.kind === "stable") {
1287
+ const merged = [...state.history, ...state.pendingEntries, entry];
1288
+ state.pendingSoftSignals = 0;
1289
+ state.pendingEntries = [];
1290
+ state.history = trimHistory(merged, cfg.historyWindow);
1291
+ sessionState.set(sessionKey, state);
1292
+ pruneStateMaps(sessionState);
1293
+ return;
1294
+ }
1295
+
1296
+ if (decision.kind === "suspect") {
1297
+ state.pendingSoftSignals += 1;
1298
+ state.pendingEntries = trimHistory([...state.pendingEntries, entry], cfg.softConsecutiveSignals);
1299
+ sessionState.set(sessionKey, state);
1300
+ pruneStateMaps(sessionState);
1301
+ return;
1302
+ }
1303
+
1304
+ const rotated = await rotateSessionEntry({
1305
+ api,
1306
+ cfg,
1307
+ sessionKey,
1308
+ agentId: params.agentId,
1309
+ source: params.source,
1310
+ reason: decision.reason,
1311
+ metrics: {
1312
+ ...decision.metrics,
1313
+ pendingSoftSignals: state.pendingSoftSignals,
1314
+ },
1315
+ entry,
1316
+ contentHash,
1317
+ state,
1318
+ });
1319
+
1320
+ if (rotated) {
1321
+ recentRotationBySession.set(`${sessionKey}:${contentHash}`, Date.now());
1322
+ }
1323
+
1324
+ sessionState.set(sessionKey, state);
1325
+ pruneStateMaps(sessionState);
1326
+ pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
1327
+ };
1328
+
1329
+ api.on("message_received", async (event, ctx) => {
1330
+ if (!cfg.enabled) {
1331
+ return;
1332
+ }
1333
+ const channelId = ctx.channelId?.trim();
1334
+ if (!channelId) {
1335
+ return;
1336
+ }
1337
+
1338
+ const peer = inferFastPeer(event, { conversationId: ctx.conversationId });
1339
+ const text = event.content?.trim() ?? "";
1340
+ if (!text) {
1341
+ return;
1342
+ }
1343
+
1344
+ const fastEventKey = [
1345
+ channelId,
1346
+ ctx.accountId ?? "",
1347
+ peer.kind,
1348
+ peer.id,
1349
+ hashString(normalizeTextForHash(text)),
1350
+ ].join("|");
1351
+ const seenAt = recentFastEvents.get(fastEventKey);
1352
+ if (typeof seenAt === "number" && Date.now() - seenAt < FAST_EVENT_TTL_MS) {
1353
+ return;
1354
+ }
1355
+ recentFastEvents.set(fastEventKey, Date.now());
1356
+ pruneRecentMap(recentFastEvents, FAST_EVENT_TTL_MS, MAX_RECENT_FAST_EVENTS);
1357
+
1358
+ let resolved: ResolvedFastSession | null = null;
1359
+ try {
1360
+ const route = api.runtime.channel.routing.resolveAgentRoute({
1361
+ cfg: api.config,
1362
+ channel: channelId,
1363
+ accountId: ctx.accountId,
1364
+ peer,
1365
+ });
1366
+ resolved = {
1367
+ sessionKey: route.sessionKey,
1368
+ routeKind: peer.kind,
1369
+ };
1370
+ } catch (error) {
1371
+ if (cfg.debug) {
1372
+ api.logger.info(
1373
+ `topic-shift-reset: fast-route-skip channel=${channelId} peer=${maybeJson(peer)} err=${String(error)}`,
1374
+ );
1375
+ }
1376
+ return;
1377
+ }
1378
+
1379
+ await classifyAndMaybeRotate({
1380
+ source: "fast",
1381
+ sessionKey: resolved.sessionKey,
1382
+ text,
1383
+ messageProvider: channelId,
1384
+ dedupeKey: fastEventKey,
1385
+ });
1386
+ });
1387
+
1388
+ api.on("before_model_resolve", async (event, ctx) => {
1389
+ if (!cfg.enabled) {
1390
+ return;
1391
+ }
1392
+ const sessionKey = ctx.sessionKey?.trim();
1393
+ if (!sessionKey) {
1394
+ return;
1395
+ }
1396
+
1397
+ await classifyAndMaybeRotate({
1398
+ source: "fallback",
1399
+ sessionKey,
1400
+ text: event.prompt,
1401
+ messageProvider: ctx.messageProvider,
1402
+ agentId: ctx.agentId,
1403
+ });
1404
+ });
1405
+ }