openclaw-topic-shift-reset 0.4.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
@@ -47,6 +47,7 @@ Add this plugin entry in `~/.openclaw/openclaw.json` (or merge into your existin
47
47
  },
48
48
  "softSuspect": {
49
49
  "action": "ask",
50
+ "mode": "strict",
50
51
  "ttlSeconds": 120
51
52
  },
52
53
  "dryRun": false,
@@ -116,6 +117,7 @@ When the classifier sees a soft topic-shift signal (`suspect`) but not enough co
116
117
  {
117
118
  "softSuspect": {
118
119
  "action": "ask",
120
+ "mode": "strict",
119
121
  "prompt": "Potential topic shift detected. Ask one short clarification question to confirm the user's new goal before proceeding.",
120
122
  "ttlSeconds": 120
121
123
  }
@@ -123,6 +125,7 @@ When the classifier sees a soft topic-shift signal (`suspect`) but not enough co
123
125
  ```
124
126
 
125
127
  - `action`: `ask` (default) or `none`.
128
+ - `mode`: `strict` (default, require clarification turn before soft-confirm reset) or `best_effort` (legacy timing-based behavior).
126
129
  - `prompt`: optional custom steer text.
127
130
  - `ttlSeconds`: max age before a pending steer expires.
128
131
 
@@ -153,6 +156,14 @@ All plugin logs are prefixed with `topic-shift-reset:`.
153
156
 
154
157
  - `classify source=<...> kind=<warmup|stable|suspect|rotate-hard|rotate-soft> reason=<...> ...`
155
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.
156
167
  - `skip-internal-provider source=<...> provider=<...> session=<...>`
157
168
  Skipped event from internal/non-user provider (for example cron/system paths).
158
169
  - `skip-low-signal source=<...> session=<...> chars=<n> tokens=<n>`
@@ -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,
@@ -29,6 +30,7 @@ This plugin now accepts one canonical key per concept:
29
30
  ## Public options
30
31
 
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.
32
34
 
33
35
  - `enabled`: plugin on/off.
34
36
  - `preset`: `conservative | balanced | aggressive`.
@@ -41,6 +43,7 @@ Classifier inputs are limited to inbound user message text and successful outbou
41
43
  - `handoff.lastN`: number of transcript messages to include in handoff.
42
44
  - `handoff.maxChars`: per-message truncation cap in handoff text.
43
45
  - `softSuspect.action`: `ask | none`.
46
+ - `softSuspect.mode`: `strict | best_effort`.
44
47
  - `softSuspect.prompt`: optional steer text injected on soft-suspect.
45
48
  - `softSuspect.ttlSeconds`: expiry for pending soft-suspect steer.
46
49
  - `dryRun`: logs would-rotate events without session resets.
@@ -71,6 +74,7 @@ Classifier inputs are limited to inbound user message text and successful outbou
71
74
  - `handoff.lastN`: `6`
72
75
  - `handoff.maxChars`: `220`
73
76
  - `softSuspect.action`: `ask`
77
+ - `softSuspect.mode`: `strict`
74
78
  - `softSuspect.ttlSeconds`: `120`
75
79
  - `advanced.minSignalChars`: `20`
76
80
  - `advanced.minSignalTokenCount`: `3`
@@ -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.4.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
  };
@@ -285,6 +288,7 @@ const DEFAULTS = {
285
288
  handoffLastN: 6,
286
289
  handoffMaxChars: 220,
287
290
  softSuspectAction: "ask" as SoftSuspectAction,
291
+ softSuspectMode: "strict" as SoftSuspectMode,
288
292
  softSuspectPrompt:
289
293
  "Potential topic shift detected. Ask one short clarification question to confirm the user's new goal before proceeding.",
290
294
  softSuspectTtlSeconds: 120,
@@ -408,6 +412,17 @@ function normalizeSoftSuspectAction(value: unknown): SoftSuspectAction {
408
412
  return DEFAULTS.softSuspectAction;
409
413
  }
410
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
+
411
426
  function compileRegexList(values: unknown, fallback: readonly string[]): RegExp[] {
412
427
  const source = Array.isArray(values) ? values : fallback;
413
428
  const out: RegExp[] = [];
@@ -790,6 +805,7 @@ function resolveConfig(raw: unknown): ResolvedConfig {
790
805
  },
791
806
  softSuspect: {
792
807
  action: normalizeSoftSuspectAction(softSuspect.action),
808
+ mode: normalizeSoftSuspectMode(softSuspect.mode),
793
809
  prompt:
794
810
  typeof softSuspect.prompt === "string" && softSuspect.prompt.trim()
795
811
  ? softSuspect.prompt.trim()
@@ -1671,8 +1687,14 @@ export default function register(api: OpenClawPluginApi): void {
1671
1687
  const recentAgentEvents = new Map<string, number>();
1672
1688
  const recentRotationBySession = new Map<string, number>();
1673
1689
  const pendingSoftSuspectSteeringBySession = new Map<string, number>();
1690
+ const awaitingSoftSuspectReplyBySession = new Map<string, number>();
1674
1691
  const sessionWorkQueue = new Map<string, Promise<unknown>>();
1675
1692
 
1693
+ const clearSoftSuspectSteerState = (sessionKey: string) => {
1694
+ pendingSoftSuspectSteeringBySession.delete(sessionKey);
1695
+ awaitingSoftSuspectReplyBySession.delete(sessionKey);
1696
+ };
1697
+
1676
1698
  const persistencePath = (() => {
1677
1699
  try {
1678
1700
  const stateDir = api.runtime.state.resolveStateDir();
@@ -1914,8 +1936,9 @@ export default function register(api: OpenClawPluginApi): void {
1914
1936
  }
1915
1937
 
1916
1938
  const tokens = new Set(tokenList);
1917
-
1918
1939
  const now = Date.now();
1940
+ const entry: HistoryEntry = { tokens, at: now };
1941
+
1919
1942
  const contentHash = hashString(normalizeTextForHash(text));
1920
1943
  const lastRotationAt = recentRotationBySession.get(`${sessionKey}:${contentHash}`);
1921
1944
  if (typeof lastRotationAt === "number" && now - lastRotationAt < ROTATION_DEDUPE_MS) {
@@ -1935,10 +1958,41 @@ export default function register(api: OpenClawPluginApi): void {
1935
1958
  } satisfies SessionState);
1936
1959
  state.lastSeenAt = now;
1937
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
+
1938
1992
  const baselineTokens = unionTokens(state.history);
1939
1993
  const lexical = computeLexicalFeatures({
1940
1994
  cfg,
1941
- entry: { tokens, at: now },
1995
+ entry,
1942
1996
  tokenList,
1943
1997
  baselineTokens,
1944
1998
  });
@@ -1969,12 +2023,14 @@ export default function register(api: OpenClawPluginApi): void {
1969
2023
  }
1970
2024
  }
1971
2025
 
1972
- const entry: HistoryEntry = { tokens, embedding, at: now };
2026
+ if (Array.isArray(embedding) && embedding.length > 0) {
2027
+ entry.embedding = embedding;
2028
+ }
1973
2029
  const similarity =
1974
2030
  entry.embedding && state.topicCentroid
1975
2031
  ? cosineSimilarity(entry.embedding, state.topicCentroid)
1976
2032
  : undefined;
1977
- const decision = classifyMessage({
2033
+ let decision = classifyMessage({
1978
2034
  cfg,
1979
2035
  state,
1980
2036
  baselineTokenCount: baselineTokens.size,
@@ -1984,6 +2040,22 @@ export default function register(api: OpenClawPluginApi): void {
1984
2040
  now,
1985
2041
  });
1986
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
+
1987
2059
  if (cfg.debug) {
1988
2060
  api.logger.debug(
1989
2061
  [
@@ -2005,7 +2077,7 @@ export default function register(api: OpenClawPluginApi): void {
2005
2077
  }
2006
2078
 
2007
2079
  if (decision.kind === "warmup") {
2008
- pendingSoftSuspectSteeringBySession.delete(sessionKey);
2080
+ clearSoftSuspectSteerState(sessionKey);
2009
2081
  state.pendingSoftSignals = 0;
2010
2082
  state.pendingEntries = [];
2011
2083
  state.history = trimHistory([...state.history, entry], cfg.historyWindow);
@@ -2017,7 +2089,7 @@ export default function register(api: OpenClawPluginApi): void {
2017
2089
  }
2018
2090
 
2019
2091
  if (decision.kind === "stable") {
2020
- pendingSoftSuspectSteeringBySession.delete(sessionKey);
2092
+ clearSoftSuspectSteerState(sessionKey);
2021
2093
  const merged = [...state.history, ...state.pendingEntries, entry];
2022
2094
  for (const item of [...state.pendingEntries, entry]) {
2023
2095
  updateTopicCentroid(state, item.embedding);
@@ -2039,6 +2111,9 @@ export default function register(api: OpenClawPluginApi): void {
2039
2111
  Math.max(cfg.softSuspect.ttlMs * 2, 60_000),
2040
2112
  MAX_RECENT_MESSAGE_EVENTS,
2041
2113
  );
2114
+ if (cfg.debug) {
2115
+ api.logger.debug(`topic-shift-reset: suspect-queued session=${sessionKey}`);
2116
+ }
2042
2117
  }
2043
2118
  state.pendingSoftSignals += 1;
2044
2119
  state.pendingEntries = trimHistory([...state.pendingEntries, entry], cfg.softConsecutiveSignals);
@@ -2065,7 +2140,7 @@ export default function register(api: OpenClawPluginApi): void {
2065
2140
  });
2066
2141
 
2067
2142
  if (rotated) {
2068
- pendingSoftSuspectSteeringBySession.delete(sessionKey);
2143
+ clearSoftSuspectSteerState(sessionKey);
2069
2144
  if (!cfg.dryRun) {
2070
2145
  recentRotationBySession.set(`${sessionKey}:${contentHash}`, Date.now());
2071
2146
  }
@@ -2229,15 +2304,36 @@ export default function register(api: OpenClawPluginApi): void {
2229
2304
  if (!sessionKey) {
2230
2305
  return;
2231
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
+
2232
2316
  const seenAt = pendingSoftSuspectSteeringBySession.get(sessionKey);
2233
2317
  if (typeof seenAt !== "number") {
2234
2318
  return;
2235
2319
  }
2236
- if (Date.now() - seenAt > cfg.softSuspect.ttlMs) {
2320
+ if (now - seenAt > cfg.softSuspect.ttlMs) {
2237
2321
  pendingSoftSuspectSteeringBySession.delete(sessionKey);
2238
2322
  return;
2239
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
+ }
2240
2333
  pendingSoftSuspectSteeringBySession.delete(sessionKey);
2334
+ if (cfg.debug) {
2335
+ api.logger.debug(`topic-shift-reset: ask-injected session=${sessionKey}`);
2336
+ }
2241
2337
  return { prependContext: cfg.softSuspect.prompt };
2242
2338
  });
2243
2339