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 +11 -0
- package/docs/configuration.md +4 -0
- package/openclaw.plugin.json +6 -1
- package/package.json +1 -1
- package/src/index.ts +104 -8
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>`
|
package/docs/configuration.md
CHANGED
|
@@ -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`
|
package/openclaw.plugin.json
CHANGED
|
@@ -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": "
|
|
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
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|