openclaw-topic-shift-reset 0.1.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import type { OpenClawPluginApi, PluginHookMessageReceivedEvent } from "openclaw/plugin-sdk";
4
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
5
5
  import {
6
6
  readJsonFileWithFallback,
7
7
  withFileLock,
@@ -11,7 +11,7 @@ import {
11
11
  type PresetName = "conservative" | "balanced" | "aggressive";
12
12
  type EmbeddingProvider = "auto" | "none" | "openai" | "ollama";
13
13
  type HandoffMode = "none" | "summary" | "verbatim_last_n";
14
- type HandoffPreference = "none" | "summary" | "verbatim";
14
+ type SoftSuspectAction = "none" | "ask";
15
15
 
16
16
  type EmbeddingConfig = {
17
17
  provider?: EmbeddingProvider;
@@ -21,6 +21,24 @@ type EmbeddingConfig = {
21
21
  timeoutMs?: number;
22
22
  };
23
23
 
24
+ type HandoffConfig = {
25
+ mode?: HandoffMode;
26
+ lastN?: number;
27
+ maxChars?: number;
28
+ };
29
+
30
+ type SoftSuspectConfig = {
31
+ action?: SoftSuspectAction;
32
+ prompt?: string;
33
+ ttlSeconds?: number;
34
+ };
35
+
36
+ type StripRulesConfig = {
37
+ dropLinePrefixPatterns?: string[];
38
+ dropExactLines?: string[];
39
+ dropFencedBlockAfterHeaderPatterns?: string[];
40
+ };
41
+
24
42
  type TopicShiftResetAdvancedConfig = {
25
43
  historyWindow?: number;
26
44
  minHistoryMessages?: number;
@@ -29,7 +47,12 @@ type TopicShiftResetAdvancedConfig = {
29
47
  minSignalChars?: number;
30
48
  minSignalTokenCount?: number;
31
49
  minSignalEntropy?: number;
50
+ minUniqueTokenRatio?: number;
51
+ shortMessageTokenLimit?: number;
52
+ embeddingTriggerMargin?: number;
32
53
  stripEnvelope?: boolean;
54
+ stripRules?: StripRulesConfig;
55
+ handoffTailReadMaxBytes?: number;
33
56
  softConsecutiveSignals?: number;
34
57
  cooldownMinutes?: number;
35
58
  ignoredProviders?: string[];
@@ -39,18 +62,14 @@ type TopicShiftResetAdvancedConfig = {
39
62
  hardSimilarityThreshold?: number;
40
63
  softNoveltyThreshold?: number;
41
64
  hardNoveltyThreshold?: number;
42
- handoff?: HandoffPreference | HandoffMode;
43
- handoffLastN?: number;
44
- handoffMaxChars?: number;
45
- embeddings?: EmbeddingProvider;
46
- embedding?: EmbeddingConfig;
47
65
  };
48
66
 
49
67
  type TopicShiftResetConfig = {
50
68
  enabled?: boolean;
51
69
  preset?: PresetName;
52
- embeddings?: EmbeddingProvider;
53
- handoff?: HandoffPreference;
70
+ embedding?: EmbeddingConfig;
71
+ handoff?: HandoffConfig;
72
+ softSuspect?: SoftSuspectConfig;
54
73
  dryRun?: boolean;
55
74
  debug?: boolean;
56
75
  advanced?: TopicShiftResetAdvancedConfig;
@@ -65,7 +84,16 @@ type ResolvedConfig = {
65
84
  minSignalChars: number;
66
85
  minSignalTokenCount: number;
67
86
  minSignalEntropy: number;
87
+ minUniqueTokenRatio: number;
88
+ shortMessageTokenLimit: number;
89
+ embeddingTriggerMargin: number;
68
90
  stripEnvelope: boolean;
91
+ stripRules: {
92
+ dropLinePrefixPatterns: RegExp[];
93
+ dropExactLines: Set<string>;
94
+ dropFencedBlockAfterHeaderPatterns: RegExp[];
95
+ };
96
+ handoffTailReadMaxBytes: number;
69
97
  softConsecutiveSignals: number;
70
98
  cooldownMinutes: number;
71
99
  ignoredProviders: Set<string>;
@@ -75,9 +103,16 @@ type ResolvedConfig = {
75
103
  hardSimilarityThreshold: number;
76
104
  softNoveltyThreshold: number;
77
105
  hardNoveltyThreshold: number;
78
- handoffMode: HandoffMode;
79
- handoffLastN: number;
80
- handoffMaxChars: number;
106
+ handoff: {
107
+ mode: HandoffMode;
108
+ lastN: number;
109
+ maxChars: number;
110
+ };
111
+ softSuspect: {
112
+ action: SoftSuspectAction;
113
+ prompt: string;
114
+ ttlMs: number;
115
+ };
81
116
  embedding: {
82
117
  provider: EmbeddingProvider;
83
118
  model?: string;
@@ -113,18 +148,55 @@ type SessionState = {
113
148
  pendingSoftSignals: number;
114
149
  pendingEntries: HistoryEntry[];
115
150
  lastResetAt?: number;
151
+ topicCentroid?: number[];
152
+ topicCount: number;
153
+ topicDim?: number;
154
+ lastSeenAt: number;
155
+ };
156
+
157
+ type PersistedHistoryEntry = {
158
+ tokens: string[];
159
+ at: number;
160
+ embedding?: number[];
161
+ };
162
+
163
+ type PersistedSessionState = {
164
+ history: PersistedHistoryEntry[];
165
+ pendingSoftSignals: number;
166
+ pendingEntries: PersistedHistoryEntry[];
167
+ lastResetAt?: number;
168
+ topicCentroid?: number[];
169
+ topicCount: number;
170
+ topicDim?: number;
116
171
  lastSeenAt: number;
117
172
  };
118
173
 
174
+ type PersistedRuntimeState = {
175
+ version: number;
176
+ savedAt: number;
177
+ sessionStateBySessionKey: Record<string, PersistedSessionState>;
178
+ recentRotationBySession: Record<string, number>;
179
+ };
180
+
119
181
  type ClassifierMetrics = {
120
182
  score: number;
121
183
  novelty: number;
122
184
  lexicalDistance: number;
185
+ uniqueTokenRatio: number;
186
+ entropy: number;
123
187
  similarity?: number;
124
188
  usedEmbedding: boolean;
125
189
  pendingSoftSignals: number;
126
190
  };
127
191
 
192
+ type LexicalFeatures = {
193
+ score: number;
194
+ novelty: number;
195
+ lexicalDistance: number;
196
+ uniqueTokenRatio: number;
197
+ entropy: number;
198
+ };
199
+
128
200
  type ClassificationDecision =
129
201
  | { kind: "warmup" | "stable" | "suspect"; metrics: ClassifierMetrics; reason: string }
130
202
  | { kind: "rotate-hard" | "rotate-soft"; metrics: ClassifierMetrics; reason: string };
@@ -134,9 +206,9 @@ type EmbeddingBackend = {
134
206
  embed: (text: string) => Promise<number[] | null>;
135
207
  };
136
208
 
137
- type ResolvedFastSession = {
138
- sessionKey: string;
139
- routeKind: "direct" | "group" | "thread";
209
+ type FastMessageEventLike = {
210
+ from?: string;
211
+ metadata?: Record<string, unknown>;
140
212
  };
141
213
 
142
214
  type TranscriptMessage = {
@@ -207,15 +279,30 @@ const PRESETS = {
207
279
  const DEFAULTS = {
208
280
  enabled: true,
209
281
  preset: "balanced" as PresetName,
210
- handoff: "summary" as HandoffPreference,
282
+ handoffMode: "summary" as HandoffMode,
211
283
  handoffLastN: 6,
212
284
  handoffMaxChars: 220,
285
+ softSuspectAction: "ask" as SoftSuspectAction,
286
+ softSuspectPrompt:
287
+ "Potential topic shift detected. Ask one short clarification question to confirm the user's new goal before proceeding.",
288
+ softSuspectTtlSeconds: 120,
213
289
  embeddingProvider: "auto" as EmbeddingProvider,
214
290
  embeddingTimeoutMs: 7000,
215
291
  minSignalChars: 20,
216
292
  minSignalTokenCount: 3,
217
293
  minSignalEntropy: 1.2,
294
+ minUniqueTokenRatio: 0.34,
295
+ shortMessageTokenLimit: 6,
296
+ embeddingTriggerMargin: 0.08,
218
297
  stripEnvelope: true,
298
+ stripDropLinePrefixPatterns: [
299
+ "^[A-Za-z][A-Za-z _-]{0,30}:\\s*\\[",
300
+ ],
301
+ stripDropExactLines: [] as string[],
302
+ stripDropFencedBlockAfterHeaderPatterns: [
303
+ "^[A-Za-z][A-Za-z _-]{0,40}:\\s*\\([^)]*(metadata|context)[^)]*\\):?$",
304
+ ],
305
+ handoffTailReadMaxBytes: 512 * 1024,
219
306
  dryRun: false,
220
307
  debug: false,
221
308
  } as const;
@@ -228,7 +315,7 @@ const LOCK_OPTIONS = {
228
315
  maxTimeout: 250,
229
316
  randomize: true,
230
317
  },
231
- stale: 45_000,
318
+ stale: 30 * 60 * 1000,
232
319
  } as const;
233
320
 
234
321
  const MAX_TRACKED_SESSIONS = 10_000;
@@ -236,6 +323,13 @@ const STALE_SESSION_STATE_MS = 4 * 60 * 60 * 1000;
236
323
  const MAX_RECENT_FAST_EVENTS = 20_000;
237
324
  const FAST_EVENT_TTL_MS = 5 * 60 * 1000;
238
325
  const ROTATION_DEDUPE_MS = 25_000;
326
+ const CROSS_PATH_DEDUPE_MS = 15_000;
327
+ const PERSISTENCE_SCHEMA_VERSION = 1;
328
+ const PERSISTENCE_FILE_NAME = "runtime-state.v1.json";
329
+ const PERSISTENCE_FLUSH_DEBOUNCE_MS = 1_200;
330
+ const MAX_TOKENS_PER_PERSISTED_ENTRY = 256;
331
+ const MAX_PERSISTED_EMBEDDING_DIM = 8_192;
332
+ const MAX_PERSISTED_PENDING_ENTRIES = 8;
239
333
 
240
334
  function clampInt(value: unknown, fallback: number, min: number, max: number): number {
241
335
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -264,15 +358,6 @@ function clampFloat(value: unknown, fallback: number, min: number, max: number):
264
358
  return value;
265
359
  }
266
360
 
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
361
  function normalizePreset(value: unknown): PresetName {
277
362
  if (typeof value !== "string") {
278
363
  return DEFAULTS.preset;
@@ -300,18 +385,268 @@ function normalizeEmbeddingProvider(value: unknown): EmbeddingProvider {
300
385
  return DEFAULTS.embeddingProvider;
301
386
  }
302
387
 
303
- function normalizeHandoffPreference(value: unknown): HandoffPreference {
388
+ function normalizeHandoffMode(value: unknown): HandoffMode {
389
+ if (typeof value !== "string") {
390
+ return DEFAULTS.handoffMode;
391
+ }
392
+ const normalized = value.trim().toLowerCase();
393
+ if (normalized === "none" || normalized === "summary" || normalized === "verbatim_last_n") {
394
+ return normalized as HandoffMode;
395
+ }
396
+ return DEFAULTS.handoffMode;
397
+ }
398
+
399
+ function normalizeSoftSuspectAction(value: unknown): SoftSuspectAction {
304
400
  if (typeof value !== "string") {
305
- return DEFAULTS.handoff;
401
+ return DEFAULTS.softSuspectAction;
306
402
  }
307
403
  const normalized = value.trim().toLowerCase();
308
- if (normalized === "none" || normalized === "summary") {
404
+ if (normalized === "none" || normalized === "ask") {
309
405
  return normalized;
310
406
  }
311
- if (normalized === "verbatim" || normalized === "verbatim_last_n") {
312
- return "verbatim";
407
+ return DEFAULTS.softSuspectAction;
408
+ }
409
+
410
+ function compileRegexList(values: unknown, fallback: readonly string[]): RegExp[] {
411
+ const source = Array.isArray(values) ? values : fallback;
412
+ const out: RegExp[] = [];
413
+ for (const item of source) {
414
+ if (typeof item !== "string") {
415
+ continue;
416
+ }
417
+ const pattern = item.trim();
418
+ if (!pattern) {
419
+ continue;
420
+ }
421
+ try {
422
+ out.push(new RegExp(pattern));
423
+ } catch {
424
+ continue;
425
+ }
426
+ }
427
+ return out;
428
+ }
429
+
430
+ function normalizeStringList(values: unknown, fallback: readonly string[]): string[] {
431
+ const source = Array.isArray(values) ? values : fallback;
432
+ return source
433
+ .filter((item): item is string => typeof item === "string")
434
+ .map((item) => item.trim())
435
+ .filter(Boolean);
436
+ }
437
+
438
+ function sanitizeTimestamp(value: unknown, fallback: number): number {
439
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
440
+ return fallback;
441
+ }
442
+ return Math.floor(value);
443
+ }
444
+
445
+ function sanitizeEmbeddingVector(value: unknown): number[] | undefined {
446
+ if (!Array.isArray(value) || value.length === 0) {
447
+ return undefined;
448
+ }
449
+ const out: number[] = [];
450
+ for (const item of value) {
451
+ if (typeof item !== "number" || !Number.isFinite(item)) {
452
+ continue;
453
+ }
454
+ out.push(item);
455
+ if (out.length >= MAX_PERSISTED_EMBEDDING_DIM) {
456
+ break;
457
+ }
458
+ }
459
+ return out.length > 0 ? out : undefined;
460
+ }
461
+
462
+ function normalizeTokenArray(values: unknown): string[] {
463
+ if (!Array.isArray(values)) {
464
+ return [];
313
465
  }
314
- return DEFAULTS.handoff;
466
+ const out: string[] = [];
467
+ for (const value of values) {
468
+ if (typeof value !== "string") {
469
+ continue;
470
+ }
471
+ const token = value.trim();
472
+ if (!token) {
473
+ continue;
474
+ }
475
+ out.push(token);
476
+ if (out.length >= MAX_TOKENS_PER_PERSISTED_ENTRY) {
477
+ break;
478
+ }
479
+ }
480
+ return out;
481
+ }
482
+
483
+ function serializeHistoryEntry(entry: HistoryEntry, includeEmbedding: boolean): PersistedHistoryEntry {
484
+ const tokens = [...entry.tokens]
485
+ .map((token) => token.trim())
486
+ .filter(Boolean)
487
+ .slice(0, MAX_TOKENS_PER_PERSISTED_ENTRY);
488
+ const persisted: PersistedHistoryEntry = {
489
+ tokens,
490
+ at: sanitizeTimestamp(entry.at, Date.now()),
491
+ };
492
+ if (includeEmbedding && Array.isArray(entry.embedding) && entry.embedding.length > 0) {
493
+ persisted.embedding = entry.embedding.filter(Number.isFinite).slice(0, MAX_PERSISTED_EMBEDDING_DIM);
494
+ }
495
+ return persisted;
496
+ }
497
+
498
+ function deserializeHistoryEntry(
499
+ value: unknown,
500
+ includeEmbedding: boolean,
501
+ ): HistoryEntry | null {
502
+ if (!value || typeof value !== "object") {
503
+ return null;
504
+ }
505
+ const record = value as Partial<PersistedHistoryEntry>;
506
+ const tokens = normalizeTokenArray(record.tokens);
507
+ if (tokens.length === 0) {
508
+ return null;
509
+ }
510
+ const entry: HistoryEntry = {
511
+ tokens: new Set(tokens),
512
+ at: sanitizeTimestamp(record.at, Date.now()),
513
+ };
514
+ if (includeEmbedding) {
515
+ entry.embedding = sanitizeEmbeddingVector(record.embedding);
516
+ }
517
+ return entry;
518
+ }
519
+
520
+ function serializeSessionState(state: SessionState): PersistedSessionState {
521
+ const history = trimHistory(state.history, 40).map((entry) => serializeHistoryEntry(entry, false));
522
+ const pendingEntries = trimHistory(state.pendingEntries, MAX_PERSISTED_PENDING_ENTRIES).map((entry) =>
523
+ serializeHistoryEntry(entry, true),
524
+ );
525
+ const topicCentroid =
526
+ Array.isArray(state.topicCentroid) && state.topicCentroid.length > 0
527
+ ? state.topicCentroid.filter(Number.isFinite).slice(0, MAX_PERSISTED_EMBEDDING_DIM)
528
+ : undefined;
529
+
530
+ return {
531
+ history,
532
+ pendingSoftSignals: clampInt(state.pendingSoftSignals, 0, 0, 16),
533
+ pendingEntries,
534
+ lastResetAt:
535
+ typeof state.lastResetAt === "number" && Number.isFinite(state.lastResetAt)
536
+ ? Math.floor(state.lastResetAt)
537
+ : undefined,
538
+ topicCentroid,
539
+ topicCount: clampInt(state.topicCount, topicCentroid ? 1 : 0, 0, Number.MAX_SAFE_INTEGER),
540
+ topicDim:
541
+ typeof state.topicDim === "number" && Number.isFinite(state.topicDim) && state.topicDim > 0
542
+ ? Math.floor(state.topicDim)
543
+ : topicCentroid?.length,
544
+ lastSeenAt: sanitizeTimestamp(state.lastSeenAt, Date.now()),
545
+ };
546
+ }
547
+
548
+ function deserializeSessionState(value: unknown, cfg: ResolvedConfig): SessionState | null {
549
+ if (!value || typeof value !== "object") {
550
+ return null;
551
+ }
552
+ const record = value as Partial<PersistedSessionState>;
553
+ const historySource = Array.isArray(record.history) ? record.history : [];
554
+ const pendingSource = Array.isArray(record.pendingEntries) ? record.pendingEntries : [];
555
+
556
+ const history = trimHistory(
557
+ historySource
558
+ .map((entry) => deserializeHistoryEntry(entry, false))
559
+ .filter((entry): entry is HistoryEntry => !!entry),
560
+ cfg.historyWindow,
561
+ );
562
+ const pendingEntries = trimHistory(
563
+ pendingSource
564
+ .map((entry) => deserializeHistoryEntry(entry, true))
565
+ .filter((entry): entry is HistoryEntry => !!entry),
566
+ Math.max(MAX_PERSISTED_PENDING_ENTRIES, cfg.softConsecutiveSignals),
567
+ );
568
+
569
+ const topicCentroid = sanitizeEmbeddingVector(record.topicCentroid);
570
+ const topicCount = clampInt(record.topicCount, topicCentroid ? 1 : 0, 0, Number.MAX_SAFE_INTEGER);
571
+ const topicDim = clampInt(record.topicDim, topicCentroid?.length ?? 0, 0, MAX_PERSISTED_EMBEDDING_DIM);
572
+
573
+ return {
574
+ history,
575
+ pendingSoftSignals: clampInt(record.pendingSoftSignals, 0, 0, 16),
576
+ pendingEntries,
577
+ lastResetAt:
578
+ typeof record.lastResetAt === "number" && Number.isFinite(record.lastResetAt)
579
+ ? Math.floor(record.lastResetAt)
580
+ : undefined,
581
+ topicCentroid,
582
+ topicCount,
583
+ topicDim: topicDim > 0 ? topicDim : topicCentroid?.length,
584
+ lastSeenAt: sanitizeTimestamp(record.lastSeenAt, Date.now()),
585
+ };
586
+ }
587
+
588
+ function normalizeProviderId(raw: string): string {
589
+ const provider = raw.trim().toLowerCase();
590
+ if (!provider) {
591
+ return "";
592
+ }
593
+ if (provider === "telegram" || provider.includes("telegram")) {
594
+ return "telegram";
595
+ }
596
+ if (
597
+ provider === "whatsapp" ||
598
+ provider.includes("whatsapp") ||
599
+ provider.includes("baileys")
600
+ ) {
601
+ return "whatsapp";
602
+ }
603
+ if (provider === "signal" || provider.includes("signal")) {
604
+ return "signal";
605
+ }
606
+ if (provider === "discord" || provider.includes("discord")) {
607
+ return "discord";
608
+ }
609
+ if (provider === "slack" || provider.includes("slack")) {
610
+ return "slack";
611
+ }
612
+ if (provider === "matrix" || provider.includes("matrix")) {
613
+ return "matrix";
614
+ }
615
+ if (provider === "msteams" || provider.includes("teams")) {
616
+ return "msteams";
617
+ }
618
+ if (provider === "imessage" || provider.includes("imessage") || provider.includes("bluebubbles")) {
619
+ return "imessage";
620
+ }
621
+ if (provider === "web" || provider.includes("webchat")) {
622
+ return "web";
623
+ }
624
+ if (provider === "voice" || provider.includes("voice")) {
625
+ return "voice";
626
+ }
627
+ if (provider.includes("openai")) {
628
+ return "openai";
629
+ }
630
+ if (provider.includes("anthropic")) {
631
+ return "anthropic";
632
+ }
633
+ if (provider.includes("ollama")) {
634
+ return "ollama";
635
+ }
636
+ return provider;
637
+ }
638
+
639
+ function isInternalNonUserProvider(provider: string): boolean {
640
+ if (!provider) {
641
+ return false;
642
+ }
643
+ return (
644
+ provider === "heartbeat" ||
645
+ provider === "exec-event" ||
646
+ provider.startsWith("cron") ||
647
+ provider.includes("heartbeat") ||
648
+ provider.includes("cron")
649
+ );
315
650
  }
316
651
 
317
652
  function resolveConfig(raw: unknown): ResolvedConfig {
@@ -320,9 +655,17 @@ function resolveConfig(raw: unknown): ResolvedConfig {
320
655
  obj.advanced && typeof obj.advanced === "object"
321
656
  ? (obj.advanced as TopicShiftResetAdvancedConfig)
322
657
  : {};
323
- const advancedEmbedding =
324
- advanced.embedding && typeof advanced.embedding === "object"
325
- ? (advanced.embedding as EmbeddingConfig)
658
+ const embedding =
659
+ obj.embedding && typeof obj.embedding === "object" ? (obj.embedding as EmbeddingConfig) : {};
660
+ const handoff =
661
+ obj.handoff && typeof obj.handoff === "object" ? (obj.handoff as HandoffConfig) : {};
662
+ const softSuspect =
663
+ obj.softSuspect && typeof obj.softSuspect === "object"
664
+ ? (obj.softSuspect as SoftSuspectConfig)
665
+ : {};
666
+ const stripRules =
667
+ advanced.stripRules && typeof advanced.stripRules === "object"
668
+ ? (advanced.stripRules as StripRulesConfig)
326
669
  : {};
327
670
 
328
671
  const preset = normalizePreset(obj.preset);
@@ -331,15 +674,13 @@ function resolveConfig(raw: unknown): ResolvedConfig {
331
674
  const ignoredProviders = new Set(
332
675
  Array.isArray(advanced.ignoredProviders)
333
676
  ? advanced.ignoredProviders
334
- .map((value) => (typeof value === "string" ? value.trim().toLowerCase() : ""))
677
+ .map((value) =>
678
+ typeof value === "string" ? normalizeProviderId(value.trim().toLowerCase()) : "",
679
+ )
335
680
  .filter(Boolean)
336
681
  : [],
337
682
  );
338
683
 
339
- const handoffPreference = normalizeHandoffPreference(pickDefined(advanced.handoff, obj.handoff));
340
- const handoffMode: HandoffMode =
341
- handoffPreference === "verbatim" ? "verbatim_last_n" : handoffPreference;
342
-
343
684
  return {
344
685
  enabled: obj.enabled ?? DEFAULTS.enabled,
345
686
  historyWindow: clampInt(advanced.historyWindow, presetConfig.historyWindow, 2, 40),
@@ -369,7 +710,44 @@ function resolveConfig(raw: unknown): ResolvedConfig {
369
710
  0,
370
711
  8,
371
712
  ),
713
+ minUniqueTokenRatio: clampFloat(
714
+ advanced.minUniqueTokenRatio,
715
+ DEFAULTS.minUniqueTokenRatio,
716
+ 0,
717
+ 1,
718
+ ),
719
+ shortMessageTokenLimit: clampInt(
720
+ advanced.shortMessageTokenLimit,
721
+ DEFAULTS.shortMessageTokenLimit,
722
+ 1,
723
+ 40,
724
+ ),
725
+ embeddingTriggerMargin: clampFloat(
726
+ advanced.embeddingTriggerMargin,
727
+ DEFAULTS.embeddingTriggerMargin,
728
+ 0,
729
+ 0.5,
730
+ ),
372
731
  stripEnvelope: advanced.stripEnvelope ?? DEFAULTS.stripEnvelope,
732
+ stripRules: {
733
+ dropLinePrefixPatterns: compileRegexList(
734
+ stripRules.dropLinePrefixPatterns,
735
+ DEFAULTS.stripDropLinePrefixPatterns,
736
+ ),
737
+ dropExactLines: new Set(
738
+ normalizeStringList(stripRules.dropExactLines, DEFAULTS.stripDropExactLines),
739
+ ),
740
+ dropFencedBlockAfterHeaderPatterns: compileRegexList(
741
+ stripRules.dropFencedBlockAfterHeaderPatterns,
742
+ DEFAULTS.stripDropFencedBlockAfterHeaderPatterns,
743
+ ),
744
+ },
745
+ handoffTailReadMaxBytes: clampInt(
746
+ advanced.handoffTailReadMaxBytes,
747
+ DEFAULTS.handoffTailReadMaxBytes,
748
+ 64 * 1024,
749
+ 8 * 1024 * 1024,
750
+ ),
373
751
  softConsecutiveSignals: clampInt(
374
752
  advanced.softConsecutiveSignals,
375
753
  presetConfig.softConsecutiveSignals,
@@ -404,26 +782,34 @@ function resolveConfig(raw: unknown): ResolvedConfig {
404
782
  0,
405
783
  1,
406
784
  ),
407
- handoffMode,
408
- handoffLastN: clampInt(advanced.handoffLastN, DEFAULTS.handoffLastN, 1, 20),
409
- handoffMaxChars: clampInt(advanced.handoffMaxChars, DEFAULTS.handoffMaxChars, 60, 800),
785
+ handoff: {
786
+ mode: normalizeHandoffMode(handoff.mode),
787
+ lastN: clampInt(handoff.lastN, DEFAULTS.handoffLastN, 1, 20),
788
+ maxChars: clampInt(handoff.maxChars, DEFAULTS.handoffMaxChars, 60, 800),
789
+ },
790
+ softSuspect: {
791
+ action: normalizeSoftSuspectAction(softSuspect.action),
792
+ prompt:
793
+ typeof softSuspect.prompt === "string" && softSuspect.prompt.trim()
794
+ ? softSuspect.prompt.trim()
795
+ : DEFAULTS.softSuspectPrompt,
796
+ ttlMs: clampInt(softSuspect.ttlSeconds, DEFAULTS.softSuspectTtlSeconds, 10, 1_800) * 1000,
797
+ },
410
798
  embedding: {
411
- provider: normalizeEmbeddingProvider(
412
- pickDefined(advanced.embeddings, advancedEmbedding.provider, obj.embeddings),
413
- ),
799
+ provider: normalizeEmbeddingProvider(embedding.provider),
414
800
  model: (() => {
415
- const rawModel = advancedEmbedding.model;
801
+ const rawModel = embedding.model;
416
802
  return typeof rawModel === "string" ? rawModel.trim() : undefined;
417
803
  })(),
418
804
  baseUrl: (() => {
419
- const rawBaseUrl = advancedEmbedding.baseUrl;
805
+ const rawBaseUrl = embedding.baseUrl;
420
806
  return typeof rawBaseUrl === "string" ? rawBaseUrl.trim() : undefined;
421
807
  })(),
422
808
  apiKey: (() => {
423
- const rawApiKey = advancedEmbedding.apiKey;
809
+ const rawApiKey = embedding.apiKey;
424
810
  return typeof rawApiKey === "string" ? rawApiKey.trim() : undefined;
425
811
  })(),
426
- timeoutMs: clampInt(advancedEmbedding.timeoutMs, DEFAULTS.embeddingTimeoutMs, 1000, 30_000),
812
+ timeoutMs: clampInt(embedding.timeoutMs, DEFAULTS.embeddingTimeoutMs, 1000, 30_000),
427
813
  },
428
814
  dryRun: obj.dryRun ?? DEFAULTS.dryRun,
429
815
  debug: obj.debug ?? DEFAULTS.debug,
@@ -484,13 +870,27 @@ function tokenEntropy(tokens: string[]): number {
484
870
  return entropy;
485
871
  }
486
872
 
487
- function stripClassifierEnvelope(text: string): string {
873
+ function matchesAny(patterns: RegExp[], value: string): boolean {
874
+ for (const pattern of patterns) {
875
+ if (pattern.test(value)) {
876
+ return true;
877
+ }
878
+ }
879
+ return false;
880
+ }
881
+
882
+ function stripClassifierEnvelope(
883
+ text: string,
884
+ rules: ResolvedConfig["stripRules"],
885
+ ): string {
488
886
  const lines = text.replace(/\r\n/g, "\n").split("\n");
489
887
  const kept: string[] = [];
490
888
  let skipFence = false;
491
889
  let expectingMetadataFence = false;
890
+ let sawSemanticContent = false;
492
891
 
493
- for (const line of lines) {
892
+ for (let index = 0; index < lines.length; index += 1) {
893
+ const line = lines[index] ?? "";
494
894
  const trimmed = line.trim();
495
895
  if (skipFence) {
496
896
  if (trimmed.startsWith("```")) {
@@ -499,14 +899,17 @@ function stripClassifierEnvelope(text: string): string {
499
899
  continue;
500
900
  }
501
901
 
502
- if (
503
- trimmed === "Conversation info (untrusted metadata):" ||
504
- trimmed === "Replied message (untrusted, for context):"
505
- ) {
902
+ if (matchesAny(rules.dropFencedBlockAfterHeaderPatterns, trimmed)) {
506
903
  expectingMetadataFence = true;
507
904
  continue;
508
905
  }
509
906
 
907
+ // Drop top fenced metadata envelopes even if header wording changes.
908
+ if (!sawSemanticContent && index < 12 && (trimmed === "```" || trimmed === "```json")) {
909
+ skipFence = true;
910
+ continue;
911
+ }
912
+
510
913
  if (expectingMetadataFence && trimmed.startsWith("```")) {
511
914
  skipFence = true;
512
915
  expectingMetadataFence = false;
@@ -515,17 +918,14 @@ function stripClassifierEnvelope(text: string): string {
515
918
 
516
919
  expectingMetadataFence = false;
517
920
 
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
- ) {
921
+ if (rules.dropExactLines.has(trimmed) || matchesAny(rules.dropLinePrefixPatterns, trimmed)) {
525
922
  continue;
526
923
  }
527
924
 
528
925
  kept.push(line);
926
+ if (trimmed) {
927
+ sawSemanticContent = true;
928
+ }
529
929
  }
530
930
 
531
931
  return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
@@ -592,29 +992,36 @@ function cosineSimilarity(a: number[], b: number[]): number | undefined {
592
992
  return dot / (Math.sqrt(normA) * Math.sqrt(normB));
593
993
  }
594
994
 
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;
995
+ function seedTopicCentroid(state: SessionState, vector?: number[]): void {
996
+ if (!Array.isArray(vector) || vector.length === 0) {
997
+ state.topicCentroid = undefined;
998
+ state.topicCount = 0;
999
+ state.topicDim = undefined;
1000
+ return;
602
1001
  }
603
- for (const vector of vectors) {
604
- if (vector.length !== dim) {
605
- return undefined;
606
- }
1002
+ state.topicCentroid = [...vector];
1003
+ state.topicCount = 1;
1004
+ state.topicDim = vector.length;
1005
+ }
1006
+
1007
+ function updateTopicCentroid(state: SessionState, vector?: number[]): void {
1008
+ if (!Array.isArray(vector) || vector.length === 0) {
1009
+ return;
607
1010
  }
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
- }
1011
+ if (
1012
+ !Array.isArray(state.topicCentroid) ||
1013
+ state.topicCentroid.length !== vector.length ||
1014
+ !state.topicCount
1015
+ ) {
1016
+ seedTopicCentroid(state, vector);
1017
+ return;
613
1018
  }
614
- for (let i = 0; i < dim; i += 1) {
615
- out[i] /= vectors.length;
1019
+ const nextCount = state.topicCount + 1;
1020
+ for (let i = 0; i < vector.length; i += 1) {
1021
+ state.topicCentroid[i] += (vector[i] - state.topicCentroid[i]) / nextCount;
616
1022
  }
617
- return out;
1023
+ state.topicCount = nextCount;
1024
+ state.topicDim = vector.length;
618
1025
  }
619
1026
 
620
1027
  function trimHistory(entries: HistoryEntry[], limit: number): HistoryEntry[] {
@@ -645,7 +1052,10 @@ function looksLikeGroup(value?: string): boolean {
645
1052
  );
646
1053
  }
647
1054
 
648
- function inferFastPeer(event: PluginHookMessageReceivedEvent, ctx: { conversationId?: string }) {
1055
+ function inferFastPeer(
1056
+ event: FastMessageEventLike,
1057
+ ctx: { conversationId?: string },
1058
+ ): { kind: "direct" | "group" | "channel"; id: string } {
649
1059
  const from = event.from?.trim() ?? "";
650
1060
  const conversationId = ctx.conversationId?.trim() || from;
651
1061
  const metadata = (event.metadata ?? {}) as Record<string, unknown>;
@@ -659,12 +1069,13 @@ function inferFastPeer(event: PluginHookMessageReceivedEvent, ctx: { conversatio
659
1069
 
660
1070
  if (threadId) {
661
1071
  return {
662
- kind: "thread" as const,
1072
+ kind: "group",
663
1073
  id: `${conversationId || from}:thread:${threadId}`,
664
1074
  };
665
1075
  }
666
1076
 
667
- const kind = looksLikeGroup(conversationId) || looksLikeGroup(from) ? "group" : "direct";
1077
+ const kind: "group" | "direct" =
1078
+ looksLikeGroup(conversationId) || looksLikeGroup(from) ? "group" : "direct";
668
1079
  return {
669
1080
  kind,
670
1081
  id: conversationId || from || "unknown",
@@ -763,45 +1174,84 @@ function resolveEmbeddingBackend(cfg: ResolvedConfig): EmbeddingBackend | null {
763
1174
  return createOllamaBackend(cfg);
764
1175
  }
765
1176
 
766
- function classifyMessage(params: {
1177
+ function computeLexicalFeatures(params: {
767
1178
  cfg: ResolvedConfig;
768
- state: SessionState;
769
1179
  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);
1180
+ tokenList: string[];
1181
+ baselineTokens: Set<string>;
1182
+ }): LexicalFeatures {
1183
+ const lexicalSimilarity = jaccardSimilarity(params.entry.tokens, params.baselineTokens);
777
1184
  const lexicalDistance = 1 - lexicalSimilarity;
778
- const novelty = noveltyRatio(entry.tokens, baselineTokens);
1185
+ const novelty = noveltyRatio(params.entry.tokens, params.baselineTokens);
1186
+ const uniqueTokenRatio =
1187
+ params.tokenList.length > 0 ? params.entry.tokens.size / params.tokenList.length : 0;
1188
+ const entropy = tokenEntropy(params.tokenList);
779
1189
 
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";
1190
+ let score = 0.55 * lexicalDistance + 0.45 * novelty;
1191
+ if (uniqueTokenRatio < params.cfg.minUniqueTokenRatio) {
1192
+ score -= (params.cfg.minUniqueTokenRatio - uniqueTokenRatio) * 0.4;
1193
+ }
1194
+ if (params.tokenList.length <= params.cfg.shortMessageTokenLimit && entropy < params.cfg.minSignalEntropy) {
1195
+ score -= (params.cfg.minSignalEntropy - entropy) * 0.06;
1196
+ }
787
1197
 
788
- const score = usedEmbedding
789
- ? 0.7 * (1 - similarity) + 0.15 * lexicalDistance + 0.15 * novelty
790
- : 0.55 * lexicalDistance + 0.45 * novelty;
1198
+ return {
1199
+ score: Math.max(0, Math.min(1, score)),
1200
+ novelty,
1201
+ lexicalDistance,
1202
+ uniqueTokenRatio,
1203
+ entropy,
1204
+ };
1205
+ }
1206
+
1207
+ function shouldRequestEmbedding(params: {
1208
+ cfg: ResolvedConfig;
1209
+ backendAvailable: boolean;
1210
+ lexical: LexicalFeatures;
1211
+ warmup: boolean;
1212
+ cooldownActive: boolean;
1213
+ }): boolean {
1214
+ if (!params.backendAvailable || params.warmup || params.cooldownActive) {
1215
+ return false;
1216
+ }
1217
+ if (params.lexical.score >= params.cfg.softScoreThreshold - params.cfg.embeddingTriggerMargin) {
1218
+ return true;
1219
+ }
1220
+ return (
1221
+ params.lexical.novelty >= params.cfg.softNoveltyThreshold * 0.9 &&
1222
+ params.lexical.lexicalDistance >= 0.35
1223
+ );
1224
+ }
1225
+
1226
+ function classifyMessage(params: {
1227
+ cfg: ResolvedConfig;
1228
+ state: SessionState;
1229
+ baselineTokenCount: number;
1230
+ lexical: LexicalFeatures;
1231
+ similarity?: number;
1232
+ usedEmbedding: boolean;
1233
+ now: number;
1234
+ }): ClassificationDecision {
1235
+ const { cfg, state, now } = params;
1236
+ const score =
1237
+ params.usedEmbedding && typeof params.similarity === "number"
1238
+ ? 0.7 * (1 - params.similarity) +
1239
+ 0.15 * params.lexical.lexicalDistance +
1240
+ 0.15 * params.lexical.novelty
1241
+ : params.lexical.score;
791
1242
 
792
1243
  const metrics = {
793
1244
  score,
794
- novelty,
795
- lexicalDistance,
796
- similarity,
797
- usedEmbedding,
1245
+ novelty: params.lexical.novelty,
1246
+ lexicalDistance: params.lexical.lexicalDistance,
1247
+ uniqueTokenRatio: params.lexical.uniqueTokenRatio,
1248
+ entropy: params.lexical.entropy,
1249
+ similarity: params.similarity,
1250
+ usedEmbedding: params.usedEmbedding,
798
1251
  pendingSoftSignals: state.pendingSoftSignals,
799
1252
  } satisfies ClassifierMetrics;
800
1253
 
801
- if (
802
- baselineEntries.length < cfg.minHistoryMessages ||
803
- baselineTokens.size < cfg.minMeaningfulTokens
804
- ) {
1254
+ if (state.history.length < cfg.minHistoryMessages || params.baselineTokenCount < cfg.minMeaningfulTokens) {
805
1255
  return { kind: "warmup", metrics, reason: "warmup" };
806
1256
  }
807
1257
 
@@ -814,8 +1264,11 @@ function classifyMessage(params: {
814
1264
 
815
1265
  const hardSignal =
816
1266
  score >= cfg.hardScoreThreshold ||
817
- ((typeof similarity === "number" ? similarity <= cfg.hardSimilarityThreshold : false) &&
818
- novelty >= cfg.hardNoveltyThreshold);
1267
+ (params.usedEmbedding && typeof params.similarity === "number"
1268
+ ? params.similarity <= cfg.hardSimilarityThreshold &&
1269
+ params.lexical.novelty >= cfg.hardNoveltyThreshold
1270
+ : params.lexical.novelty >= cfg.hardNoveltyThreshold &&
1271
+ params.lexical.lexicalDistance >= 0.65);
819
1272
 
820
1273
  if (hardSignal) {
821
1274
  return { kind: "rotate-hard", metrics, reason: "hard-threshold" };
@@ -823,9 +1276,11 @@ function classifyMessage(params: {
823
1276
 
824
1277
  const softSignal =
825
1278
  score >= cfg.softScoreThreshold ||
826
- ((typeof similarity === "number" ? similarity <= cfg.softSimilarityThreshold : false) &&
827
- novelty >= cfg.softNoveltyThreshold) ||
828
- (!usedEmbedding && novelty >= cfg.softNoveltyThreshold && lexicalDistance >= 0.45);
1279
+ (params.usedEmbedding && typeof params.similarity === "number"
1280
+ ? params.similarity <= cfg.softSimilarityThreshold &&
1281
+ params.lexical.novelty >= cfg.softNoveltyThreshold
1282
+ : params.lexical.novelty >= cfg.softNoveltyThreshold &&
1283
+ params.lexical.lexicalDistance >= 0.45);
829
1284
 
830
1285
  if (!softSignal) {
831
1286
  return { kind: "stable", metrics, reason: "stable" };
@@ -885,15 +1340,10 @@ function resolveSessionFilePathFromEntry(params: {
885
1340
  return path.resolve(sessionsDir, `${sessionId}.jsonl`);
886
1341
  }
887
1342
 
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");
1343
+ function parseTranscriptTailLines(lines: string[], takeLast: number): TranscriptMessage[] {
894
1344
  const messages: TranscriptMessage[] = [];
895
-
896
- for (const line of lines) {
1345
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
1346
+ const line = lines[i] ?? "";
897
1347
  const trimmed = line.trim();
898
1348
  if (!trimmed) {
899
1349
  continue;
@@ -924,12 +1374,67 @@ async function readTranscriptTail(params: {
924
1374
  continue;
925
1375
  }
926
1376
  messages.push({ role, text });
1377
+ if (messages.length >= takeLast) {
1378
+ break;
1379
+ }
927
1380
  }
1381
+ messages.reverse();
1382
+ return messages;
1383
+ }
928
1384
 
929
- if (messages.length <= params.takeLast) {
930
- return messages;
1385
+ async function readTranscriptTail(params: {
1386
+ sessionFile: string;
1387
+ takeLast: number;
1388
+ maxBytes: number;
1389
+ onFallbackFullRead?: () => void;
1390
+ }): Promise<TranscriptMessage[]> {
1391
+ const fd = await fs.open(params.sessionFile, "r");
1392
+ let fallbackFullRead = false;
1393
+ try {
1394
+ const stat = await fd.stat();
1395
+ if (stat.size <= 0) {
1396
+ return [];
1397
+ }
1398
+
1399
+ let position = stat.size;
1400
+ let bytesReadTotal = 0;
1401
+ const chunks: string[] = [];
1402
+ while (position > 0 && bytesReadTotal < params.maxBytes) {
1403
+ const remainingBudget = params.maxBytes - bytesReadTotal;
1404
+ const toRead = Math.min(64 * 1024, position, remainingBudget);
1405
+ if (toRead <= 0) {
1406
+ break;
1407
+ }
1408
+ const nextPosition = position - toRead;
1409
+ const buffer = Buffer.allocUnsafe(toRead);
1410
+ const result = await fd.read(buffer, 0, toRead, nextPosition);
1411
+ if (result.bytesRead <= 0) {
1412
+ break;
1413
+ }
1414
+ chunks.unshift(buffer.subarray(0, result.bytesRead).toString("utf-8"));
1415
+ position = nextPosition;
1416
+ bytesReadTotal += result.bytesRead;
1417
+ if (chunks.join("").split("\n").length > params.takeLast * 30) {
1418
+ break;
1419
+ }
1420
+ }
1421
+
1422
+ const tail = parseTranscriptTailLines(chunks.join("").split("\n"), params.takeLast);
1423
+ if (tail.length >= params.takeLast || bytesReadTotal >= stat.size) {
1424
+ return tail;
1425
+ }
1426
+ fallbackFullRead = true;
1427
+ } finally {
1428
+ await fd.close();
931
1429
  }
932
- return messages.slice(messages.length - params.takeLast);
1430
+
1431
+ if (!fallbackFullRead) {
1432
+ return [];
1433
+ }
1434
+
1435
+ params.onFallbackFullRead?.();
1436
+ const raw = await fs.readFile(params.sessionFile, "utf-8");
1437
+ return parseTranscriptTailLines(raw.split("\n"), params.takeLast);
933
1438
  }
934
1439
 
935
1440
  function truncateText(text: string, maxChars: number): string {
@@ -968,7 +1473,7 @@ async function buildHandoffEventFromPreviousSession(params: {
968
1473
  previousEntry?: SessionEntryLike;
969
1474
  logger: OpenClawPluginApi["logger"];
970
1475
  }): Promise<string | null> {
971
- if (params.cfg.handoffMode === "none") {
1476
+ if (params.cfg.handoff.mode === "none") {
972
1477
  return null;
973
1478
  }
974
1479
  const sessionFile = resolveSessionFilePathFromEntry({
@@ -982,12 +1487,18 @@ async function buildHandoffEventFromPreviousSession(params: {
982
1487
  try {
983
1488
  const tail = await readTranscriptTail({
984
1489
  sessionFile,
985
- takeLast: params.cfg.handoffLastN,
1490
+ takeLast: params.cfg.handoff.lastN,
1491
+ maxBytes: params.cfg.handoffTailReadMaxBytes,
1492
+ onFallbackFullRead: () => {
1493
+ params.logger.warn(
1494
+ `topic-shift-reset: handoff tail fallback full-read file=${sessionFile}`,
1495
+ );
1496
+ },
986
1497
  });
987
1498
  return formatHandoffEventText({
988
- mode: params.cfg.handoffMode,
1499
+ mode: params.cfg.handoff.mode,
989
1500
  messages: tail,
990
- maxChars: params.cfg.handoffMaxChars,
1501
+ maxChars: params.cfg.handoff.maxChars,
991
1502
  });
992
1503
  } catch (error) {
993
1504
  params.logger.warn(
@@ -997,15 +1508,17 @@ async function buildHandoffEventFromPreviousSession(params: {
997
1508
  }
998
1509
  }
999
1510
 
1000
- function pruneStateMaps(stateBySession: Map<string, SessionState>): void {
1511
+ function pruneStateMaps(stateBySession: Map<string, SessionState>): boolean {
1512
+ let changed = false;
1001
1513
  const now = Date.now();
1002
1514
  for (const [sessionKey, state] of stateBySession) {
1003
1515
  if (now - state.lastSeenAt > STALE_SESSION_STATE_MS) {
1004
1516
  stateBySession.delete(sessionKey);
1517
+ changed = true;
1005
1518
  }
1006
1519
  }
1007
1520
  if (stateBySession.size <= MAX_TRACKED_SESSIONS) {
1008
- return;
1521
+ return changed;
1009
1522
  }
1010
1523
  const ordered = [...stateBySession.entries()].sort((a, b) => a[1].lastSeenAt - b[1].lastSeenAt);
1011
1524
  const toDrop = stateBySession.size - MAX_TRACKED_SESSIONS;
@@ -1013,19 +1526,23 @@ function pruneStateMaps(stateBySession: Map<string, SessionState>): void {
1013
1526
  const sessionKey = ordered[i]?.[0];
1014
1527
  if (sessionKey) {
1015
1528
  stateBySession.delete(sessionKey);
1529
+ changed = true;
1016
1530
  }
1017
1531
  }
1532
+ return changed;
1018
1533
  }
1019
1534
 
1020
- function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number): void {
1535
+ function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number): boolean {
1536
+ let changed = false;
1021
1537
  const now = Date.now();
1022
1538
  for (const [key, ts] of map) {
1023
1539
  if (now - ts > ttlMs) {
1024
1540
  map.delete(key);
1541
+ changed = true;
1025
1542
  }
1026
1543
  }
1027
1544
  if (map.size <= maxSize) {
1028
- return;
1545
+ return changed;
1029
1546
  }
1030
1547
  const ordered = [...map.entries()].sort((a, b) => a[1] - b[1]);
1031
1548
  const toDrop = map.size - maxSize;
@@ -1033,8 +1550,38 @@ function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number
1033
1550
  const key = ordered[i]?.[0];
1034
1551
  if (key) {
1035
1552
  map.delete(key);
1553
+ changed = true;
1036
1554
  }
1037
1555
  }
1556
+ return changed;
1557
+ }
1558
+
1559
+ function pruneRecentClassifiedMap(
1560
+ map: Map<string, { at: number; source: "fast" | "fallback" }>,
1561
+ ttlMs: number,
1562
+ maxSize: number,
1563
+ ): boolean {
1564
+ let changed = false;
1565
+ const now = Date.now();
1566
+ for (const [key, value] of map) {
1567
+ if (now - value.at > ttlMs) {
1568
+ map.delete(key);
1569
+ changed = true;
1570
+ }
1571
+ }
1572
+ if (map.size <= maxSize) {
1573
+ return changed;
1574
+ }
1575
+ const ordered = [...map.entries()].sort((a, b) => a[1].at - b[1].at);
1576
+ const toDrop = map.size - maxSize;
1577
+ for (let i = 0; i < toDrop; i += 1) {
1578
+ const key = ordered[i]?.[0];
1579
+ if (key) {
1580
+ map.delete(key);
1581
+ changed = true;
1582
+ }
1583
+ }
1584
+ return changed;
1038
1585
  }
1039
1586
 
1040
1587
  async function rotateSessionEntry(params: {
@@ -1054,13 +1601,9 @@ async function rotateSessionEntry(params: {
1054
1601
  });
1055
1602
 
1056
1603
  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
1604
  params.api.logger.info(
1062
1605
  [
1063
- `topic-shift-reset: dry-run rotate`,
1606
+ `topic-shift-reset: would-rotate`,
1064
1607
  `source=${params.source}`,
1065
1608
  `reason=${params.reason}`,
1066
1609
  `session=${params.sessionKey}`,
@@ -1129,8 +1672,9 @@ async function rotateSessionEntry(params: {
1129
1672
  params.state.pendingSoftSignals = 0;
1130
1673
  params.state.pendingEntries = [];
1131
1674
  params.state.history = trimHistory([params.entry], params.cfg.historyWindow);
1675
+ seedTopicCentroid(params.state, params.entry.embedding);
1132
1676
 
1133
- params.api.logger.warn(
1677
+ params.api.logger.info(
1134
1678
  [
1135
1679
  `topic-shift-reset: rotated`,
1136
1680
  `source=${params.source}`,
@@ -1152,6 +1696,185 @@ export default function register(api: OpenClawPluginApi): void {
1152
1696
  const sessionState = new Map<string, SessionState>();
1153
1697
  const recentFastEvents = new Map<string, number>();
1154
1698
  const recentRotationBySession = new Map<string, number>();
1699
+ const pendingSoftSuspectSteeringBySession = new Map<string, number>();
1700
+ const recentClassifiedBySessionHash = new Map<
1701
+ string,
1702
+ { at: number; source: "fast" | "fallback" }
1703
+ >();
1704
+ const sessionWorkQueue = new Map<string, Promise<unknown>>();
1705
+
1706
+ const persistencePath = (() => {
1707
+ try {
1708
+ const stateDir = api.runtime.state.resolveStateDir();
1709
+ return path.join(stateDir, "plugins", api.id, PERSISTENCE_FILE_NAME);
1710
+ } catch (error) {
1711
+ api.logger.warn(`topic-shift-reset: persistence disabled (state path): ${String(error)}`);
1712
+ return null;
1713
+ }
1714
+ })();
1715
+
1716
+ let persistenceDirty = false;
1717
+ let persistenceTimer: NodeJS.Timeout | null = null;
1718
+ let persistenceFlushPromise: Promise<void> | null = null;
1719
+ let persistenceLoadPromise: Promise<void> = Promise.resolve();
1720
+
1721
+ const clearPersistenceTimer = () => {
1722
+ if (!persistenceTimer) {
1723
+ return;
1724
+ }
1725
+ clearTimeout(persistenceTimer);
1726
+ persistenceTimer = null;
1727
+ };
1728
+
1729
+ const flushPersistentState = async (reason: string): Promise<void> => {
1730
+ if (!persistencePath) {
1731
+ return;
1732
+ }
1733
+ await persistenceLoadPromise;
1734
+ if (!persistenceDirty) {
1735
+ return;
1736
+ }
1737
+ if (persistenceFlushPromise) {
1738
+ await persistenceFlushPromise;
1739
+ return;
1740
+ }
1741
+ persistenceFlushPromise = (async () => {
1742
+ const now = Date.now();
1743
+ pruneStateMaps(sessionState);
1744
+ pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
1745
+
1746
+ const payload: PersistedRuntimeState = {
1747
+ version: PERSISTENCE_SCHEMA_VERSION,
1748
+ savedAt: now,
1749
+ sessionStateBySessionKey: {},
1750
+ recentRotationBySession: {},
1751
+ };
1752
+
1753
+ for (const [sessionKey, state] of sessionState) {
1754
+ payload.sessionStateBySessionKey[sessionKey] = serializeSessionState(state);
1755
+ }
1756
+ for (const [rotationKey, ts] of recentRotationBySession) {
1757
+ if (now - ts <= ROTATION_DEDUPE_MS * 3) {
1758
+ payload.recentRotationBySession[rotationKey] = ts;
1759
+ }
1760
+ }
1761
+
1762
+ await withFileLock(persistencePath, LOCK_OPTIONS, async () => {
1763
+ await writeJsonFileAtomically(persistencePath, payload);
1764
+ });
1765
+ persistenceDirty = false;
1766
+ if (cfg.debug) {
1767
+ api.logger.debug(
1768
+ `topic-shift-reset: state-flushed reason=${reason} sessions=${sessionState.size} rotations=${recentRotationBySession.size}`,
1769
+ );
1770
+ }
1771
+ })()
1772
+ .catch((error) => {
1773
+ api.logger.warn(`topic-shift-reset: state flush failed err=${String(error)}`);
1774
+ })
1775
+ .finally(() => {
1776
+ persistenceFlushPromise = null;
1777
+ });
1778
+ await persistenceFlushPromise;
1779
+ };
1780
+
1781
+ const schedulePersistentFlush = (urgent = false) => {
1782
+ if (!persistencePath) {
1783
+ return;
1784
+ }
1785
+ persistenceDirty = true;
1786
+ if (urgent) {
1787
+ void flushPersistentState("urgent");
1788
+ return;
1789
+ }
1790
+ if (persistenceTimer) {
1791
+ return;
1792
+ }
1793
+ persistenceTimer = setTimeout(() => {
1794
+ persistenceTimer = null;
1795
+ void flushPersistentState("scheduled");
1796
+ }, PERSISTENCE_FLUSH_DEBOUNCE_MS);
1797
+ persistenceTimer.unref?.();
1798
+ };
1799
+
1800
+ const runSerializedBySession = async <T>(
1801
+ sessionKey: string,
1802
+ fn: () => Promise<T>,
1803
+ ): Promise<T> => {
1804
+ const previous = sessionWorkQueue.get(sessionKey) ?? Promise.resolve();
1805
+ const current = previous.catch(() => undefined).then(fn);
1806
+ const tail = current.then(
1807
+ () => undefined,
1808
+ () => undefined,
1809
+ );
1810
+ sessionWorkQueue.set(sessionKey, tail);
1811
+ try {
1812
+ return await current;
1813
+ } finally {
1814
+ if (sessionWorkQueue.get(sessionKey) === tail) {
1815
+ sessionWorkQueue.delete(sessionKey);
1816
+ }
1817
+ }
1818
+ };
1819
+
1820
+ persistenceLoadPromise = (async () => {
1821
+ if (!persistencePath) {
1822
+ return;
1823
+ }
1824
+ try {
1825
+ const loaded = await withFileLock(persistencePath, LOCK_OPTIONS, async () => {
1826
+ return await readJsonFileWithFallback<PersistedRuntimeState | null>(persistencePath, null);
1827
+ });
1828
+ const value = loaded.value;
1829
+ if (!value || typeof value !== "object") {
1830
+ return;
1831
+ }
1832
+ if (value.version !== PERSISTENCE_SCHEMA_VERSION) {
1833
+ api.logger.warn(
1834
+ `topic-shift-reset: state version mismatch expected=${PERSISTENCE_SCHEMA_VERSION} got=${String(value.version)}; ignoring persisted state`,
1835
+ );
1836
+ return;
1837
+ }
1838
+
1839
+ const restoredSessionStateByKey =
1840
+ value.sessionStateBySessionKey && typeof value.sessionStateBySessionKey === "object"
1841
+ ? value.sessionStateBySessionKey
1842
+ : {};
1843
+ for (const [sessionKey, rawState] of Object.entries(restoredSessionStateByKey)) {
1844
+ const trimmedSessionKey = sessionKey.trim();
1845
+ if (!trimmedSessionKey) {
1846
+ continue;
1847
+ }
1848
+ const restored = deserializeSessionState(rawState, cfg);
1849
+ if (!restored) {
1850
+ continue;
1851
+ }
1852
+ sessionState.set(trimmedSessionKey, restored);
1853
+ }
1854
+ pruneStateMaps(sessionState);
1855
+
1856
+ const restoredRotationByKey =
1857
+ value.recentRotationBySession && typeof value.recentRotationBySession === "object"
1858
+ ? value.recentRotationBySession
1859
+ : {};
1860
+ for (const [key, ts] of Object.entries(restoredRotationByKey)) {
1861
+ if (!key.trim()) {
1862
+ continue;
1863
+ }
1864
+ if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= 0) {
1865
+ continue;
1866
+ }
1867
+ recentRotationBySession.set(key, Math.floor(ts));
1868
+ }
1869
+ pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
1870
+
1871
+ api.logger.info(
1872
+ `topic-shift-reset: restored state sessions=${sessionState.size} rotations=${recentRotationBySession.size}`,
1873
+ );
1874
+ } catch (error) {
1875
+ api.logger.warn(`topic-shift-reset: state restore failed err=${String(error)}`);
1876
+ }
1877
+ })();
1155
1878
 
1156
1879
  let embeddingBackend: EmbeddingBackend | null = null;
1157
1880
  let embeddingInitError: string | null = null;
@@ -1164,54 +1887,56 @@ export default function register(api: OpenClawPluginApi): void {
1164
1887
  if (embeddingInitError) {
1165
1888
  api.logger.warn(`topic-shift-reset: embedding backend init failed: ${embeddingInitError}`);
1166
1889
  } else if (!embeddingBackend) {
1167
- api.logger.warn("topic-shift-reset: embedding backend unavailable, using lexical-only mode");
1890
+ api.logger.info("topic-shift-reset: embedding backend unavailable, using lexical-only mode");
1168
1891
  } else {
1169
1892
  api.logger.info(`topic-shift-reset: embedding backend ${embeddingBackend.name}`);
1170
1893
  }
1171
1894
 
1172
- const classifyAndMaybeRotate = async (params: {
1895
+ const classifyAndMaybeRotateInner = async (params: {
1173
1896
  source: "fast" | "fallback";
1174
1897
  sessionKey: string;
1175
1898
  text: string;
1176
1899
  messageProvider?: string;
1177
1900
  agentId?: string;
1178
- dedupeKey?: string;
1179
1901
  }) => {
1902
+ await persistenceLoadPromise;
1180
1903
  if (!cfg.enabled) {
1181
1904
  return;
1182
1905
  }
1183
- const sessionKey = params.sessionKey.trim();
1906
+ const sessionKey = params.sessionKey;
1184
1907
  if (!sessionKey) {
1185
1908
  return;
1186
1909
  }
1187
1910
 
1188
- const provider = params.messageProvider?.trim().toLowerCase();
1911
+ const provider = normalizeProviderId(params.messageProvider ?? "");
1189
1912
  if (provider && cfg.ignoredProviders.has(provider)) {
1190
1913
  return;
1191
1914
  }
1915
+ if (isInternalNonUserProvider(provider)) {
1916
+ if (cfg.debug) {
1917
+ api.logger.debug(
1918
+ `topic-shift-reset: skip-internal-provider source=${params.source} provider=${provider} session=${sessionKey}`,
1919
+ );
1920
+ }
1921
+ return;
1922
+ }
1192
1923
 
1193
1924
  const rawText = params.text.trim();
1194
- const text = cfg.stripEnvelope ? stripClassifierEnvelope(rawText) : rawText;
1925
+ const text = cfg.stripEnvelope ? stripClassifierEnvelope(rawText, cfg.stripRules) : rawText;
1195
1926
  if (!text || text.startsWith("/")) {
1196
1927
  return;
1197
1928
  }
1198
1929
 
1199
1930
  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
- ) {
1931
+ if (text.length < cfg.minSignalChars || tokenList.length < cfg.minSignalTokenCount) {
1206
1932
  if (cfg.debug) {
1207
- api.logger.info(
1933
+ api.logger.debug(
1208
1934
  [
1209
1935
  `topic-shift-reset: skip-low-signal`,
1210
1936
  `source=${params.source}`,
1211
1937
  `session=${sessionKey}`,
1212
1938
  `chars=${text.length}`,
1213
1939
  `tokens=${tokenList.length}`,
1214
- `entropy=${signalEntropy.toFixed(3)}`,
1215
1940
  ].join(" "),
1216
1941
  );
1217
1942
  }
@@ -1219,18 +1944,73 @@ export default function register(api: OpenClawPluginApi): void {
1219
1944
  }
1220
1945
 
1221
1946
  const tokens = new Set(tokenList);
1222
- if (tokens.size < cfg.minMeaningfulTokens) {
1947
+
1948
+ const now = Date.now();
1949
+ const contentHash = hashString(normalizeTextForHash(text));
1950
+ const classifyDedupeKey = `${sessionKey}:${contentHash}`;
1951
+ const recentCrossSourceClassification = recentClassifiedBySessionHash.get(classifyDedupeKey);
1952
+ if (
1953
+ recentCrossSourceClassification &&
1954
+ recentCrossSourceClassification.source !== params.source &&
1955
+ now - recentCrossSourceClassification.at < CROSS_PATH_DEDUPE_MS
1956
+ ) {
1957
+ if (cfg.debug) {
1958
+ api.logger.debug(
1959
+ [
1960
+ `topic-shift-reset: skip-cross-path-duplicate`,
1961
+ `session=${sessionKey}`,
1962
+ `source=${params.source}`,
1963
+ `prevSource=${recentCrossSourceClassification.source}`,
1964
+ ].join(" "),
1965
+ );
1966
+ }
1223
1967
  return;
1224
1968
  }
1969
+ recentClassifiedBySessionHash.set(classifyDedupeKey, { at: now, source: params.source });
1970
+ pruneRecentClassifiedMap(recentClassifiedBySessionHash, CROSS_PATH_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
1225
1971
 
1226
- const contentHash = hashString(normalizeTextForHash(text));
1227
1972
  const lastRotationAt = recentRotationBySession.get(`${sessionKey}:${contentHash}`);
1228
- if (typeof lastRotationAt === "number" && Date.now() - lastRotationAt < ROTATION_DEDUPE_MS) {
1973
+ if (typeof lastRotationAt === "number" && now - lastRotationAt < ROTATION_DEDUPE_MS) {
1229
1974
  return;
1230
1975
  }
1976
+ const state =
1977
+ sessionState.get(sessionKey) ??
1978
+ ({
1979
+ history: [],
1980
+ pendingSoftSignals: 0,
1981
+ pendingEntries: [],
1982
+ lastResetAt: undefined,
1983
+ topicCentroid: undefined,
1984
+ topicCount: 0,
1985
+ topicDim: undefined,
1986
+ lastSeenAt: now,
1987
+ } satisfies SessionState);
1988
+ state.lastSeenAt = now;
1989
+
1990
+ const baselineTokens = unionTokens(state.history);
1991
+ const lexical = computeLexicalFeatures({
1992
+ cfg,
1993
+ entry: { tokens, at: now },
1994
+ tokenList,
1995
+ baselineTokens,
1996
+ });
1997
+ const warmup =
1998
+ state.history.length < cfg.minHistoryMessages || baselineTokens.size < cfg.minMeaningfulTokens;
1999
+ const cooldownMs = cfg.cooldownMinutes * 60_000;
2000
+ const cooldownActive =
2001
+ cooldownMs > 0 && typeof state.lastResetAt === "number" && now - state.lastResetAt < cooldownMs;
1231
2002
 
1232
2003
  let embedding: number[] | undefined;
1233
- if (embeddingBackend) {
2004
+ if (
2005
+ shouldRequestEmbedding({
2006
+ cfg,
2007
+ backendAvailable: !!embeddingBackend,
2008
+ lexical,
2009
+ warmup,
2010
+ cooldownActive,
2011
+ }) &&
2012
+ embeddingBackend
2013
+ ) {
1234
2014
  try {
1235
2015
  const vector = await embeddingBackend.embed(text);
1236
2016
  if (Array.isArray(vector) && vector.length > 0) {
@@ -1241,23 +2021,23 @@ export default function register(api: OpenClawPluginApi): void {
1241
2021
  }
1242
2022
  }
1243
2023
 
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
2024
  const entry: HistoryEntry = { tokens, embedding, at: now };
1257
- const decision = classifyMessage({ cfg, state, entry, now });
2025
+ const similarity =
2026
+ entry.embedding && state.topicCentroid
2027
+ ? cosineSimilarity(entry.embedding, state.topicCentroid)
2028
+ : undefined;
2029
+ const decision = classifyMessage({
2030
+ cfg,
2031
+ state,
2032
+ baselineTokenCount: baselineTokens.size,
2033
+ lexical,
2034
+ similarity,
2035
+ usedEmbedding: typeof similarity === "number",
2036
+ now,
2037
+ });
1258
2038
 
1259
2039
  if (cfg.debug) {
1260
- api.logger.info(
2040
+ api.logger.debug(
1261
2041
  [
1262
2042
  `topic-shift-reset: classify`,
1263
2043
  `source=${params.source}`,
@@ -1267,6 +2047,8 @@ export default function register(api: OpenClawPluginApi): void {
1267
2047
  `score=${decision.metrics.score.toFixed(3)}`,
1268
2048
  `novelty=${decision.metrics.novelty.toFixed(3)}`,
1269
2049
  `lex=${decision.metrics.lexicalDistance.toFixed(3)}`,
2050
+ `uniq=${decision.metrics.uniqueTokenRatio.toFixed(3)}`,
2051
+ `entropy=${decision.metrics.entropy.toFixed(3)}`,
1270
2052
  `sim=${typeof decision.metrics.similarity === "number" ? decision.metrics.similarity.toFixed(3) : "n/a"}`,
1271
2053
  `embed=${decision.metrics.usedEmbedding ? "1" : "0"}`,
1272
2054
  `pending=${state.pendingSoftSignals}`,
@@ -1275,29 +2057,46 @@ export default function register(api: OpenClawPluginApi): void {
1275
2057
  }
1276
2058
 
1277
2059
  if (decision.kind === "warmup") {
2060
+ pendingSoftSuspectSteeringBySession.delete(sessionKey);
1278
2061
  state.pendingSoftSignals = 0;
1279
2062
  state.pendingEntries = [];
1280
2063
  state.history = trimHistory([...state.history, entry], cfg.historyWindow);
2064
+ updateTopicCentroid(state, entry.embedding);
1281
2065
  sessionState.set(sessionKey, state);
1282
2066
  pruneStateMaps(sessionState);
2067
+ schedulePersistentFlush(false);
1283
2068
  return;
1284
2069
  }
1285
2070
 
1286
2071
  if (decision.kind === "stable") {
2072
+ pendingSoftSuspectSteeringBySession.delete(sessionKey);
1287
2073
  const merged = [...state.history, ...state.pendingEntries, entry];
2074
+ for (const item of [...state.pendingEntries, entry]) {
2075
+ updateTopicCentroid(state, item.embedding);
2076
+ }
1288
2077
  state.pendingSoftSignals = 0;
1289
2078
  state.pendingEntries = [];
1290
2079
  state.history = trimHistory(merged, cfg.historyWindow);
1291
2080
  sessionState.set(sessionKey, state);
1292
2081
  pruneStateMaps(sessionState);
2082
+ schedulePersistentFlush(false);
1293
2083
  return;
1294
2084
  }
1295
2085
 
1296
2086
  if (decision.kind === "suspect") {
2087
+ if (cfg.softSuspect.action === "ask") {
2088
+ pendingSoftSuspectSteeringBySession.set(sessionKey, now);
2089
+ pruneRecentMap(
2090
+ pendingSoftSuspectSteeringBySession,
2091
+ Math.max(cfg.softSuspect.ttlMs * 2, 60_000),
2092
+ MAX_RECENT_FAST_EVENTS,
2093
+ );
2094
+ }
1297
2095
  state.pendingSoftSignals += 1;
1298
2096
  state.pendingEntries = trimHistory([...state.pendingEntries, entry], cfg.softConsecutiveSignals);
1299
2097
  sessionState.set(sessionKey, state);
1300
2098
  pruneStateMaps(sessionState);
2099
+ schedulePersistentFlush(false);
1301
2100
  return;
1302
2101
  }
1303
2102
 
@@ -1318,12 +2117,42 @@ export default function register(api: OpenClawPluginApi): void {
1318
2117
  });
1319
2118
 
1320
2119
  if (rotated) {
1321
- recentRotationBySession.set(`${sessionKey}:${contentHash}`, Date.now());
2120
+ pendingSoftSuspectSteeringBySession.delete(sessionKey);
2121
+ if (!cfg.dryRun) {
2122
+ recentRotationBySession.set(`${sessionKey}:${contentHash}`, Date.now());
2123
+ }
2124
+ schedulePersistentFlush(true);
1322
2125
  }
1323
2126
 
1324
2127
  sessionState.set(sessionKey, state);
1325
2128
  pruneStateMaps(sessionState);
1326
- pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
2129
+ const prunedRotations = pruneRecentMap(
2130
+ recentRotationBySession,
2131
+ ROTATION_DEDUPE_MS * 3,
2132
+ MAX_RECENT_FAST_EVENTS,
2133
+ );
2134
+ if (prunedRotations) {
2135
+ schedulePersistentFlush(false);
2136
+ }
2137
+ };
2138
+
2139
+ const classifyAndMaybeRotate = async (params: {
2140
+ source: "fast" | "fallback";
2141
+ sessionKey: string;
2142
+ text: string;
2143
+ messageProvider?: string;
2144
+ agentId?: string;
2145
+ }) => {
2146
+ const sessionKey = params.sessionKey.trim();
2147
+ if (!sessionKey) {
2148
+ return;
2149
+ }
2150
+ await runSerializedBySession(sessionKey, async () => {
2151
+ await classifyAndMaybeRotateInner({
2152
+ ...params,
2153
+ sessionKey,
2154
+ });
2155
+ });
1327
2156
  };
1328
2157
 
1329
2158
  api.on("message_received", async (event, ctx) => {
@@ -1355,7 +2184,8 @@ export default function register(api: OpenClawPluginApi): void {
1355
2184
  recentFastEvents.set(fastEventKey, Date.now());
1356
2185
  pruneRecentMap(recentFastEvents, FAST_EVENT_TTL_MS, MAX_RECENT_FAST_EVENTS);
1357
2186
 
1358
- let resolved: ResolvedFastSession | null = null;
2187
+ let resolvedSessionKey = "";
2188
+ let resolvedAgentId: string | undefined;
1359
2189
  try {
1360
2190
  const route = api.runtime.channel.routing.resolveAgentRoute({
1361
2191
  cfg: api.config,
@@ -1363,13 +2193,11 @@ export default function register(api: OpenClawPluginApi): void {
1363
2193
  accountId: ctx.accountId,
1364
2194
  peer,
1365
2195
  });
1366
- resolved = {
1367
- sessionKey: route.sessionKey,
1368
- routeKind: peer.kind,
1369
- };
2196
+ resolvedSessionKey = route.sessionKey;
2197
+ resolvedAgentId = route.agentId;
1370
2198
  } catch (error) {
1371
2199
  if (cfg.debug) {
1372
- api.logger.info(
2200
+ api.logger.debug(
1373
2201
  `topic-shift-reset: fast-route-skip channel=${channelId} peer=${maybeJson(peer)} err=${String(error)}`,
1374
2202
  );
1375
2203
  }
@@ -1378,10 +2206,10 @@ export default function register(api: OpenClawPluginApi): void {
1378
2206
 
1379
2207
  await classifyAndMaybeRotate({
1380
2208
  source: "fast",
1381
- sessionKey: resolved.sessionKey,
2209
+ sessionKey: resolvedSessionKey,
1382
2210
  text,
1383
2211
  messageProvider: channelId,
1384
- dedupeKey: fastEventKey,
2212
+ agentId: resolvedAgentId,
1385
2213
  });
1386
2214
  });
1387
2215
 
@@ -1402,4 +2230,29 @@ export default function register(api: OpenClawPluginApi): void {
1402
2230
  agentId: ctx.agentId,
1403
2231
  });
1404
2232
  });
2233
+
2234
+ api.on("before_prompt_build", async (_event, ctx) => {
2235
+ if (!cfg.enabled || cfg.softSuspect.action !== "ask") {
2236
+ return;
2237
+ }
2238
+ const sessionKey = ctx.sessionKey?.trim();
2239
+ if (!sessionKey) {
2240
+ return;
2241
+ }
2242
+ const seenAt = pendingSoftSuspectSteeringBySession.get(sessionKey);
2243
+ if (typeof seenAt !== "number") {
2244
+ return;
2245
+ }
2246
+ if (Date.now() - seenAt > cfg.softSuspect.ttlMs) {
2247
+ pendingSoftSuspectSteeringBySession.delete(sessionKey);
2248
+ return;
2249
+ }
2250
+ pendingSoftSuspectSteeringBySession.delete(sessionKey);
2251
+ return { prependContext: cfg.softSuspect.prompt };
2252
+ });
2253
+
2254
+ api.on("gateway_stop", async () => {
2255
+ clearPersistenceTimer();
2256
+ await flushPersistentState("gateway-stop");
2257
+ });
1405
2258
  }