openclaw-topic-shift-reset 0.4.0 → 0.4.2

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
 
@@ -151,8 +154,16 @@ All plugin logs are prefixed with `topic-shift-reset:`.
151
154
 
152
155
  ### Debug (`debug: true`)
153
156
 
154
- - `classify source=<...> kind=<warmup|stable|suspect|rotate-hard|rotate-soft> reason=<...> ...`
155
- Full classifier output and metrics for a processed message.
157
+ - `classify source=<...> kind=<warmup|stable|suspect|rotate-hard|rotate-soft> reason=<...> ... textHash=<...> tokens=[...] text="..."`
158
+ Full classifier output and metrics plus a compact message preview 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,10 +43,11 @@ 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.
47
- - `debug`: emits per-message classifier diagnostics.
50
+ - `debug`: emits per-message classifier diagnostics, including compact message previews.
48
51
 
49
52
  ## Built-in preset defaults
50
53
 
@@ -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.2",
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,
@@ -325,6 +329,8 @@ const STALE_SESSION_STATE_MS = 4 * 60 * 60 * 1000;
325
329
  const MAX_RECENT_MESSAGE_EVENTS = 20_000;
326
330
  const MESSAGE_EVENT_TTL_MS = 5 * 60 * 1000;
327
331
  const ROTATION_DEDUPE_MS = 25_000;
332
+ const DEBUG_LOG_TEXT_PREVIEW_CHARS = 180;
333
+ const DEBUG_LOG_TOKEN_PREVIEW_COUNT = 12;
328
334
  const PERSISTENCE_SCHEMA_VERSION = 1;
329
335
  const PERSISTENCE_FILE_NAME = "runtime-state.v1.json";
330
336
  const PERSISTENCE_FLUSH_DEBOUNCE_MS = 1_200;
@@ -408,6 +414,17 @@ function normalizeSoftSuspectAction(value: unknown): SoftSuspectAction {
408
414
  return DEFAULTS.softSuspectAction;
409
415
  }
410
416
 
