openclaw-topic-shift-reset 0.3.0 → 0.4.1

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
@@ -2,6 +2,8 @@
2
2
 
3
3
  OpenClaw plugin that detects topic shifts and rotates to a fresh session automatically.
4
4
 
5
+ Classifier input sources: inbound user message text (`message_received`) and successful outbound agent message text (`message_sent`) only. It does not classify `before_model_resolve` prompt wrappers.
6
+
5
7
  ## Why this plugin exists
6
8
 
7
9
  OpenClaw builds each model call with the current prompt plus session history. As one session accumulates mixed topics, prompts get larger, token usage grows, and context-overflow/compaction pressure increases.
@@ -45,6 +47,7 @@ Add this plugin entry in `~/.openclaw/openclaw.json` (or merge into your existin
45
47
  },
46
48
  "softSuspect": {
47
49
  "action": "ask",
50
+ "mode": "strict",
48
51
  "ttlSeconds": 120
49
52
  },
50
53
  "dryRun": false,
@@ -114,6 +117,7 @@ When the classifier sees a soft topic-shift signal (`suspect`) but not enough co
114
117
  {
115
118
  "softSuspect": {
116
119
  "action": "ask",
120
+ "mode": "strict",
117
121
  "prompt": "Potential topic shift detected. Ask one short clarification question to confirm the user's new goal before proceeding.",
118
122
  "ttlSeconds": 120
119
123
  }
@@ -121,6 +125,7 @@ When the classifier sees a soft topic-shift signal (`suspect`) but not enough co
121
125
  ```
122
126
 
123
127
  - `action`: `ask` (default) or `none`.
128
+ - `mode`: `strict` (default, require clarification turn before soft-confirm reset) or `best_effort` (legacy timing-based behavior).
124
129
  - `prompt`: optional custom steer text.
125
130
  - `ttlSeconds`: max age before a pending steer expires.
126
131
 
@@ -130,6 +135,67 @@ When the classifier sees a soft topic-shift signal (`suspect`) but not enough co
130
135
  openclaw logs --follow --plain | rg topic-shift-reset
131
136
  ```
132
137
 
138
+ ## Log reference
139
+
140
+ All plugin logs are prefixed with `topic-shift-reset:`.
141
+
142
+ ### Info
143
+
144
+ - `embedding backend <name>`
145
+ Embeddings are active (`openai:*` or `ollama:*` backend).
146
+ - `embedding backend unavailable, using lexical-only mode`
147
+ No embedding backend is available; lexical signals only.
148
+ - `restored state sessions=<n> rotations=<n>`
149
+ Persisted runtime state restored at startup.
150
+ - `would-rotate source=<user|agent> reason=<...> session=<...> ...`
151
+ Dry-run rotation decision; no session mutation is written.
152
+ - `rotated source=<user|agent> reason=<...> session=<...> ... handoff=<0|1>`
153
+ Rotation executed (new `sessionId` written). `handoff=1` means handoff context was enqueued.
154
+
155
+ ### Debug (`debug: true`)
156
+
157
+ - `classify source=<...> kind=<warmup|stable|suspect|rotate-hard|rotate-soft> reason=<...> ...`
158
+ Full classifier output and metrics for a processed message.
159
+ - `suspect-queued session=<...>`
160
+ Soft-suspect state queued for clarification steering.
161
+ - `ask-injected session=<...>`
162
+ Clarification steer was injected into prompt build for this session.
163
+ - `ask-resolved user-reply session=<...>`
164
+ A new user reply arrived after the injected clarification turn.
165
+ - `ask-blocked-waiting-injection session=<...>`
166
+ Strict mode prevented soft-confirm reset until clarification steer is injected.
167
+ - `skip-internal-provider source=<...> provider=<...> session=<...>`
168
+ Skipped event from internal/non-user provider (for example cron/system paths).
169
+ - `skip-low-signal source=<...> session=<...> chars=<n> tokens=<n>`
170
+ Skipped message because it did not meet minimum signal thresholds.
171
+ - `user-route-skip channel=<...> peer=<...> err=<...>`
172
+ User-message route resolution failed, so the inbound event was ignored.
173
+ - `agent-route-skip channel=<...> peer=<...> err=<...>`
174
+ Agent-message route resolution failed, so the outbound event was ignored.
175
+ - `state-flushed reason=<scheduled|urgent|gateway-stop> sessions=<n> rotations=<n>`
176
+ In-memory classifier state flushed to persistence storage.
177
+
178
+ ### Warn
179
+
180
+ - `rotate failed no-session-entry session=<...>`
181
+ Rotation was requested but no matching session entry was found to mutate.
182
+ - `handoff tail fallback full-read file=<...>`
183
+ Tail read optimization fell back to a full transcript read.
184
+ - `handoff read failed file=<...> err=<...>`
185
+ Could not read prior session transcript for handoff injection.
186
+ - `persistence disabled (state path): <err>`
187
+ Plugin could not resolve state path; persistence is disabled.
188
+ - `state flush failed err=<...>`
189
+ Failed to write persistent state.
190
+ - `state restore failed err=<...>`
191
+ Failed to read/parse persistent state.
192
+ - `state version mismatch expected=<...> got=<...>; ignoring persisted state`
193
+ Stored persistence schema version differs; old state is ignored.
194
+ - `embedding backend init failed: <err>`
195
+ Embedding backend initialization failed at startup.
196
+ - `embeddings error backend=<name> err=<...>`
197
+ Runtime embedding request failed for a message; processing continues.
198
+
133
199
  ## Advanced tuning
134
200
 
135
201
  Use `config.advanced` only if needed. Full reference:
@@ -155,4 +221,4 @@ No build step is required. OpenClaw loads `src/index.ts` via jiti.
155
221
 
156
222
  ## Known tradeoff (plugin-only)
157
223
 
158
- This plugin improves timing with fast path + fallback, but cannot guarantee 100% that the triggering message becomes the first persisted message of the new session without core pre-session hooks.
224
+ This plugin cannot guarantee 100% that the triggering message becomes the first persisted message of the new session because resets happen in runtime hooks and provider pipelines vary.
@@ -19,6 +19,7 @@ This plugin now accepts one canonical key per concept:
19
19
  },
