openclaw-topic-shift-reset 0.2.0 → 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/README.md CHANGED
@@ -11,6 +11,7 @@ This plugin tries to prevent that by detecting topic shifts and rotating to a ne
11
11
  - fewer prompt tokens per turn after a shift
12
12
  - less stale context bleeding into new questions
13
13
  - lower chance of overflow/compaction churn on long chats
14
+ - classifier state persisted across gateway restarts (no cold start after reboot)
14
15
 
15
16
  Does it deliver? Yes for clear topic changes, especially with embeddings enabled and sane defaults. It is not a core patch, so behavior is best-effort: subtle/short messages can be ambiguous, and hook timing means the triggering turn cannot be guaranteed to become the very first persisted message of the new session in every path.
16
17
 
@@ -42,6 +43,10 @@ Add this plugin entry in `~/.openclaw/openclaw.json` (or merge into your existin
42
43
  "lastN": 6,
43
44
  "maxChars": 220
44
45
  },
46
+ "softSuspect": {
47
+ "action": "ask",
48
+ "ttlSeconds": 120
49
+ },
45
50
  "dryRun": false,
46
51
  "debug": false
47
52
  }
@@ -101,6 +106,24 @@ Provider options:
101
106
  - `ollama`
102
107
  - `none` (lexical only)
103
108
 
109
+ ## Soft suspect clarification
110
+
111
+ When the classifier sees a soft topic-shift signal (`suspect`) but not enough confidence to rotate yet, the plugin can inject one-turn steer context so the model asks a brief clarification question before continuing.
112
+
113
+ ```json
114
+ {
115
+ "softSuspect": {
116
+ "action": "ask",
117
+ "prompt": "Potential topic shift detected. Ask one short clarification question to confirm the user's new goal before proceeding.",
118
+ "ttlSeconds": 120
119
+ }
120
+ }
121
+ ```
122
+
123
+ - `action`: `ask` (default) or `none`.
124
+ - `prompt`: optional custom steer text.
125
+ - `ttlSeconds`: max age before a pending steer expires.
126
+
104
127
  ## Logs
105
128
 
