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 +67 -1
- package/docs/configuration.md +10 -4
- package/openclaw.plugin.json +6 -1
- package/package.json +1 -1
- package/src/index.ts +174 -88
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
|
|
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.
|
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,
|
|
@@ -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
|
-
-
|
|
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
|
|
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=<
|
|
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-
|
|
176
|
+
- `classify` / `skip-low-signal` / `user-route-skip` / `agent-route-skip`: debug-level diagnostics (`debug=true`).
|
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
|
};
|
|
@@ -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
|
|
324
|
-
const
|
|
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:
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
2181
|
-
if (typeof seenAt === "number" && Date.now() - seenAt <
|
|
2203
|
+
const seenAt = recentUserEvents.get(userEventKey);
|
|
2204
|
+
if (typeof seenAt === "number" && Date.now() - seenAt < MESSAGE_EVENT_TTL_MS) {
|
|
2182
2205
|
return;
|
|
2183
2206
|
}
|
|
2184
|
-
|
|
2185
|
-
pruneRecentMap(
|
|
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:
|
|
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: "
|
|
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("
|
|
2239
|
+
api.on("message_sent", async (event, ctx) => {
|
|
2217
2240
|
if (!cfg.enabled) {
|
|
2218
2241
|
return;
|
|
2219
2242
|
}
|
|
2220
|
-
|
|
2221
|
-
|
|
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: "
|
|
2227
|
-
sessionKey,
|
|
2228
|
-
text
|
|
2229
|
-
messageProvider:
|
|
2230
|
-
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 (
|
|
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
|
|