20
20
  "softSuspect": {
21
21
  "action": "ask",
22
+ "mode": "strict",
22
23
  "ttlSeconds": 120
23
24
  },
24
25
  "dryRun": false,
@@ -28,6 +29,9 @@ This plugin now accepts one canonical key per concept:
28
29
 
29
30
  ## Public options
30
31
 
32
+ Classifier inputs are limited to inbound user message text and successful outbound agent message text.
33
+ With `softSuspect.mode: "strict"` (default), a soft-confirm reset is blocked until a clarification steer is injected and a subsequent user reply is observed.
34
+
31
35
  - `enabled`: plugin on/off.
32
36
  - `preset`: `conservative | balanced | aggressive`.
33
37
  - `embedding.provider`: `auto | openai | ollama | none`.
@@ -39,6 +43,7 @@ This plugin now accepts one canonical key per concept:
39
43
  - `handoff.lastN`: number of transcript messages to include in handoff.
40
44
  - `handoff.maxChars`: per-message truncation cap in handoff text.
41
45
  - `softSuspect.action`: `ask | none`.
46
+ - `softSuspect.mode`: `strict | best_effort`.
42
47
  - `softSuspect.prompt`: optional steer text injected on soft-suspect.
43
48
  - `softSuspect.ttlSeconds`: expiry for pending soft-suspect steer.
44
49
  - `dryRun`: logs would-rotate events without session resets.
@@ -69,6 +74,7 @@ This plugin now accepts one canonical key per concept:
69
74
  - `handoff.lastN`: `6`
70
75
  - `handoff.maxChars`: `220`
71
76
  - `softSuspect.action`: `ask`
77
+ - `softSuspect.mode`: `strict`
72
78
  - `softSuspect.ttlSeconds`: `120`
73
79
  - `advanced.minSignalChars`: `20`
74
80
  - `advanced.minSignalTokenCount`: `3`
@@ -128,7 +134,7 @@ Advanced keys:
128
134
  `ignoredProviders` expects canonical provider IDs:
129
135
 
130
136
  - `telegram`, `whatsapp`, `signal`, `discord`, `slack`, `matrix`, `msteams`, `imessage`, `web`, `voice`
131
- - model/provider IDs like `openai`, `anthropic`, `ollama` (for fallback hook contexts)
137
+ - internal/system-style providers like `cron-event`, `heartbeat`, `exec-event`
132
138
 