106
129
  ```bash
@@ -17,6 +17,10 @@ This plugin now accepts one canonical key per concept:
17
17
  "lastN": 6,
18
18
  "maxChars": 220
19
19
  },
20
+ "softSuspect": {
21
+ "action": "ask",
22
+ "ttlSeconds": 120
23
+ },
20
24
  "dryRun": false,
21
25
  "debug": false
22
26
  }
@@ -34,6 +38,9 @@ This plugin now accepts one canonical key per concept:
34
38
  - `handoff.mode`: `none | summary | verbatim_last_n`.
35
39
  - `handoff.lastN`: number of transcript messages to include in handoff.
36
40
  - `handoff.maxChars`: per-message truncation cap in handoff text.
41
+ - `softSuspect.action`: `ask | none`.
42
+ - `softSuspect.prompt`: optional steer text injected on soft-suspect.
43
+ - `softSuspect.ttlSeconds`: expiry for pending soft-suspect steer.
37
44
  - `dryRun`: logs would-rotate events without session resets.
38
45
  - `debug`: emits per-message classifier diagnostics.
39
46
 
@@ -61,6 +68,8 @@ This plugin now accepts one canonical key per concept:
61
68
  - `handoff.mode`: `summary`
62
69
  - `handoff.lastN`: `6`
63
70
  - `handoff.maxChars`: `220`
71
+ - `softSuspect.action`: `ask`
72
+ - `softSuspect.ttlSeconds`: `120`
64
73
  - `advanced.minSignalChars`: `20`
65
74
  - `advanced.minSignalTokenCount`: `3`
66
75
  - `advanced.minSignalEntropy`: `1.2`
@@ -131,6 +140,14 @@ Legacy alias keys are not supported in this release. Config validation fails if
131
140
  - `advanced.embedding`, `advanced.embeddings`, `advanced.handoff*`
132
141
  - previous top-level tuning keys
133
142
 
143
+ ## Runtime persistence
144
+
145
+ Classifier runtime state is persisted automatically under the OpenClaw state directory (`plugins/<plugin-id>/runtime-state.v1.json`).
146
+
147
+ - Persisted: per-session topic history, pending soft-signal windows, topic centroid, rotation dedupe map.
148
+ - Not persisted: transient fast-event dedupe cache.
149
+ - No extra config is required.
150
+
134
151
  ## Log interpretation
135
152
 
136
153
  Classifier logs look like:
@@ -150,3 +167,4 @@ Other lines:
150
167
  - `skip-low-signal`: message skipped by hard signal floor (`minSignalChars`/`minSignalTokenCount`).
151
168
  - `would-rotate`: `dryRun=true` synthetic rotate event (no reset mutation).
152
169
  - `rotated`: actual session rotation happened.
170
+ - `classify` / `skip-low-signal` / `skip-cross-path-duplicate`: debug-level diagnostics (`debug=true`).
@@ -64,6 +64,27 @@
64
64
  }
65
65
  }
66
66
  },
67
+ "softSuspect": {
68
+ "type": "object",
69
+ "additionalProperties": false,
70
+ "properties": {
71
+ "action": {
72
+ "type": "string",
73
+ "enum": ["none", "ask"],
74
+ "default": "ask"
75
+ },
76
+ "prompt": {
77
+ "type": "string",
78
+ "default": "Potential topic shift detected. Ask one short clarification question to confirm the user's new goal before proceeding."
79
+ },
80
+ "ttlSeconds": {
81
+ "type": "integer",
82
+ "minimum": 10,
83
+ "maximum": 1800,
84
+ "default": 120
85
+ }
86
+ }
87
+ },
67
88
  "dryRun": {
68
89
  "type": "boolean",
69
90
  "default": false
@@ -245,6 +266,10 @@
245
266
  "label": "Context Handoff",
246
267
  "help": "mode=summary is the safest default; verbatim_last_n copies recent transcript lines."
247
268
  },
269
+ "softSuspect": {
270
+ "label": "Soft-Suspect Clarification",
271
+ "help": "When a soft topic-shift signal appears, optionally steer the model to ask one clarification question before proceeding."
272
+ },
248
273
  "dryRun": {
249
274
  "label": "Dry Run",
250
275
  "help": "Only log decisions; do not rotate sessions."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-topic-shift-reset",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "OpenClaw plugin that detects topic shifts and starts a fresh session automatically.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -17,6 +17,8 @@
17
17
  "scripts": {
18
18
  "build": "echo 'No build required. OpenClaw loads src/index.ts via jiti.'",
19
19
  "dev": "echo 'No watch build required.'",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
20
22
  "prepack": "npm run build"
21
23
  },
22
24
  "keywords": [
@@ -32,6 +34,10 @@
32
34
  "peerDependencies": {
33
35
  "openclaw": ">=2026.2.18"
34
36
  },
37
+ "devDependencies": {
38
+ "@types/node": "^22.13.8",
39
+ "vitest": "^3.0.7"
40
+ },
35
41
  "openclaw": {
36
42
  "extensions": [
37
43
  "./src/index.ts"
package/src/index.ts CHANGED
@@ -11,6 +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 SoftSuspectAction = "none" | "ask";
14
15
 
15
16
  type EmbeddingConfig = {
16
17
  provider?: EmbeddingProvider;
@@ -26,6 +27,12 @@ type HandoffConfig = {
26
27
  maxChars?: number;
27
28
  };
28
29
 
30
+ type SoftSuspectConfig = {
31
+ action?: SoftSuspectAction;
32
+ prompt?: string;
33
+ ttlSeconds?: number;
34
+ };
35
+
29
36
  type StripRulesConfig = {
30
37
  dropLinePrefixPatterns?: string[];
31
38
  dropExactLines?: string[];
@@ -62,6 +69,7 @@ type TopicShiftResetConfig = {
62
69
  preset?: PresetName;
63
70
  embedding?: EmbeddingConfig;
64
71
  handoff?: HandoffConfig;
72
+ softSuspect?: SoftSuspectConfig;
65
73
  dryRun?: boolean;
66
74
  debug?: boolean;
67
75
  advanced?: TopicShiftResetAdvancedConfig;
@@ -100,6 +108,11 @@ type ResolvedConfig = {
100
108
  lastN: number;
101
109
  maxChars: number;
102
110
  };
111
+ softSuspect: {
112
+ action: SoftSuspectAction;
113
+ prompt: string;
114
+ ttlMs: number;
115
+ };
103
116
  embedding: {
104
117
  provider: EmbeddingProvider;
105
118
  model?: string;
@@ -141,6 +154,30 @@ type SessionState = {
141
154
  lastSeenAt: number;
142
155
  };
143
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;
171
+ lastSeenAt: number;
172
+ };
173
+
174
+ type PersistedRuntimeState = {
175
+ version: number;
176
+ savedAt: number;
177
+ sessionStateBySessionKey: Record<string, PersistedSessionState>;
178
+ recentRotationBySession: Record<string, number>;
179
+ };
180
+
144
181
  type ClassifierMetrics = {
145
182
  score: number;
146
183
  novelty: number;
@@ -245,6 +282,10 @@ const DEFAULTS = {
245
282
  handoffMode: "summary" as HandoffMode,
246
283
  handoffLastN: 6,
247
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,
248
289
  embeddingProvider: "auto" as EmbeddingProvider,
249
290
  embeddingTimeoutMs: 7000,
250
291
  minSignalChars: 20,
@@ -274,7 +315,7 @@ const LOCK_OPTIONS = {
274
315
  maxTimeout: 250,
275
316
  randomize: true,
276
317
  },
277
- stale: 45_000,
318
+ stale: 30 * 60 * 1000,
278
319
  } as const;
279
320
 
280
321
  const MAX_TRACKED_SESSIONS = 10_000;
@@ -282,6 +323,13 @@ const STALE_SESSION_STATE_MS = 4 * 60 * 60 * 1000;
282
323
  const MAX_RECENT_FAST_EVENTS = 20_000;
283
324
  const FAST_EVENT_TTL_MS = 5 * 60 * 1000;
284
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;
285
333
 
286
334
  function clampInt(value: unknown, fallback: number, min: number, max: number): number {
287
335
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -348,6 +396,17 @@ function normalizeHandoffMode(value: unknown): HandoffMode {
348
396
  return DEFAULTS.handoffMode;
349
397
  }
350
398
 
399
+ function normalizeSoftSuspectAction(value: unknown): SoftSuspectAction {
400
+ if (typeof value !== "string") {
401
+ return DEFAULTS.softSuspectAction;
402
+ }
403
+ const normalized = value.trim().toLowerCase();
404
+ if (normalized === "none" || normalized === "ask") {
405
+ return normalized;
406
+ }
407
+ return DEFAULTS.softSuspectAction;
408
+ }
409
+
351
410
  function compileRegexList(values: unknown, fallback: readonly string[]): RegExp[] {
352
411
  const source = Array.isArray(values) ? values : fallback;
353
412
  const out: RegExp[] = [];
@@ -376,6 +435,156 @@ function normalizeStringList(values: unknown, fallback: readonly string[]): stri
376
435
  .filter(Boolean);
377
436
  }
378
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 [];
465
+ }
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
+
379
588
  function normalizeProviderId(raw: string): string {
380
589
  const provider = raw.trim().toLowerCase();
381
590
  if (!provider) {
@@ -427,6 +636,19 @@ function normalizeProviderId(raw: string): string {
427
636
  return provider;
428
637
  }
429
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
+ );
650
+ }
651
+
430
652
  function resolveConfig(raw: unknown): ResolvedConfig {
431
653
  const obj = raw && typeof raw === "object" ? (raw as TopicShiftResetConfig) : {};
432
654
  const advanced =
@@ -437,6 +659,10 @@ function resolveConfig(raw: unknown): ResolvedConfig {
437
659
  obj.embedding && typeof obj.embedding === "object" ? (obj.embedding as EmbeddingConfig) : {};
438
660
  const handoff =
439
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
+ : {};
440
666
  const stripRules =
441
667
  advanced.stripRules && typeof advanced.stripRules === "object"
442
668
  ? (advanced.stripRules as StripRulesConfig)
@@ -561,6 +787,14 @@ function resolveConfig(raw: unknown): ResolvedConfig {
561
787
  lastN: clampInt(handoff.lastN, DEFAULTS.handoffLastN, 1, 20),
562
788
  maxChars: clampInt(handoff.maxChars, DEFAULTS.handoffMaxChars, 60, 800),
563
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
+ },
564
798
  embedding: {
565
799
  provider: normalizeEmbeddingProvider(embedding.provider),
566
800
  model: (() => {
@@ -1274,15 +1508,17 @@ async function buildHandoffEventFromPreviousSession(params: {
1274
1508
  }
1275
1509
  }
1276
1510
 
1277
- function pruneStateMaps(stateBySession: Map<string, SessionState>): void {
1511
+ function pruneStateMaps(stateBySession: Map<string, SessionState>): boolean {
1512
+ let changed = false;
1278
1513
  const now = Date.now();
1279
1514
  for (const [sessionKey, state] of stateBySession) {
1280
1515
  if (now - state.lastSeenAt > STALE_SESSION_STATE_MS) {
1281
1516
  stateBySession.delete(sessionKey);
1517
+ changed = true;
1282
1518
  }
1283
1519
  }
1284
1520
  if (stateBySession.size <= MAX_TRACKED_SESSIONS) {
1285
- return;
1521
+ return changed;
1286
1522
  }
1287
1523
  const ordered = [...stateBySession.entries()].sort((a, b) => a[1].lastSeenAt - b[1].lastSeenAt);
1288
1524
  const toDrop = stateBySession.size - MAX_TRACKED_SESSIONS;
@@ -1290,19 +1526,23 @@ function pruneStateMaps(stateBySession: Map<string, SessionState>): void {
1290
1526
  const sessionKey = ordered[i]?.[0];
1291
1527
  if (sessionKey) {
1292
1528
  stateBySession.delete(sessionKey);
1529
+ changed = true;
1293
1530
  }
1294
1531
  }
1532
+ return changed;
1295
1533
  }
1296
1534
 
1297
- 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;
1298
1537
  const now = Date.now();
1299
1538
  for (const [key, ts] of map) {
1300
1539
  if (now - ts > ttlMs) {
1301
1540
  map.delete(key);
1541
+ changed = true;
1302
1542
  }
1303
1543
  }
1304
1544
  if (map.size <= maxSize) {
1305
- return;
1545
+ return changed;
1306
1546
  }
1307
1547
  const ordered = [...map.entries()].sort((a, b) => a[1] - b[1]);
1308
1548
  const toDrop = map.size - maxSize;
@@ -1310,8 +1550,38 @@ function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number
1310
1550
  const key = ordered[i]?.[0];
1311
1551
  if (key) {
1312
1552
  map.delete(key);
1553
+ changed = true;
1554
+ }
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;
1313
1582
  }
1314
1583
  }
1584
+ return changed;
1315
1585
  }
1316
1586
 
1317
1587
  async function rotateSessionEntry(params: {
@@ -1426,6 +1696,185 @@ export default function register(api: OpenClawPluginApi): void {
1426
1696
  const sessionState = new Map<string, SessionState>();
1427
1697
  const recentFastEvents = new Map<string, number>();
1428
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
+ })();
1429
1878
 
1430
1879
  let embeddingBackend: EmbeddingBackend | null = null;
1431
1880
  let embeddingInitError: string | null = null;
@@ -1443,17 +1892,18 @@ export default function register(api: OpenClawPluginApi): void {
1443
1892
  api.logger.info(`topic-shift-reset: embedding backend ${embeddingBackend.name}`);
1444
1893
  }
1445
1894
 
1446
- const classifyAndMaybeRotate = async (params: {
1895
+ const classifyAndMaybeRotateInner = async (params: {
1447
1896
  source: "fast" | "fallback";
1448
1897
  sessionKey: string;
1449
1898
  text: string;
1450
1899
  messageProvider?: string;
1451
1900
  agentId?: string;
1452
1901
  }) => {
1902
+ await persistenceLoadPromise;
1453
1903
  if (!cfg.enabled) {
1454
1904
  return;
1455
1905
  }
1456
- const sessionKey = params.sessionKey.trim();
1906
+ const sessionKey = params.sessionKey;
1457
1907
  if (!sessionKey) {
1458
1908
  return;
1459
1909
  }
@@ -1462,6 +1912,14 @@ export default function register(api: OpenClawPluginApi): void {
1462
1912
  if (provider && cfg.ignoredProviders.has(provider)) {
1463
1913
  return;
1464
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
+ }
1465
1923
 
1466
1924
  const rawText = params.text.trim();
1467
1925
  const text = cfg.stripEnvelope ? stripClassifierEnvelope(rawText, cfg.stripRules) : rawText;
@@ -1472,7 +1930,7 @@ export default function register(api: OpenClawPluginApi): void {
1472
1930
  const tokenList = tokenizeList(text, cfg.minTokenLength);
1473
1931
  if (text.length < cfg.minSignalChars || tokenList.length < cfg.minSignalTokenCount) {
1474
1932
  if (cfg.debug) {
1475
- api.logger.info(
1933
+ api.logger.debug(
1476
1934
  [
1477
1935
  `topic-shift-reset: skip-low-signal`,
1478
1936
  `source=${params.source}`,
@@ -1487,13 +1945,34 @@ export default function register(api: OpenClawPluginApi): void {
1487
1945
 
1488
1946
  const tokens = new Set(tokenList);
1489
1947
 
1948
+ const now = Date.now();
1490
1949
  const contentHash = hashString(normalizeTextForHash(text));
1491
- const lastRotationAt = recentRotationBySession.get(`${sessionKey}:${contentHash}`);
1492
- if (typeof lastRotationAt === "number" && Date.now() - lastRotationAt < ROTATION_DEDUPE_MS) {
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
+ }
1493
1967
  return;
1494
1968
  }
1969
+ recentClassifiedBySessionHash.set(classifyDedupeKey, { at: now, source: params.source });
1970
+ pruneRecentClassifiedMap(recentClassifiedBySessionHash, CROSS_PATH_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
1495
1971
 
1496
- const now = Date.now();
1972
+ const lastRotationAt = recentRotationBySession.get(`${sessionKey}:${contentHash}`);
1973
+ if (typeof lastRotationAt === "number" && now - lastRotationAt < ROTATION_DEDUPE_MS) {
1974
+ return;
1975
+ }
1497
1976
  const state =
1498
1977
  sessionState.get(sessionKey) ??
1499
1978
  ({
@@ -1558,7 +2037,7 @@ export default function register(api: OpenClawPluginApi): void {
1558
2037
  });
1559
2038
 
1560
2039
  if (cfg.debug) {
1561
- api.logger.info(
2040
+ api.logger.debug(
1562
2041
  [
1563
2042
  `topic-shift-reset: classify`,
1564
2043
  `source=${params.source}`,
@@ -1578,16 +2057,19 @@ export default function register(api: OpenClawPluginApi): void {
1578
2057
  }
1579
2058
 
1580
2059
  if (decision.kind === "warmup") {
2060
+ pendingSoftSuspectSteeringBySession.delete(sessionKey);
1581
2061
  state.pendingSoftSignals = 0;
1582
2062
  state.pendingEntries = [];
1583
2063
  state.history = trimHistory([...state.history, entry], cfg.historyWindow);
1584
2064
  updateTopicCentroid(state, entry.embedding);
1585
2065
  sessionState.set(sessionKey, state);
1586
2066
  pruneStateMaps(sessionState);
2067
+ schedulePersistentFlush(false);
1587
2068
  return;
1588
2069
  }
1589
2070
 
1590
2071
  if (decision.kind === "stable") {
2072
+ pendingSoftSuspectSteeringBySession.delete(sessionKey);
1591
2073
  const merged = [...state.history, ...state.pendingEntries, entry];
1592
2074
  for (const item of [...state.pendingEntries, entry]) {
1593
2075
  updateTopicCentroid(state, item.embedding);
@@ -1597,14 +2079,24 @@ export default function register(api: OpenClawPluginApi): void {
1597
2079
  state.history = trimHistory(merged, cfg.historyWindow);
1598
2080
  sessionState.set(sessionKey, state);
1599
2081
  pruneStateMaps(sessionState);
2082
+ schedulePersistentFlush(false);
1600
2083
  return;
1601
2084
  }
1602
2085
 
1603
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
+ }
1604
2095
  state.pendingSoftSignals += 1;
1605
2096
  state.pendingEntries = trimHistory([...state.pendingEntries, entry], cfg.softConsecutiveSignals);
1606
2097
  sessionState.set(sessionKey, state);
1607
2098
  pruneStateMaps(sessionState);
2099
+ schedulePersistentFlush(false);
1608
2100
  return;
1609
2101
  }
1610
2102
 
@@ -1625,14 +2117,42 @@ export default function register(api: OpenClawPluginApi): void {
1625
2117
  });
1626
2118
 
1627
2119
  if (rotated) {
2120
+ pendingSoftSuspectSteeringBySession.delete(sessionKey);
1628
2121
  if (!cfg.dryRun) {
1629
2122
  recentRotationBySession.set(`${sessionKey}:${contentHash}`, Date.now());
1630
2123
  }
2124
+ schedulePersistentFlush(true);
1631
2125
  }
1632
2126
 
1633
2127
  sessionState.set(sessionKey, state);
1634
2128
  pruneStateMaps(sessionState);
1635
- 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
+ });
1636
2156
  };
1637
2157
 
1638
2158
  api.on("message_received", async (event, ctx) => {
@@ -1665,6 +2185,7 @@ export default function register(api: OpenClawPluginApi): void {
1665
2185
  pruneRecentMap(recentFastEvents, FAST_EVENT_TTL_MS, MAX_RECENT_FAST_EVENTS);
1666
2186
 
1667
2187
  let resolvedSessionKey = "";
2188
+ let resolvedAgentId: string | undefined;
1668
2189
  try {
1669
2190
  const route = api.runtime.channel.routing.resolveAgentRoute({
1670
2191
  cfg: api.config,
@@ -1673,9 +2194,10 @@ export default function register(api: OpenClawPluginApi): void {
1673
2194
  peer,
1674
2195
  });
1675
2196
  resolvedSessionKey = route.sessionKey;
2197
+ resolvedAgentId = route.agentId;
1676
2198
  } catch (error) {
1677
2199
  if (cfg.debug) {
1678
- api.logger.info(
2200
+ api.logger.debug(
1679
2201
  `topic-shift-reset: fast-route-skip channel=${channelId} peer=${maybeJson(peer)} err=${String(error)}`,
1680
2202
  );
1681
2203
  }
@@ -1687,6 +2209,7 @@ export default function register(api: OpenClawPluginApi): void {
1687
2209
  sessionKey: resolvedSessionKey,
1688
2210
  text,
1689
2211
  messageProvider: channelId,
2212
+ agentId: resolvedAgentId,
1690
2213
  });
1691
2214
  });
1692
2215
 
@@ -1707,4 +2230,29 @@ export default function register(api: OpenClawPluginApi): void {
1707
2230
  agentId: ctx.agentId,
1708
2231
  });
1709
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
+ });
1710
2258
  }