417
+ function normalizeSoftSuspectMode(value: unknown): SoftSuspectMode {
418
+ if (typeof value !== "string") {
419
+ return DEFAULTS.softSuspectMode;
420
+ }
421
+ const normalized = value.trim().toLowerCase();
422
+ if (normalized === "best_effort" || normalized === "strict") {
423
+ return normalized;
424
+ }
425
+ return DEFAULTS.softSuspectMode;
426
+ }
427
+
411
428
  function compileRegexList(values: unknown, fallback: readonly string[]): RegExp[] {
412
429
  const source = Array.isArray(values) ? values : fallback;
413
430
  const out: RegExp[] = [];
@@ -790,6 +807,7 @@ function resolveConfig(raw: unknown): ResolvedConfig {
790
807
  },
791
808
  softSuspect: {
792
809
  action: normalizeSoftSuspectAction(softSuspect.action),
810
+ mode: normalizeSoftSuspectMode(softSuspect.mode),
793
811
  prompt:
794
812
  typeof softSuspect.prompt === "string" && softSuspect.prompt.trim()
795
813
  ? softSuspect.prompt.trim()
@@ -1671,8 +1689,14 @@ export default function register(api: OpenClawPluginApi): void {
1671
1689
  const recentAgentEvents = new Map<string, number>();
1672
1690
  const recentRotationBySession = new Map<string, number>();
1673
1691
  const pendingSoftSuspectSteeringBySession = new Map<string, number>();
1692
+ const awaitingSoftSuspectReplyBySession = new Map<string, number>();
1674
1693
  const sessionWorkQueue = new Map<string, Promise<unknown>>();
1675
1694
 
1695
+ const clearSoftSuspectSteerState = (sessionKey: string) => {
1696
+ pendingSoftSuspectSteeringBySession.delete(sessionKey);
1697
+ awaitingSoftSuspectReplyBySession.delete(sessionKey);
1698
+ };
1699
+
1676
1700
  const persistencePath = (() => {
1677
1701
  try {
1678
1702
  const stateDir = api.runtime.state.resolveStateDir();
@@ -1914,8 +1938,9 @@ export default function register(api: OpenClawPluginApi): void {
1914
1938
  }
1915
1939
 
1916
1940
  const tokens = new Set(tokenList);
1917
-
1918
1941
  const now = Date.now();
1942
+ const entry: HistoryEntry = { tokens, at: now };
1943
+
1919
1944
  const contentHash = hashString(normalizeTextForHash(text));
1920
1945
  const lastRotationAt = recentRotationBySession.get(`${sessionKey}:${contentHash}`);
1921
1946
  if (typeof lastRotationAt === "number" && now - lastRotationAt < ROTATION_DEDUPE_MS) {
@@ -1935,10 +1960,41 @@ export default function register(api: OpenClawPluginApi): void {
1935
1960
  } satisfies SessionState);
1936
1961
  state.lastSeenAt = now;
1937
1962
 
1963
+ if (params.source === "agent") {
1964
+ // Outbound agent text helps keep topic context fresh, but it should not
1965
+ // advance soft-suspect confirmation state by itself.
1966
+ state.history = trimHistory([...state.history, entry], cfg.historyWindow);
1967
+ updateTopicCentroid(state, entry.embedding);
1968
+ sessionState.set(sessionKey, state);
1969
+ pruneStateMaps(sessionState);
1970
+ schedulePersistentFlush(false);
1971
+ return;
1972
+ }
1973
+
1974
+ const pendingSoftSteerAt = pendingSoftSuspectSteeringBySession.get(sessionKey);
1975
+ const pendingSoftSteerActive =
1976
+ typeof pendingSoftSteerAt === "number" && now - pendingSoftSteerAt <= cfg.softSuspect.ttlMs;
1977
+ if (typeof pendingSoftSteerAt === "number" && !pendingSoftSteerActive) {
1978
+ pendingSoftSuspectSteeringBySession.delete(sessionKey);
1979
+ }
1980
+
1981
+ const awaitingSoftReplyAt = awaitingSoftSuspectReplyBySession.get(sessionKey);
1982
+ const awaitingSoftReplyActive =
1983
+ typeof awaitingSoftReplyAt === "number" && now - awaitingSoftReplyAt <= cfg.softSuspect.ttlMs;
1984
+ if (typeof awaitingSoftReplyAt === "number" && !awaitingSoftReplyActive) {
1985
+ awaitingSoftSuspectReplyBySession.delete(sessionKey);
1986
+ }
1987
+ if (awaitingSoftReplyActive) {
1988
+ awaitingSoftSuspectReplyBySession.delete(sessionKey);
1989
+ if (cfg.debug) {
1990
+ api.logger.debug(`topic-shift-reset: ask-resolved user-reply session=${sessionKey}`);
1991
+ }
1992
+ }
1993
+
1938
1994
  const baselineTokens = unionTokens(state.history);
1939
1995
  const lexical = computeLexicalFeatures({
1940
1996
  cfg,
1941
- entry: { tokens, at: now },
1997
+ entry,
1942
1998
  tokenList,
1943
1999
  baselineTokens,
1944
2000
  });
@@ -1969,12 +2025,14 @@ export default function register(api: OpenClawPluginApi): void {
1969
2025
  }
1970
2026
  }
1971
2027
 
1972
- const entry: HistoryEntry = { tokens, embedding, at: now };
2028
+ if (Array.isArray(embedding) && embedding.length > 0) {
2029
+ entry.embedding = embedding;
2030
+ }
1973
2031
  const similarity =
1974
2032
  entry.embedding && state.topicCentroid
1975
2033
  ? cosineSimilarity(entry.embedding, state.topicCentroid)
1976
2034
  : undefined;
1977
- const decision = classifyMessage({
2035
+ let decision = classifyMessage({
1978
2036
  cfg,
1979
2037
  state,
1980
2038
  baselineTokenCount: baselineTokens.size,
@@ -1984,28 +2042,52 @@ export default function register(api: OpenClawPluginApi): void {
1984
2042
  now,
1985
2043
  });
1986
2044
 
2045
+ if (
2046
+ cfg.softSuspect.action === "ask" &&
2047
+ cfg.softSuspect.mode === "strict" &&
2048
+ pendingSoftSteerActive &&
2049
+ decision.kind === "rotate-soft"
2050
+ ) {
2051
+ if (cfg.debug) {
2052
+ api.logger.debug(`topic-shift-reset: ask-blocked-waiting-injection session=${sessionKey}`);
2053
+ }
2054
+ decision = {
2055
+ kind: "suspect",
2056
+ reason: "soft-suspect-awaiting-ask",
2057
+ metrics: decision.metrics,
2058
+ };
2059
+ }
2060
+
1987
2061
  if (cfg.debug) {
1988
- api.logger.debug(
1989
- [
1990
- `topic-shift-reset: classify`,
1991
- `source=${params.source}`,
1992
- `kind=${decision.kind}`,
1993
- `reason=${decision.reason}`,
1994
- `session=${sessionKey}`,
1995
- `score=${decision.metrics.score.toFixed(3)}`,
1996
- `novelty=${decision.metrics.novelty.toFixed(3)}`,
1997
- `lex=${decision.metrics.lexicalDistance.toFixed(3)}`,
1998
- `uniq=${decision.metrics.uniqueTokenRatio.toFixed(3)}`,
1999
- `entropy=${decision.metrics.entropy.toFixed(3)}`,
2000
- `sim=${typeof decision.metrics.similarity === "number" ? decision.metrics.similarity.toFixed(3) : "n/a"}`,
2001
- `embed=${decision.metrics.usedEmbedding ? "1" : "0"}`,
2002
- `pending=${state.pendingSoftSignals}`,
2003
- ].join(" "),
2004
- );
2062
+ const textPreview = truncateText(text, DEBUG_LOG_TEXT_PREVIEW_CHARS);
2063
+ const rawPreview = truncateText(rawText, DEBUG_LOG_TEXT_PREVIEW_CHARS);
2064
+ const debugFields = [
2065
+ `topic-shift-reset: classify`,
2066
+ `source=${params.source}`,
2067
+ `kind=${decision.kind}`,
2068
+ `reason=${decision.reason}`,
2069
+ `session=${sessionKey}`,
2070
+ `score=${decision.metrics.score.toFixed(3)}`,
2071
+ `novelty=${decision.metrics.novelty.toFixed(3)}`,
2072
+ `lex=${decision.metrics.lexicalDistance.toFixed(3)}`,
2073
+ `uniq=${decision.metrics.uniqueTokenRatio.toFixed(3)}`,
2074
+ `entropy=${decision.metrics.entropy.toFixed(3)}`,
2075
+ `sim=${typeof decision.metrics.similarity === "number" ? decision.metrics.similarity.toFixed(3) : "n/a"}`,
2076
+ `embed=${decision.metrics.usedEmbedding ? "1" : "0"}`,
2077
+ `pending=${state.pendingSoftSignals}`,
2078
+ `baseline=${baselineTokens.size}`,
2079
+ `textHash=${contentHash}`,
2080
+ `tokens=${maybeJson(tokenList.slice(0, DEBUG_LOG_TOKEN_PREVIEW_COUNT))}`,
2081
+ `text=${maybeJson(textPreview)}`,
2082
+ ];
2083
+ if (rawPreview !== textPreview) {
2084
+ debugFields.push(`raw=${maybeJson(rawPreview)}`);
2085
+ }
2086
+ api.logger.debug(debugFields.join(" "));
2005
2087
  }
2006
2088
 
2007
2089
  if (decision.kind === "warmup") {
2008
- pendingSoftSuspectSteeringBySession.delete(sessionKey);
2090
+ clearSoftSuspectSteerState(sessionKey);
2009
2091
  state.pendingSoftSignals = 0;
2010
2092
  state.pendingEntries = [];
2011
2093
  state.history = trimHistory([...state.history, entry], cfg.historyWindow);
@@ -2017,7 +2099,7 @@ export default function register(api: OpenClawPluginApi): void {
2017
2099
  }
2018
2100
 
2019
2101
  if (decision.kind === "stable") {
2020
- pendingSoftSuspectSteeringBySession.delete(sessionKey);
2102
+ clearSoftSuspectSteerState(sessionKey);
2021
2103
  const merged = [...state.history, ...state.pendingEntries, entry];
2022
2104
  for (const item of [...state.pendingEntries, entry]) {
2023
2105
  updateTopicCentroid(state, item.embedding);
@@ -2039,6 +2121,9 @@ export default function register(api: OpenClawPluginApi): void {
2039
2121
  Math.max(cfg.softSuspect.ttlMs * 2, 60_000),
2040
2122
  MAX_RECENT_MESSAGE_EVENTS,
2041
2123
  );
2124
+ if (cfg.debug) {
2125
+ api.logger.debug(`topic-shift-reset: suspect-queued session=${sessionKey}`);
2126
+ }
2042
2127
  }
2043
2128
  state.pendingSoftSignals += 1;
2044
2129
  state.pendingEntries = trimHistory([...state.pendingEntries, entry], cfg.softConsecutiveSignals);
@@ -2065,7 +2150,7 @@ export default function register(api: OpenClawPluginApi): void {
2065
2150
  });
2066
2151
 
2067
2152
  if (rotated) {
2068
- pendingSoftSuspectSteeringBySession.delete(sessionKey);
2153
+ clearSoftSuspectSteerState(sessionKey);
2069
2154
  if (!cfg.dryRun) {
2070
2155
  recentRotationBySession.set(`${sessionKey}:${contentHash}`, Date.now());
2071
2156
  }
@@ -2229,15 +2314,36 @@ export default function register(api: OpenClawPluginApi): void {
2229
2314
  if (!sessionKey) {
2230
2315
  return;
2231
2316
  }
2317
+ const now = Date.now();
2318
+ const awaitingAt = awaitingSoftSuspectReplyBySession.get(sessionKey);
2319
+ if (typeof awaitingAt === "number") {
2320
+ if (now - awaitingAt > cfg.softSuspect.ttlMs) {
2321
+ awaitingSoftSuspectReplyBySession.delete(sessionKey);
2322
+ }
2323
+ return;
2324
+ }
2325
+
2232
2326
  const seenAt = pendingSoftSuspectSteeringBySession.get(sessionKey);
2233
2327
  if (typeof seenAt !== "number") {
2234
2328
  return;
2235
2329
  }
2236
- if (Date.now() - seenAt > cfg.softSuspect.ttlMs) {
2330
+ if (now - seenAt > cfg.softSuspect.ttlMs) {
2237
2331
  pendingSoftSuspectSteeringBySession.delete(sessionKey);
2238
2332
  return;
2239
2333
  }
2334
+
2335
+ if (cfg.softSuspect.mode === "strict") {
2336
+ awaitingSoftSuspectReplyBySession.set(sessionKey, now);
2337
+ pruneRecentMap(
2338
+ awaitingSoftSuspectReplyBySession,
2339
+ Math.max(cfg.softSuspect.ttlMs * 2, 60_000),
2340
+ MAX_RECENT_MESSAGE_EVENTS,
2341
+ );
2342
+ }
2240
2343
  pendingSoftSuspectSteeringBySession.delete(sessionKey);
2344
+ if (cfg.debug) {
2345
+ api.logger.debug(`topic-shift-reset: ask-injected session=${sessionKey}`);
2346
+ }
2241
2347
  return { prependContext: cfg.softSuspect.prompt };
2242
2348
  });
2243
2349