133
139
  ## Migration note
134
140
 
@@ -145,14 +151,14 @@ Legacy alias keys are not supported in this release. Config validation fails if
145
151
  Classifier runtime state is persisted automatically under the OpenClaw state directory (`plugins/<plugin-id>/runtime-state.v1.json`).
146
152
 
147
153
  - Persisted: per-session topic history, pending soft-signal windows, topic centroid, rotation dedupe map.
148
- - Not persisted: transient fast-event dedupe cache.
154
+ - Not persisted: transient message-event dedupe cache.
149
155
  - No extra config is required.
150
156
 
151
157
  ## Log interpretation
152
158
 
153
159
  Classifier logs look like:
154
160
 
155
- `topic-shift-reset: classify source=<fast|fallback> kind=<...> reason=<...> ...`
161
+ `topic-shift-reset: classify source=<user|agent> kind=<...> reason=<...> ...`
156
162
 
157
163
  Kinds:
158
164
 
@@ -167,4 +173,4 @@ Other lines:
167
173
  - `skip-low-signal`: message skipped by hard signal floor (`minSignalChars`/`minSignalTokenCount`).
168
174
  - `would-rotate`: `dryRun=true` synthetic rotate event (no reset mutation).
169
175
  - `rotated`: actual session rotation happened.
170
- - `classify` / `skip-low-signal` / `skip-cross-path-duplicate`: debug-level diagnostics (`debug=true`).
176
+ - `classify` / `skip-low-signal` / `user-route-skip` / `agent-route-skip`: debug-level diagnostics (`debug=true`).
@@ -73,6 +73,11 @@
73
73
  "enum": ["none", "ask"],
74
74
  "default": "ask"
75
75
  },
76
+ "mode": {
77
+ "type": "string",
78
+ "enum": ["strict", "best_effort"],
79
+ "default": "strict"
80
+ },
76
81
  "prompt": {
77
82
  "type": "string",
78
83
  "default": "Potential topic shift detected. Ask one short clarification question to confirm the user's new goal before proceeding."
@@ -268,7 +273,7 @@
268
273
  },
269
274
  "softSuspect": {
270
275
  "label": "Soft-Suspect Clarification",
271
- "help": "When a soft topic-shift signal appears, optionally steer the model to ask one clarification question before proceeding."
276
+ "help": "strict = require one clarification turn before soft-confirm reset. best_effort = steer when possible."
272
277
  },
273
278
  "dryRun": {
274
279
  "label": "Dry Run",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-topic-shift-reset",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ type PresetName = "conservative" | "balanced" | "aggressive";
12
12
  type EmbeddingProvider = "auto" | "none" | "openai" | "ollama";
13
13
  type HandoffMode = "none" | "summary" | "verbatim_last_n";
14
14
  type SoftSuspectAction = "none" | "ask";
15
+ type SoftSuspectMode = "best_effort" | "strict";
15
16
 
16
17
  type EmbeddingConfig = {
17
18
  provider?: EmbeddingProvider;
@@ -29,6 +30,7 @@ type HandoffConfig = {
29
30
 
30
31
  type SoftSuspectConfig = {
31
32
  action?: SoftSuspectAction;
33
+ mode?: SoftSuspectMode;
32
34
  prompt?: string;
33
35
  ttlSeconds?: number;
34
36
  };
@@ -110,6 +112,7 @@ type ResolvedConfig = {
110
112
  };
111
113
  softSuspect: {
112
114
  action: SoftSuspectAction;
115
+ mode: SoftSuspectMode;
113
116
  prompt: string;
114
117
  ttlMs: number;
115
118
  };
@@ -211,6 +214,8 @@ type FastMessageEventLike = {
211
214
  metadata?: Record<string, unknown>;
212
215
  };
213
216
 
217
+ type ClassificationSource = "user" | "agent";
218
+
214
219
  type TranscriptMessage = {
215
220
  role: string;
216
221
  text: string;
@@ -283,6 +288,7 @@ const DEFAULTS = {
283
288
  handoffLastN: 6,
284
289
  handoffMaxChars: 220,
285
290
  softSuspectAction: "ask" as SoftSuspectAction,
291
+ softSuspectMode: "strict" as SoftSuspectMode,
286
292
  softSuspectPrompt:
287
293
  "Potential topic shift detected. Ask one short clarification question to confirm the user's new goal before proceeding.",
288
294
  softSuspectTtlSeconds: 120,
@@ -320,10 +326,9 @@ const LOCK_OPTIONS = {
320
326
 
321
327
  const MAX_TRACKED_SESSIONS = 10_000;
322
328
  const STALE_SESSION_STATE_MS = 4 * 60 * 60 * 1000;
323
- const MAX_RECENT_FAST_EVENTS = 20_000;
324
- const FAST_EVENT_TTL_MS = 5 * 60 * 1000;
329
+ const MAX_RECENT_MESSAGE_EVENTS = 20_000;
330
+ const MESSAGE_EVENT_TTL_MS = 5 * 60 * 1000;
325
331
  const ROTATION_DEDUPE_MS = 25_000;
326
- const CROSS_PATH_DEDUPE_MS = 15_000;
327
332
  const PERSISTENCE_SCHEMA_VERSION = 1;
328
333
  const PERSISTENCE_FILE_NAME = "runtime-state.v1.json";
329
334
  const PERSISTENCE_FLUSH_DEBOUNCE_MS = 1_200;
@@ -407,6 +412,17 @@ function normalizeSoftSuspectAction(value: unknown): SoftSuspectAction {
407
412
  return DEFAULTS.softSuspectAction;
408
413
  }
409
414
 
415
+ function normalizeSoftSuspectMode(value: unknown): SoftSuspectMode {
416
+ if (typeof value !== "string") {
417
+ return DEFAULTS.softSuspectMode;
418
+ }
419
+ const normalized = value.trim().toLowerCase();
420
+ if (normalized === "best_effort" || normalized === "strict") {
421
+ return normalized;
422
+ }
423
+ return DEFAULTS.softSuspectMode;
424
+ }
425
+
410
426
  function compileRegexList(values: unknown, fallback: readonly string[]): RegExp[] {
411
427
  const source = Array.isArray(values) ? values : fallback;
412
428
  const out: RegExp[] = [];
@@ -789,6 +805,7 @@ function resolveConfig(raw: unknown): ResolvedConfig {
789
805
  },
790
806
  softSuspect: {
791
807
  action: normalizeSoftSuspectAction(softSuspect.action),
808
+ mode: normalizeSoftSuspectMode(softSuspect.mode),
792
809
  prompt:
793
810
  typeof softSuspect.prompt === "string" && softSuspect.prompt.trim()
794
811
  ? softSuspect.prompt.trim()
@@ -1556,40 +1573,12 @@ function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number
1556
1573
  return changed;
1557
1574
  }
1558
1575
 
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;
1585
- }
1586
-
1587
1576
  async function rotateSessionEntry(params: {
1588
1577
  api: OpenClawPluginApi;
1589
1578
  cfg: ResolvedConfig;
1590
1579
  sessionKey: string;
1591
1580
  agentId?: string;
1592
- source: "fast" | "fallback";
1581
+ source: ClassificationSource;
1593
1582
  reason: string;
1594
1583
  metrics: ClassifierMetrics;
1595
1584
  entry: HistoryEntry;
@@ -1694,15 +1683,18 @@ async function rotateSessionEntry(params: {
1694
1683
  export default function register(api: OpenClawPluginApi): void {
1695
1684
  const cfg = resolveConfig(api.pluginConfig);
1696
1685
  const sessionState = new Map<string, SessionState>();
1697
- const recentFastEvents = new Map<string, number>();
1686
+ const recentUserEvents = new Map<string, number>();
1687
+ const recentAgentEvents = new Map<string, number>();
1698
1688
  const recentRotationBySession = new Map<string, number>();
1699
1689
  const pendingSoftSuspectSteeringBySession = new Map<string, number>();
1700
- const recentClassifiedBySessionHash = new Map<
1701
- string,
1702
- { at: number; source: "fast" | "fallback" }
1703
- >();
1690
+ const awaitingSoftSuspectReplyBySession = new Map<string, number>();
1704
1691
  const sessionWorkQueue = new Map<string, Promise<unknown>>();
1705
1692
 
1693
+ const clearSoftSuspectSteerState = (sessionKey: string) => {
1694
+ pendingSoftSuspectSteeringBySession.delete(sessionKey);
1695
+ awaitingSoftSuspectReplyBySession.delete(sessionKey);
1696
+ };
1697
+
1706
1698
  const persistencePath = (() => {
1707
1699
  try {
1708
1700
  const stateDir = api.runtime.state.resolveStateDir();
@@ -1741,7 +1733,7 @@ export default function register(api: OpenClawPluginApi): void {
1741
1733
  persistenceFlushPromise = (async () => {
1742
1734
  const now = Date.now();
1743
1735
  pruneStateMaps(sessionState);
1744
- pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
1736
+ pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_MESSAGE_EVENTS);
1745
1737
 
1746
1738
  const payload: PersistedRuntimeState = {
1747
1739
  version: PERSISTENCE_SCHEMA_VERSION,
@@ -1866,7 +1858,7 @@ export default function register(api: OpenClawPluginApi): void {
1866
1858
  }
1867
1859
  recentRotationBySession.set(key, Math.floor(ts));
1868
1860
  }
1869
- pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
1861
+ pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_MESSAGE_EVENTS);
1870
1862
 
1871
1863
  api.logger.info(
1872
1864
  `topic-shift-reset: restored state sessions=${sessionState.size} rotations=${recentRotationBySession.size}`,
@@ -1893,7 +1885,7 @@ export default function register(api: OpenClawPluginApi): void {
1893
1885
  }
1894
1886
 
1895
1887
  const classifyAndMaybeRotateInner = async (params: {
1896
- source: "fast" | "fallback";
1888
+ source: ClassificationSource;
1897
1889
  sessionKey: string;
1898
1890
  text: string;
1899
1891
  messageProvider?: string;
@@ -1944,31 +1936,10 @@ export default function register(api: OpenClawPluginApi): void {
1944
1936
  }
1945
1937
 
1946
1938
  const tokens = new Set(tokenList);
1947
-
1948
1939
  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
- }
1967
- return;
1968
- }
1969
- recentClassifiedBySessionHash.set(classifyDedupeKey, { at: now, source: params.source });
1970
- pruneRecentClassifiedMap(recentClassifiedBySessionHash, CROSS_PATH_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
1940
+ const entry: HistoryEntry = { tokens, at: now };
1971
1941
 
1942
+ const contentHash = hashString(normalizeTextForHash(text));
1972
1943
  const lastRotationAt = recentRotationBySession.get(`${sessionKey}:${contentHash}`);
1973
1944
  if (typeof lastRotationAt === "number" && now - lastRotationAt < ROTATION_DEDUPE_MS) {
1974
1945
  return;
@@ -1987,10 +1958,41 @@ export default function register(api: OpenClawPluginApi): void {
1987
1958
  } satisfies SessionState);
1988
1959
  state.lastSeenAt = now;
1989
1960
 
1961
+ if (params.source === "agent") {
1962
+ // Outbound agent text helps keep topic context fresh, but it should not
1963
+ // advance soft-suspect confirmation state by itself.
1964
+ state.history = trimHistory([...state.history, entry], cfg.historyWindow);
1965
+ updateTopicCentroid(state, entry.embedding);
1966
+ sessionState.set(sessionKey, state);
1967
+ pruneStateMaps(sessionState);
1968
+ schedulePersistentFlush(false);
1969
+ return;
1970
+ }
1971
+
1972
+ const pendingSoftSteerAt = pendingSoftSuspectSteeringBySession.get(sessionKey);
1973
+ const pendingSoftSteerActive =
1974
+ typeof pendingSoftSteerAt === "number" && now - pendingSoftSteerAt <= cfg.softSuspect.ttlMs;
1975
+ if (typeof pendingSoftSteerAt === "number" && !pendingSoftSteerActive) {
1976
+ pendingSoftSuspectSteeringBySession.delete(sessionKey);
1977
+ }
1978
+
1979
+ const awaitingSoftReplyAt = awaitingSoftSuspectReplyBySession.get(sessionKey);
1980
+ const awaitingSoftReplyActive =
1981
+ typeof awaitingSoftReplyAt === "number" && now - awaitingSoftReplyAt <= cfg.softSuspect.ttlMs;
1982
+ if (typeof awaitingSoftReplyAt === "number" && !awaitingSoftReplyActive) {
1983
+ awaitingSoftSuspectReplyBySession.delete(sessionKey);
1984
+ }
1985
+ if (awaitingSoftReplyActive) {
1986
+ awaitingSoftSuspectReplyBySession.delete(sessionKey);
1987
+ if (cfg.debug) {
1988
+ api.logger.debug(`topic-shift-reset: ask-resolved user-reply session=${sessionKey}`);
1989
+ }
1990
+ }
1991
+
1990
1992
  const baselineTokens = unionTokens(state.history);
1991
1993
  const lexical = computeLexicalFeatures({
1992
1994
  cfg,
1993
- entry: { tokens, at: now },
1995
+ entry,
1994
1996
  tokenList,
1995
1997
  baselineTokens,
1996
1998
  });
@@ -2021,12 +2023,14 @@ export default function register(api: OpenClawPluginApi): void {
2021
2023
  }
2022
2024
  }
2023
2025
 
2024
- const entry: HistoryEntry = { tokens, embedding, at: now };
2026
+ if (Array.isArray(embedding) && embedding.length > 0) {
2027
+ entry.embedding = embedding;
2028
+ }
2025
2029
  const similarity =
2026
2030
  entry.embedding && state.topicCentroid
2027
2031
  ? cosineSimilarity(entry.embedding, state.topicCentroid)
2028
2032
  : undefined;
2029
- const decision = classifyMessage({
2033
+ let decision = classifyMessage({
2030
2034
  cfg,
2031
2035
  state,
2032
2036
  baselineTokenCount: baselineTokens.size,
@@ -2036,6 +2040,22 @@ export default function register(api: OpenClawPluginApi): void {
2036
2040
  now,
2037
2041
  });
2038
2042
 
2043
+ if (
2044
+ cfg.softSuspect.action === "ask" &&
2045
+ cfg.softSuspect.mode === "strict" &&
2046
+ pendingSoftSteerActive &&
2047
+ decision.kind === "rotate-soft"
2048
+ ) {
2049
+ if (cfg.debug) {
2050
+ api.logger.debug(`topic-shift-reset: ask-blocked-waiting-injection session=${sessionKey}`);
2051
+ }
2052
+ decision = {
2053
+ kind: "suspect",
2054
+ reason: "soft-suspect-awaiting-ask",
2055
+ metrics: decision.metrics,
2056
+ };
2057
+ }
2058
+
2039
2059
  if (cfg.debug) {
2040
2060
  api.logger.debug(
2041
2061
  [
@@ -2057,7 +2077,7 @@ export default function register(api: OpenClawPluginApi): void {
2057
2077
  }
2058
2078
 
2059
2079
  if (decision.kind === "warmup") {
2060
- pendingSoftSuspectSteeringBySession.delete(sessionKey);
2080
+ clearSoftSuspectSteerState(sessionKey);
2061
2081
  state.pendingSoftSignals = 0;
2062
2082
  state.pendingEntries = [];
2063
2083
  state.history = trimHistory([...state.history, entry], cfg.historyWindow);
@@ -2069,7 +2089,7 @@ export default function register(api: OpenClawPluginApi): void {
2069
2089
  }
2070
2090
 
2071
2091
  if (decision.kind === "stable") {
2072
- pendingSoftSuspectSteeringBySession.delete(sessionKey);
2092
+ clearSoftSuspectSteerState(sessionKey);
2073
2093
  const merged = [...state.history, ...state.pendingEntries, entry];
2074
2094
  for (const item of [...state.pendingEntries, entry]) {
2075
2095
  updateTopicCentroid(state, item.embedding);
@@ -2089,8 +2109,11 @@ export default function register(api: OpenClawPluginApi): void {
2089
2109
  pruneRecentMap(
2090
2110
  pendingSoftSuspectSteeringBySession,
2091
2111
  Math.max(cfg.softSuspect.ttlMs * 2, 60_000),
2092
- MAX_RECENT_FAST_EVENTS,
2112
+ MAX_RECENT_MESSAGE_EVENTS,
2093
2113
  );
2114
+ if (cfg.debug) {
2115
+ api.logger.debug(`topic-shift-reset: suspect-queued session=${sessionKey}`);
2116
+ }
2094
2117
  }
2095
2118
  state.pendingSoftSignals += 1;
2096
2119
  state.pendingEntries = trimHistory([...state.pendingEntries, entry], cfg.softConsecutiveSignals);
@@ -2117,7 +2140,7 @@ export default function register(api: OpenClawPluginApi): void {
2117
2140
  });
2118
2141
 
2119
2142
  if (rotated) {
2120
- pendingSoftSuspectSteeringBySession.delete(sessionKey);
2143
+ clearSoftSuspectSteerState(sessionKey);
2121
2144
  if (!cfg.dryRun) {
2122
2145
  recentRotationBySession.set(`${sessionKey}:${contentHash}`, Date.now());
2123
2146
  }
@@ -2129,7 +2152,7 @@ export default function register(api: OpenClawPluginApi): void {
2129
2152
  const prunedRotations = pruneRecentMap(
2130
2153
  recentRotationBySession,
2131
2154
  ROTATION_DEDUPE_MS * 3,
2132
- MAX_RECENT_FAST_EVENTS,
2155
+ MAX_RECENT_MESSAGE_EVENTS,
2133
2156
  );
2134
2157
  if (prunedRotations) {
2135
2158
  schedulePersistentFlush(false);
@@ -2137,7 +2160,7 @@ export default function register(api: OpenClawPluginApi): void {
2137
2160
  };
2138
2161
 
2139
2162
  const classifyAndMaybeRotate = async (params: {
2140
- source: "fast" | "fallback";
2163
+ source: ClassificationSource;
2141
2164
  sessionKey: string;
2142
2165
  text: string;
2143
2166
  messageProvider?: string;
@@ -2170,19 +2193,19 @@ export default function register(api: OpenClawPluginApi): void {
2170
2193
  return;
2171
2194
  }
2172
2195
 
2173
- const fastEventKey = [
2196
+ const userEventKey = [
2174
2197
  channelId,
2175
2198
  ctx.accountId ?? "",
2176
2199
  peer.kind,
2177
2200
  peer.id,
2178
2201
  hashString(normalizeTextForHash(text)),
2179
2202
  ].join("|");
2180
- const seenAt = recentFastEvents.get(fastEventKey);
2181
- if (typeof seenAt === "number" && Date.now() - seenAt < FAST_EVENT_TTL_MS) {
2203
+ const seenAt = recentUserEvents.get(userEventKey);
2204
+ if (typeof seenAt === "number" && Date.now() - seenAt < MESSAGE_EVENT_TTL_MS) {
2182
2205
  return;
2183
2206
  }
2184
- recentFastEvents.set(fastEventKey, Date.now());
2185
- pruneRecentMap(recentFastEvents, FAST_EVENT_TTL_MS, MAX_RECENT_FAST_EVENTS);
2207
+ recentUserEvents.set(userEventKey, Date.now());
2208
+ pruneRecentMap(recentUserEvents, MESSAGE_EVENT_TTL_MS, MAX_RECENT_MESSAGE_EVENTS);
2186
2209
 
2187
2210
  let resolvedSessionKey = "";
2188
2211
  let resolvedAgentId: string | undefined;
@@ -2198,14 +2221,14 @@ export default function register(api: OpenClawPluginApi): void {
2198
2221
  } catch (error) {
2199
2222
  if (cfg.debug) {
2200
2223
  api.logger.debug(
2201
- `topic-shift-reset: fast-route-skip channel=${channelId} peer=${maybeJson(peer)} err=${String(error)}`,
2224
+ `topic-shift-reset: user-route-skip channel=${channelId} peer=${maybeJson(peer)} err=${String(error)}`,
2202
2225
  );
2203
2226
  }
2204
2227
  return;
2205
2228
  }
2206
2229
 
2207
2230
  await classifyAndMaybeRotate({
2208
- source: "fast",
2231
+ source: "user",
2209
2232
  sessionKey: resolvedSessionKey,
2210
2233
  text,
2211
2234
  messageProvider: channelId,
@@ -2213,21 +2236,63 @@ export default function register(api: OpenClawPluginApi): void {
2213
2236
  });
2214
2237
  });
2215
2238
 
2216
- api.on("before_model_resolve", async (event, ctx) => {
2239
+ api.on("message_sent", async (event, ctx) => {
2217
2240
  if (!cfg.enabled) {
2218
2241
  return;
2219
2242
  }
2220
- const sessionKey = ctx.sessionKey?.trim();
2221
- if (!sessionKey) {
2243
+ if (!event.success) {
2244
+ return;
2245
+ }
2246
+ const channelId = ctx.channelId?.trim();
2247
+ if (!channelId) {
2248
+ return;
2249
+ }
2250
+ const text = event.content?.trim() ?? "";
2251
+ if (!text) {
2252
+ return;
2253
+ }
2254
+
2255
+ const peer = inferFastPeer({ from: event.to }, { conversationId: ctx.conversationId });
2256
+ const agentEventKey = [
2257
+ channelId,
2258
+ ctx.accountId ?? "",
2259
+ peer.kind,
2260
+ peer.id,
2261
+ hashString(normalizeTextForHash(text)),
2262
+ ].join("|");
2263
+ const seenAt = recentAgentEvents.get(agentEventKey);
2264
+ if (typeof seenAt === "number" && Date.now() - seenAt < MESSAGE_EVENT_TTL_MS) {
2265
+ return;
2266
+ }
2267
+ recentAgentEvents.set(agentEventKey, Date.now());
2268
+ pruneRecentMap(recentAgentEvents, MESSAGE_EVENT_TTL_MS, MAX_RECENT_MESSAGE_EVENTS);
2269
+
2270
+ let resolvedSessionKey = "";
2271
+ let resolvedAgentId: string | undefined;
2272
+ try {
2273
+ const route = api.runtime.channel.routing.resolveAgentRoute({
2274
+ cfg: api.config,
2275
+ channel: channelId,
2276
+ accountId: ctx.accountId,
2277
+ peer,
2278
+ });
2279
+ resolvedSessionKey = route.sessionKey;
2280
+ resolvedAgentId = route.agentId;
2281
+ } catch (error) {
2282
+ if (cfg.debug) {
2283
+ api.logger.debug(
2284
+ `topic-shift-reset: agent-route-skip channel=${channelId} peer=${maybeJson(peer)} err=${String(error)}`,
2285
+ );
2286
+ }
2222
2287
  return;
2223
2288
  }
2224
2289
 
2225
2290
  await classifyAndMaybeRotate({
2226
- source: "fallback",
2227
- sessionKey,
2228
- text: event.prompt,
2229
- messageProvider: ctx.messageProvider,
2230
- agentId: ctx.agentId,
2291
+ source: "agent",
2292
+ sessionKey: resolvedSessionKey,
2293
+ text,
2294
+ messageProvider: channelId,
2295
+ agentId: resolvedAgentId,
2231
2296
  });
2232
2297
  });
2233
2298
 
@@ -2239,15 +2304,36 @@ export default function register(api: OpenClawPluginApi): void {
2239
2304
  if (!sessionKey) {
2240
2305
  return;
2241
2306
  }
2307
+ const now = Date.now();
2308
+ const awaitingAt = awaitingSoftSuspectReplyBySession.get(sessionKey);
2309
+ if (typeof awaitingAt === "number") {
2310
+ if (now - awaitingAt > cfg.softSuspect.ttlMs) {
2311
+ awaitingSoftSuspectReplyBySession.delete(sessionKey);
2312
+ }
2313
+ return;
2314
+ }
2315
+
2242
2316
  const seenAt = pendingSoftSuspectSteeringBySession.get(sessionKey);
2243
2317
  if (typeof seenAt !== "number") {
2244
2318
  return;
2245
2319
  }
2246
- if (Date.now() - seenAt > cfg.softSuspect.ttlMs) {
2320
+ if (now - seenAt > cfg.softSuspect.ttlMs) {
2247
2321
  pendingSoftSuspectSteeringBySession.delete(sessionKey);
2248
2322
  return;
2249
2323
  }
2324
+
2325
+ if (cfg.softSuspect.mode === "strict") {
2326
+ awaitingSoftSuspectReplyBySession.set(sessionKey, now);
2327
+ pruneRecentMap(
2328
+ awaitingSoftSuspectReplyBySession,
2329
+ Math.max(cfg.softSuspect.ttlMs * 2, 60_000),
2330
+ MAX_RECENT_MESSAGE_EVENTS,
2331
+ );
2332
+ }
2250
2333
  pendingSoftSuspectSteeringBySession.delete(sessionKey);
2334
+ if (cfg.debug) {
2335
+ api.logger.debug(`topic-shift-reset: ask-injected session=${sessionKey}`);
2336
+ }
2251
2337
  return { prependContext: cfg.softSuspect.prompt };
2252
2338
  });
2253
2339