openclaw-topic-shift-reset 0.2.0 → 0.4.0
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 +79 -1
- package/docs/configuration.md +22 -2
- package/openclaw.plugin.json +25 -0
- package/package.json +7 -1
- package/src/index.ts +572 -34
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.
|
|
@@ -11,6 +13,7 @@ This plugin tries to prevent that by detecting topic shifts and rotating to a ne
|
|
|
11
13
|
- fewer prompt tokens per turn after a shift
|
|
12
14
|
- less stale context bleeding into new questions
|
|
13
15
|
- lower chance of overflow/compaction churn on long chats
|
|
16
|
+
- classifier state persisted across gateway restarts (no cold start after reboot)
|
|
14
17
|
|
|
15
18
|
Does it deliver? Yes for clear topic changes, especially with embeddings enabled and sane defaults. It is not a core patch, so behavior is best-effort: subtle/short messages can be ambiguous, and hook timing means the triggering turn cannot be guaranteed to become the very first persisted message of the new session in every path.
|
|
16
19
|
|
|
@@ -42,6 +45,10 @@ Add this plugin entry in `~/.openclaw/openclaw.json` (or merge into your existin
|
|
|
42
45
|
"lastN": 6,
|
|
43
46
|
"maxChars": 220
|
|
44
47
|
},
|
|
48
|
+
"softSuspect": {
|
|
49
|
+
"action": "ask",
|
|
50
|
+
"ttlSeconds": 120
|
|
51
|
+
},
|
|
45
52
|
"dryRun": false,
|
|
46
53
|
"debug": false
|
|
47
54
|
}
|
|
@@ -101,12 +108,83 @@ Provider options:
|
|
|
101
108
|
- `ollama`
|
|
102
109
|
- `none` (lexical only)
|
|
103
110
|
|
|
111
|
+
## Soft suspect clarification
|
|
112
|
+
|
|
113
|
+
When the classifier sees a soft topic-shift signal (`suspect`) but not enough confidence to rotate yet, the plugin can inject one-turn steer context so the model asks a brief clarification question before continuing.
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"softSuspect": {
|
|
118
|
+
"action": "ask",
|
|
119
|
+
"prompt": "Potential topic shift detected. Ask one short clarification question to confirm the user's new goal before proceeding.",
|
|
120
|
+
"ttlSeconds": 120
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- `action`: `ask` (default) or `none`.
|
|
126
|
+
- `prompt`: optional custom steer text.
|
|
127
|
+
- `ttlSeconds`: max age before a pending steer expires.
|
|
128
|
+
|
|
104
129
|
## Logs
|
|
105
130
|
|
|
106
131
|
```bash
|
|
107
132
|
openclaw logs --follow --plain | rg topic-shift-reset
|
|
108
133
|
```
|
|
109
134
|
|
|
135
|
+
## Log reference
|
|
136
|
+
|
|
137
|
+
All plugin logs are prefixed with `topic-shift-reset:`.
|
|
138
|
+
|
|
139
|
+
### Info
|
|
140
|
+
|
|
141
|
+
- `embedding backend <name>`
|
|
142
|
+
Embeddings are active (`openai:*` or `ollama:*` backend).
|
|
143
|
+
- `embedding backend unavailable, using lexical-only mode`
|
|
144
|
+
No embedding backend is available; lexical signals only.
|
|
145
|
+
- `restored state sessions=<n> rotations=<n>`
|
|
146
|
+
Persisted runtime state restored at startup.
|
|
147
|
+
- `would-rotate source=<user|agent> reason=<...> session=<...> ...`
|
|
148
|
+
Dry-run rotation decision; no session mutation is written.
|
|
149
|
+
- `rotated source=<user|agent> reason=<...> session=<...> ... handoff=<0|1>`
|
|
150
|
+
Rotation executed (new `sessionId` written). `handoff=1` means handoff context was enqueued.
|
|
151
|
+
|
|
152
|
+
### Debug (`debug: true`)
|
|
153
|
+
|
|
154
|
+
- `classify source=<...> kind=<warmup|stable|suspect|rotate-hard|rotate-soft> reason=<...> ...`
|
|
155
|
+
Full classifier output and metrics for a processed message.
|
|
156
|
+
- `skip-internal-provider source=<...> provider=<...> session=<...>`
|
|
157
|
+
Skipped event from internal/non-user provider (for example cron/system paths).
|
|
158
|
+
- `skip-low-signal source=<...> session=<...> chars=<n> tokens=<n>`
|
|
159
|
+
Skipped message because it did not meet minimum signal thresholds.
|
|
160
|
+
- `user-route-skip channel=<...> peer=<...> err=<...>`
|
|
161
|
+
User-message route resolution failed, so the inbound event was ignored.
|
|
162
|
+
- `agent-route-skip channel=<...> peer=<...> err=<...>`
|
|
163
|
+
Agent-message route resolution failed, so the outbound event was ignored.
|
|
164
|
+
- `state-flushed reason=<scheduled|urgent|gateway-stop> sessions=<n> rotations=<n>`
|
|
165
|
+
In-memory classifier state flushed to persistence storage.
|
|
166
|
+
|
|
167
|
+
### Warn
|
|
168
|
+
|
|
169
|
+
- `rotate failed no-session-entry session=<...>`
|
|
170
|
+
Rotation was requested but no matching session entry was found to mutate.
|
|
171
|
+
- `handoff tail fallback full-read file=<...>`
|
|
172
|
+
Tail read optimization fell back to a full transcript read.
|
|
173
|
+
- `handoff read failed file=<...> err=<...>`
|
|
174
|
+
Could not read prior session transcript for handoff injection.
|
|
175
|
+
- `persistence disabled (state path): <err>`
|
|
176
|
+
Plugin could not resolve state path; persistence is disabled.
|
|
177
|
+
- `state flush failed err=<...>`
|
|
178
|
+
Failed to write persistent state.
|
|
179
|
+
- `state restore failed err=<...>`
|
|
180
|
+
Failed to read/parse persistent state.
|
|
181
|
+
- `state version mismatch expected=<...> got=<...>; ignoring persisted state`
|
|
182
|
+
Stored persistence schema version differs; old state is ignored.
|
|
183
|
+
- `embedding backend init failed: <err>`
|
|
184
|
+
Embedding backend initialization failed at startup.
|
|
185
|
+
- `embeddings error backend=<name> err=<...>`
|
|
186
|
+
Runtime embedding request failed for a message; processing continues.
|
|
187
|
+
|
|
110
188
|
## Advanced tuning
|
|
111
189
|
|
|
112
190
|
Use `config.advanced` only if needed. Full reference:
|
|
@@ -132,4 +210,4 @@ No build step is required. OpenClaw loads `src/index.ts` via jiti.
|
|
|
132
210
|
|
|
133
211
|
## Known tradeoff (plugin-only)
|
|
134
212
|
|
|
135
|
-
This plugin
|
|
213
|
+
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
|
@@ -17,6 +17,10 @@ This plugin now accepts one canonical key per concept:
|
|
|
17
17
|
"lastN": 6,
|
|
18
18
|
"maxChars": 220
|
|
19
19
|
},
|
|
20
|
+
"softSuspect": {
|
|
21
|
+
"action": "ask",
|
|
22
|
+
"ttlSeconds": 120
|
|
23
|
+
},
|
|
20
24
|
"dryRun": false,
|
|
21
25
|
"debug": false
|
|
22
26
|
}
|
|
@@ -24,6 +28,8 @@ This plugin now accepts one canonical key per concept:
|
|
|
24
28
|
|
|
25
29
|
## Public options
|
|
26
30
|
|
|
31
|
+
Classifier inputs are limited to inbound user message text and successful outbound agent message text.
|
|
32
|
+
|
|
27
33
|
- `enabled`: plugin on/off.
|
|
28
34
|
- `preset`: `conservative | balanced | aggressive`.
|
|
29
35
|
- `embedding.provider`: `auto | openai | ollama | none`.
|
|
@@ -34,6 +40,9 @@ This plugin now accepts one canonical key per concept:
|
|
|
34
40
|
- `handoff.mode`: `none | summary | verbatim_last_n`.
|
|
35
41
|
- `handoff.lastN`: number of transcript messages to include in handoff.
|
|
36
42
|
- `handoff.maxChars`: per-message truncation cap in handoff text.
|
|
43
|
+
- `softSuspect.action`: `ask | none`.
|
|
44
|
+
- `softSuspect.prompt`: optional steer text injected on soft-suspect.
|
|
45
|
+
- `softSuspect.ttlSeconds`: expiry for pending soft-suspect steer.
|
|
37
46
|
- `dryRun`: logs would-rotate events without session resets.
|
|
38
47
|
- `debug`: emits per-message classifier diagnostics.
|
|
39
48
|
|
|
@@ -61,6 +70,8 @@ This plugin now accepts one canonical key per concept:
|
|
|
61
70
|
- `handoff.mode`: `summary`
|
|
62
71
|
- `handoff.lastN`: `6`
|
|
63
72
|
- `handoff.maxChars`: `220`
|
|
73
|
+
- `softSuspect.action`: `ask`
|
|
74
|
+
- `softSuspect.ttlSeconds`: `120`
|
|
64
75
|
- `advanced.minSignalChars`: `20`
|
|
65
76
|
- `advanced.minSignalTokenCount`: `3`
|
|
66
77
|
- `advanced.minSignalEntropy`: `1.2`
|
|
@@ -119,7 +130,7 @@ Advanced keys:
|
|
|
119
130
|
`ignoredProviders` expects canonical provider IDs:
|
|
120
131
|
|
|
121
132
|
- `telegram`, `whatsapp`, `signal`, `discord`, `slack`, `matrix`, `msteams`, `imessage`, `web`, `voice`
|
|
122
|
-
-
|
|
133
|
+
- internal/system-style providers like `cron-event`, `heartbeat`, `exec-event`
|
|
123
134
|
|
|
124
135
|
## Migration note
|
|
125
136
|
|
|
@@ -131,11 +142,19 @@ Legacy alias keys are not supported in this release. Config validation fails if
|
|
|
131
142
|
- `advanced.embedding`, `advanced.embeddings`, `advanced.handoff*`
|
|
132
143
|
- previous top-level tuning keys
|
|
133
144
|
|
|
145
|
+
## Runtime persistence
|
|
146
|
+
|
|
147
|
+
Classifier runtime state is persisted automatically under the OpenClaw state directory (`plugins/<plugin-id>/runtime-state.v1.json`).
|
|
148
|
+
|
|
149
|
+
- Persisted: per-session topic history, pending soft-signal windows, topic centroid, rotation dedupe map.
|
|
150
|
+
- Not persisted: transient message-event dedupe cache.
|
|
151
|
+
- No extra config is required.
|
|
152
|
+
|
|
134
153
|
## Log interpretation
|
|
135
154
|
|
|
136
155
|
Classifier logs look like:
|
|
137
156
|
|
|
138
|
-
`topic-shift-reset: classify source=<
|
|
157
|
+
`topic-shift-reset: classify source=<user|agent> kind=<...> reason=<...> ...`
|
|
139
158
|
|
|
140
159
|
Kinds:
|
|
141
160
|
|
|
@@ -150,3 +169,4 @@ Other lines:
|
|
|
150
169
|
- `skip-low-signal`: message skipped by hard signal floor (`minSignalChars`/`minSignalTokenCount`).
|
|
151
170
|
- `would-rotate`: `dryRun=true` synthetic rotate event (no reset mutation).
|
|
152
171
|
- `rotated`: actual session rotation happened.
|
|
172
|
+
- `classify` / `skip-low-signal` / `user-route-skip` / `agent-route-skip`: debug-level diagnostics (`debug=true`).
|
package/openclaw.plugin.json
CHANGED
|
@@ -64,6 +64,27 @@
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
|
+
"softSuspect": {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"additionalProperties": false,
|
|
70
|
+
"properties": {
|
|
71
|
+
"action": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"enum": ["none", "ask"],
|
|
74
|
+
"default": "ask"
|
|
75
|
+
},
|
|
76
|
+
"prompt": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"default": "Potential topic shift detected. Ask one short clarification question to confirm the user's new goal before proceeding."
|
|
79
|
+
},
|
|
80
|
+
"ttlSeconds": {
|
|
81
|
+
"type": "integer",
|
|
82
|
+
"minimum": 10,
|
|
83
|
+
"maximum": 1800,
|
|
84
|
+
"default": 120
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
67
88
|
"dryRun": {
|
|
68
89
|
"type": "boolean",
|
|
69
90
|
"default": false
|
|
@@ -245,6 +266,10 @@
|
|
|
245
266
|
"label": "Context Handoff",
|
|
246
267
|
"help": "mode=summary is the safest default; verbatim_last_n copies recent transcript lines."
|
|
247
268
|
},
|
|
269
|
+
"softSuspect": {
|
|
270
|
+
"label": "Soft-Suspect Clarification",
|
|
271
|
+
"help": "When a soft topic-shift signal appears, optionally steer the model to ask one clarification question before proceeding."
|
|
272
|
+
},
|
|
248
273
|
"dryRun": {
|
|
249
274
|
"label": "Dry Run",
|
|
250
275
|
"help": "Only log decisions; do not rotate sessions."
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-topic-shift-reset",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"scripts": {
|
|
18
18
|
"build": "echo 'No build required. OpenClaw loads src/index.ts via jiti.'",
|
|
19
19
|
"dev": "echo 'No watch build required.'",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
20
22
|
"prepack": "npm run build"
|
|
21
23
|
},
|
|
22
24
|
"keywords": [
|
|
@@ -32,6 +34,10 @@
|
|
|
32
34
|
"peerDependencies": {
|
|
33
35
|
"openclaw": ">=2026.2.18"
|
|
34
36
|
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^22.13.8",
|
|
39
|
+
"vitest": "^3.0.7"
|
|
40
|
+
},
|
|
35
41
|
"openclaw": {
|
|
36
42
|
"extensions": [
|
|
37
43
|
"./src/index.ts"
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type PresetName = "conservative" | "balanced" | "aggressive";
|
|
12
12
|
type EmbeddingProvider = "auto" | "none" | "openai" | "ollama";
|
|
13
13
|
type HandoffMode = "none" | "summary" | "verbatim_last_n";
|
|
14
|
+
type SoftSuspectAction = "none" | "ask";
|
|
14
15
|
|
|
15
16
|
type EmbeddingConfig = {
|
|
16
17
|
provider?: EmbeddingProvider;
|
|
@@ -26,6 +27,12 @@ type HandoffConfig = {
|
|
|
26
27
|
maxChars?: number;
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
type SoftSuspectConfig = {
|
|
31
|
+
action?: SoftSuspectAction;
|
|
32
|
+
prompt?: string;
|
|
33
|
+
ttlSeconds?: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
29
36
|
type StripRulesConfig = {
|
|
30
37
|
dropLinePrefixPatterns?: string[];
|
|
31
38
|
dropExactLines?: string[];
|
|
@@ -62,6 +69,7 @@ type TopicShiftResetConfig = {
|
|
|
62
69
|
preset?: PresetName;
|
|
63
70
|
embedding?: EmbeddingConfig;
|
|
64
71
|
handoff?: HandoffConfig;
|
|
72
|
+
softSuspect?: SoftSuspectConfig;
|
|
65
73
|
dryRun?: boolean;
|
|
66
74
|
debug?: boolean;
|
|
67
75
|
advanced?: TopicShiftResetAdvancedConfig;
|
|
@@ -100,6 +108,11 @@ type ResolvedConfig = {
|
|
|
100
108
|
lastN: number;
|
|
101
109
|
maxChars: number;
|
|
102
110
|
};
|
|
111
|
+
softSuspect: {
|
|
112
|
+
action: SoftSuspectAction;
|
|
113
|
+
prompt: string;
|
|
114
|
+
ttlMs: number;
|
|
115
|
+
};
|
|
103
116
|
embedding: {
|
|
104
117
|
provider: EmbeddingProvider;
|
|
105
118
|
model?: string;
|
|
@@ -141,6 +154,30 @@ type SessionState = {
|
|
|
141
154
|
lastSeenAt: number;
|
|
142
155
|
};
|
|
143
156
|
|
|
157
|
+
type PersistedHistoryEntry = {
|
|
158
|
+
tokens: string[];
|
|
159
|
+
at: number;
|
|
160
|
+
embedding?: number[];
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
type PersistedSessionState = {
|
|
164
|
+
history: PersistedHistoryEntry[];
|
|
165
|
+
pendingSoftSignals: number;
|
|
166
|
+
pendingEntries: PersistedHistoryEntry[];
|
|
167
|
+
lastResetAt?: number;
|
|
168
|
+
topicCentroid?: number[];
|
|
169
|
+
topicCount: number;
|
|
170
|
+
topicDim?: number;
|
|
171
|
+
lastSeenAt: number;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
type PersistedRuntimeState = {
|
|
175
|
+
version: number;
|
|
176
|
+
savedAt: number;
|
|
177
|
+
sessionStateBySessionKey: Record<string, PersistedSessionState>;
|
|
178
|
+
recentRotationBySession: Record<string, number>;
|
|
179
|
+
};
|
|
180
|
+
|
|
144
181
|
type ClassifierMetrics = {
|
|
145
182
|
score: number;
|
|
146
183
|
novelty: number;
|
|
@@ -174,6 +211,8 @@ type FastMessageEventLike = {
|
|
|
174
211
|
metadata?: Record<string, unknown>;
|
|
175
212
|
};
|
|
176
213
|
|
|
214
|
+
type ClassificationSource = "user" | "agent";
|
|
215
|
+
|
|
177
216
|
type TranscriptMessage = {
|
|
178
217
|
role: string;
|
|
179
218
|
text: string;
|
|
@@ -245,6 +284,10 @@ const DEFAULTS = {
|
|
|
245
284
|
handoffMode: "summary" as HandoffMode,
|
|
246
285
|
handoffLastN: 6,
|
|
247
286
|
handoffMaxChars: 220,
|
|
287
|
+
softSuspectAction: "ask" as SoftSuspectAction,
|
|
288
|
+
softSuspectPrompt:
|
|
289
|
+
"Potential topic shift detected. Ask one short clarification question to confirm the user's new goal before proceeding.",
|
|
290
|
+
softSuspectTtlSeconds: 120,
|
|
248
291
|
embeddingProvider: "auto" as EmbeddingProvider,
|
|
249
292
|
embeddingTimeoutMs: 7000,
|
|
250
293
|
minSignalChars: 20,
|
|
@@ -274,14 +317,20 @@ const LOCK_OPTIONS = {
|
|
|
274
317
|
maxTimeout: 250,
|
|
275
318
|
randomize: true,
|
|
276
319
|
},
|
|
277
|
-
stale:
|
|
320
|
+
stale: 30 * 60 * 1000,
|
|
278
321
|
} as const;
|
|
279
322
|
|
|
280
323
|
const MAX_TRACKED_SESSIONS = 10_000;
|
|
281
324
|
const STALE_SESSION_STATE_MS = 4 * 60 * 60 * 1000;
|
|
282
|
-
const
|
|
283
|
-
const
|
|
325
|
+
const MAX_RECENT_MESSAGE_EVENTS = 20_000;
|
|
326
|
+
const MESSAGE_EVENT_TTL_MS = 5 * 60 * 1000;
|
|
284
327
|
const ROTATION_DEDUPE_MS = 25_000;
|
|
328
|
+
const PERSISTENCE_SCHEMA_VERSION = 1;
|
|
329
|
+
const PERSISTENCE_FILE_NAME = "runtime-state.v1.json";
|
|
330
|
+
const PERSISTENCE_FLUSH_DEBOUNCE_MS = 1_200;
|
|
331
|
+
const MAX_TOKENS_PER_PERSISTED_ENTRY = 256;
|
|
332
|
+
const MAX_PERSISTED_EMBEDDING_DIM = 8_192;
|
|
333
|
+
const MAX_PERSISTED_PENDING_ENTRIES = 8;
|
|
285
334
|
|
|
286
335
|
function clampInt(value: unknown, fallback: number, min: number, max: number): number {
|
|
287
336
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
@@ -348,6 +397,17 @@ function normalizeHandoffMode(value: unknown): HandoffMode {
|
|
|
348
397
|
return DEFAULTS.handoffMode;
|
|
349
398
|
}
|
|
350
399
|
|
|
400
|
+
function normalizeSoftSuspectAction(value: unknown): SoftSuspectAction {
|
|
401
|
+
if (typeof value !== "string") {
|
|
402
|
+
return DEFAULTS.softSuspectAction;
|
|
403
|
+
}
|
|
404
|
+
const normalized = value.trim().toLowerCase();
|
|
405
|
+
if (normalized === "none" || normalized === "ask") {
|
|
406
|
+
return normalized;
|
|
407
|
+
}
|
|
408
|
+
return DEFAULTS.softSuspectAction;
|
|
409
|
+
}
|
|
410
|
+
|
|
351
411
|
function compileRegexList(values: unknown, fallback: readonly string[]): RegExp[] {
|
|
352
412
|
const source = Array.isArray(values) ? values : fallback;
|
|
353
413
|
const out: RegExp[] = [];
|
|
@@ -376,6 +436,156 @@ function normalizeStringList(values: unknown, fallback: readonly string[]): stri
|
|
|
376
436
|
.filter(Boolean);
|
|
377
437
|
}
|
|
378
438
|
|
|
439
|
+
function sanitizeTimestamp(value: unknown, fallback: number): number {
|
|
440
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
441
|
+
return fallback;
|
|
442
|
+
}
|
|
443
|
+
return Math.floor(value);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function sanitizeEmbeddingVector(value: unknown): number[] | undefined {
|
|
447
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
const out: number[] = [];
|
|
451
|
+
for (const item of value) {
|
|
452
|
+
if (typeof item !== "number" || !Number.isFinite(item)) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
out.push(item);
|
|
456
|
+
if (out.length >= MAX_PERSISTED_EMBEDDING_DIM) {
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return out.length > 0 ? out : undefined;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function normalizeTokenArray(values: unknown): string[] {
|
|
464
|
+
if (!Array.isArray(values)) {
|
|
465
|
+
return [];
|
|
466
|
+
}
|
|
467
|
+
const out: string[] = [];
|
|
468
|
+
for (const value of values) {
|
|
469
|
+
if (typeof value !== "string") {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const token = value.trim();
|
|
473
|
+
if (!token) {
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
out.push(token);
|
|
477
|
+
if (out.length >= MAX_TOKENS_PER_PERSISTED_ENTRY) {
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function serializeHistoryEntry(entry: HistoryEntry, includeEmbedding: boolean): PersistedHistoryEntry {
|
|
485
|
+
const tokens = [...entry.tokens]
|
|
486
|
+
.map((token) => token.trim())
|
|
487
|
+
.filter(Boolean)
|
|
488
|
+
.slice(0, MAX_TOKENS_PER_PERSISTED_ENTRY);
|
|
489
|
+
const persisted: PersistedHistoryEntry = {
|
|
490
|
+
tokens,
|
|
491
|
+
at: sanitizeTimestamp(entry.at, Date.now()),
|
|
492
|
+
};
|
|
493
|
+
if (includeEmbedding && Array.isArray(entry.embedding) && entry.embedding.length > 0) {
|
|
494
|
+
persisted.embedding = entry.embedding.filter(Number.isFinite).slice(0, MAX_PERSISTED_EMBEDDING_DIM);
|
|
495
|
+
}
|
|
496
|
+
return persisted;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function deserializeHistoryEntry(
|
|
500
|
+
value: unknown,
|
|
501
|
+
includeEmbedding: boolean,
|
|
502
|
+
): HistoryEntry | null {
|
|
503
|
+
if (!value || typeof value !== "object") {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
const record = value as Partial<PersistedHistoryEntry>;
|
|
507
|
+
const tokens = normalizeTokenArray(record.tokens);
|
|
508
|
+
if (tokens.length === 0) {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
const entry: HistoryEntry = {
|
|
512
|
+
tokens: new Set(tokens),
|
|
513
|
+
at: sanitizeTimestamp(record.at, Date.now()),
|
|
514
|
+
};
|
|
515
|
+
if (includeEmbedding) {
|
|
516
|
+
entry.embedding = sanitizeEmbeddingVector(record.embedding);
|
|
517
|
+
}
|
|
518
|
+
return entry;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function serializeSessionState(state: SessionState): PersistedSessionState {
|
|
522
|
+
const history = trimHistory(state.history, 40).map((entry) => serializeHistoryEntry(entry, false));
|
|
523
|
+
const pendingEntries = trimHistory(state.pendingEntries, MAX_PERSISTED_PENDING_ENTRIES).map((entry) =>
|
|
524
|
+
serializeHistoryEntry(entry, true),
|
|
525
|
+
);
|
|
526
|
+
const topicCentroid =
|
|
527
|
+
Array.isArray(state.topicCentroid) && state.topicCentroid.length > 0
|
|
528
|
+
? state.topicCentroid.filter(Number.isFinite).slice(0, MAX_PERSISTED_EMBEDDING_DIM)
|
|
529
|
+
: undefined;
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
history,
|
|
533
|
+
pendingSoftSignals: clampInt(state.pendingSoftSignals, 0, 0, 16),
|
|
534
|
+
pendingEntries,
|
|
535
|
+
lastResetAt:
|
|
536
|
+
typeof state.lastResetAt === "number" && Number.isFinite(state.lastResetAt)
|
|
537
|
+
? Math.floor(state.lastResetAt)
|
|
538
|
+
: undefined,
|
|
539
|
+
topicCentroid,
|
|
540
|
+
topicCount: clampInt(state.topicCount, topicCentroid ? 1 : 0, 0, Number.MAX_SAFE_INTEGER),
|
|
541
|
+
topicDim:
|
|
542
|
+
typeof state.topicDim === "number" && Number.isFinite(state.topicDim) && state.topicDim > 0
|
|
543
|
+
? Math.floor(state.topicDim)
|
|
544
|
+
: topicCentroid?.length,
|
|
545
|
+
lastSeenAt: sanitizeTimestamp(state.lastSeenAt, Date.now()),
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function deserializeSessionState(value: unknown, cfg: ResolvedConfig): SessionState | null {
|
|
550
|
+
if (!value || typeof value !== "object") {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
const record = value as Partial<PersistedSessionState>;
|
|
554
|
+
const historySource = Array.isArray(record.history) ? record.history : [];
|
|
555
|
+
const pendingSource = Array.isArray(record.pendingEntries) ? record.pendingEntries : [];
|
|
556
|
+
|
|
557
|
+
const history = trimHistory(
|
|
558
|
+
historySource
|
|
559
|
+
.map((entry) => deserializeHistoryEntry(entry, false))
|
|
560
|
+
.filter((entry): entry is HistoryEntry => !!entry),
|
|
561
|
+
cfg.historyWindow,
|
|
562
|
+
);
|
|
563
|
+
const pendingEntries = trimHistory(
|
|
564
|
+
pendingSource
|
|
565
|
+
.map((entry) => deserializeHistoryEntry(entry, true))
|
|
566
|
+
.filter((entry): entry is HistoryEntry => !!entry),
|
|
567
|
+
Math.max(MAX_PERSISTED_PENDING_ENTRIES, cfg.softConsecutiveSignals),
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const topicCentroid = sanitizeEmbeddingVector(record.topicCentroid);
|
|
571
|
+
const topicCount = clampInt(record.topicCount, topicCentroid ? 1 : 0, 0, Number.MAX_SAFE_INTEGER);
|
|
572
|
+
const topicDim = clampInt(record.topicDim, topicCentroid?.length ?? 0, 0, MAX_PERSISTED_EMBEDDING_DIM);
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
history,
|
|
576
|
+
pendingSoftSignals: clampInt(record.pendingSoftSignals, 0, 0, 16),
|
|
577
|
+
pendingEntries,
|
|
578
|
+
lastResetAt:
|
|
579
|
+
typeof record.lastResetAt === "number" && Number.isFinite(record.lastResetAt)
|
|
580
|
+
? Math.floor(record.lastResetAt)
|
|
581
|
+
: undefined,
|
|
582
|
+
topicCentroid,
|
|
583
|
+
topicCount,
|
|
584
|
+
topicDim: topicDim > 0 ? topicDim : topicCentroid?.length,
|
|
585
|
+
lastSeenAt: sanitizeTimestamp(record.lastSeenAt, Date.now()),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
379
589
|
function normalizeProviderId(raw: string): string {
|
|
380
590
|
const provider = raw.trim().toLowerCase();
|
|
381
591
|
if (!provider) {
|
|
@@ -427,6 +637,19 @@ function normalizeProviderId(raw: string): string {
|
|
|
427
637
|
return provider;
|
|
428
638
|
}
|
|
429
639
|
|
|
640
|
+
function isInternalNonUserProvider(provider: string): boolean {
|
|
641
|
+
if (!provider) {
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
return (
|
|
645
|
+
provider === "heartbeat" ||
|
|
646
|
+
provider === "exec-event" ||
|
|
647
|
+
provider.startsWith("cron") ||
|
|
648
|
+
provider.includes("heartbeat") ||
|
|
649
|
+
provider.includes("cron")
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
430
653
|
function resolveConfig(raw: unknown): ResolvedConfig {
|
|
431
654
|
const obj = raw && typeof raw === "object" ? (raw as TopicShiftResetConfig) : {};
|
|
432
655
|
const advanced =
|
|
@@ -437,6 +660,10 @@ function resolveConfig(raw: unknown): ResolvedConfig {
|
|
|
437
660
|
obj.embedding && typeof obj.embedding === "object" ? (obj.embedding as EmbeddingConfig) : {};
|
|
438
661
|
const handoff =
|
|
439
662
|
obj.handoff && typeof obj.handoff === "object" ? (obj.handoff as HandoffConfig) : {};
|
|
663
|
+
const softSuspect =
|
|
664
|
+
obj.softSuspect && typeof obj.softSuspect === "object"
|
|
665
|
+
? (obj.softSuspect as SoftSuspectConfig)
|
|
666
|
+
: {};
|
|
440
667
|
const stripRules =
|
|
441
668
|
advanced.stripRules && typeof advanced.stripRules === "object"
|
|
442
669
|
? (advanced.stripRules as StripRulesConfig)
|
|
@@ -561,6 +788,14 @@ function resolveConfig(raw: unknown): ResolvedConfig {
|
|
|
561
788
|
lastN: clampInt(handoff.lastN, DEFAULTS.handoffLastN, 1, 20),
|
|
562
789
|
maxChars: clampInt(handoff.maxChars, DEFAULTS.handoffMaxChars, 60, 800),
|
|
563
790
|
},
|
|
791
|
+
softSuspect: {
|
|
792
|
+
action: normalizeSoftSuspectAction(softSuspect.action),
|
|
793
|
+
prompt:
|
|
794
|
+
typeof softSuspect.prompt === "string" && softSuspect.prompt.trim()
|
|
795
|
+
? softSuspect.prompt.trim()
|
|
796
|
+
: DEFAULTS.softSuspectPrompt,
|
|
797
|
+
ttlMs: clampInt(softSuspect.ttlSeconds, DEFAULTS.softSuspectTtlSeconds, 10, 1_800) * 1000,
|
|
798
|
+
},
|
|
564
799
|
embedding: {
|
|
565
800
|
provider: normalizeEmbeddingProvider(embedding.provider),
|
|
566
801
|
model: (() => {
|
|
@@ -1274,15 +1509,17 @@ async function buildHandoffEventFromPreviousSession(params: {
|
|
|
1274
1509
|
}
|
|
1275
1510
|
}
|
|
1276
1511
|
|
|
1277
|
-
function pruneStateMaps(stateBySession: Map<string, SessionState>):
|
|
1512
|
+
function pruneStateMaps(stateBySession: Map<string, SessionState>): boolean {
|
|
1513
|
+
let changed = false;
|
|
1278
1514
|
const now = Date.now();
|
|
1279
1515
|
for (const [sessionKey, state] of stateBySession) {
|
|
1280
1516
|
if (now - state.lastSeenAt > STALE_SESSION_STATE_MS) {
|
|
1281
1517
|
stateBySession.delete(sessionKey);
|
|
1518
|
+
changed = true;
|
|
1282
1519
|
}
|
|
1283
1520
|
}
|
|
1284
1521
|
if (stateBySession.size <= MAX_TRACKED_SESSIONS) {
|
|
1285
|
-
return;
|
|
1522
|
+
return changed;
|
|
1286
1523
|
}
|
|
1287
1524
|
const ordered = [...stateBySession.entries()].sort((a, b) => a[1].lastSeenAt - b[1].lastSeenAt);
|
|
1288
1525
|
const toDrop = stateBySession.size - MAX_TRACKED_SESSIONS;
|
|
@@ -1290,19 +1527,23 @@ function pruneStateMaps(stateBySession: Map<string, SessionState>): void {
|
|
|
1290
1527
|
const sessionKey = ordered[i]?.[0];
|
|
1291
1528
|
if (sessionKey) {
|
|
1292
1529
|
stateBySession.delete(sessionKey);
|
|
1530
|
+
changed = true;
|
|
1293
1531
|
}
|
|
1294
1532
|
}
|
|
1533
|
+
return changed;
|
|
1295
1534
|
}
|
|
1296
1535
|
|
|
1297
|
-
function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number):
|
|
1536
|
+
function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number): boolean {
|
|
1537
|
+
let changed = false;
|
|
1298
1538
|
const now = Date.now();
|
|
1299
1539
|
for (const [key, ts] of map) {
|
|
1300
1540
|
if (now - ts > ttlMs) {
|
|
1301
1541
|
map.delete(key);
|
|
1542
|
+
changed = true;
|
|
1302
1543
|
}
|
|
1303
1544
|
}
|
|
1304
1545
|
if (map.size <= maxSize) {
|
|
1305
|
-
return;
|
|
1546
|
+
return changed;
|
|
1306
1547
|
}
|
|
1307
1548
|
const ordered = [...map.entries()].sort((a, b) => a[1] - b[1]);
|
|
1308
1549
|
const toDrop = map.size - maxSize;
|
|
@@ -1310,8 +1551,10 @@ function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number
|
|
|
1310
1551
|
const key = ordered[i]?.[0];
|
|
1311
1552
|
if (key) {
|
|
1312
1553
|
map.delete(key);
|
|
1554
|
+
changed = true;
|
|
1313
1555
|
}
|
|
1314
1556
|
}
|
|
1557
|
+
return changed;
|
|
1315
1558
|
}
|
|
1316
1559
|
|
|
1317
1560
|
async function rotateSessionEntry(params: {
|
|
@@ -1319,7 +1562,7 @@ async function rotateSessionEntry(params: {
|
|
|
1319
1562
|
cfg: ResolvedConfig;
|
|
1320
1563
|
sessionKey: string;
|
|
1321
1564
|
agentId?: string;
|
|
1322
|
-
source:
|
|
1565
|
+
source: ClassificationSource;
|
|
1323
1566
|
reason: string;
|
|
1324
1567
|
metrics: ClassifierMetrics;
|
|
1325
1568
|
entry: HistoryEntry;
|
|
@@ -1424,8 +1667,184 @@ async function rotateSessionEntry(params: {
|
|
|
1424
1667
|
export default function register(api: OpenClawPluginApi): void {
|
|
1425
1668
|
const cfg = resolveConfig(api.pluginConfig);
|
|
1426
1669
|
const sessionState = new Map<string, SessionState>();
|
|
1427
|
-
const
|
|
1670
|
+
const recentUserEvents = new Map<string, number>();
|
|
1671
|
+
const recentAgentEvents = new Map<string, number>();
|
|
1428
1672
|
const recentRotationBySession = new Map<string, number>();
|
|
1673
|
+
const pendingSoftSuspectSteeringBySession = new Map<string, number>();
|
|
1674
|
+
const sessionWorkQueue = new Map<string, Promise<unknown>>();
|
|
1675
|
+
|
|
1676
|
+
const persistencePath = (() => {
|
|
1677
|
+
try {
|
|
1678
|
+
const stateDir = api.runtime.state.resolveStateDir();
|
|
1679
|
+
return path.join(stateDir, "plugins", api.id, PERSISTENCE_FILE_NAME);
|
|
1680
|
+
} catch (error) {
|
|
1681
|
+
api.logger.warn(`topic-shift-reset: persistence disabled (state path): ${String(error)}`);
|
|
1682
|
+
return null;
|
|
1683
|
+
}
|
|
1684
|
+
})();
|
|
1685
|
+
|
|
1686
|
+
let persistenceDirty = false;
|
|
1687
|
+
let persistenceTimer: NodeJS.Timeout | null = null;
|
|
1688
|
+
let persistenceFlushPromise: Promise<void> | null = null;
|
|
1689
|
+
let persistenceLoadPromise: Promise<void> = Promise.resolve();
|
|
1690
|
+
|
|
1691
|
+
const clearPersistenceTimer = () => {
|
|
1692
|
+
if (!persistenceTimer) {
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
clearTimeout(persistenceTimer);
|
|
1696
|
+
persistenceTimer = null;
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
const flushPersistentState = async (reason: string): Promise<void> => {
|
|
1700
|
+
if (!persistencePath) {
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
await persistenceLoadPromise;
|
|
1704
|
+
if (!persistenceDirty) {
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
if (persistenceFlushPromise) {
|
|
1708
|
+
await persistenceFlushPromise;
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
persistenceFlushPromise = (async () => {
|
|
1712
|
+
const now = Date.now();
|
|
1713
|
+
pruneStateMaps(sessionState);
|
|
1714
|
+
pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_MESSAGE_EVENTS);
|
|
1715
|
+
|
|
1716
|
+
const payload: PersistedRuntimeState = {
|
|
1717
|
+
version: PERSISTENCE_SCHEMA_VERSION,
|
|
1718
|
+
savedAt: now,
|
|
1719
|
+
sessionStateBySessionKey: {},
|
|
1720
|
+
recentRotationBySession: {},
|
|
1721
|
+
};
|
|
1722
|
+
|
|
1723
|
+
for (const [sessionKey, state] of sessionState) {
|
|
1724
|
+
payload.sessionStateBySessionKey[sessionKey] = serializeSessionState(state);
|
|
1725
|
+
}
|
|
1726
|
+
for (const [rotationKey, ts] of recentRotationBySession) {
|
|
1727
|
+
if (now - ts <= ROTATION_DEDUPE_MS * 3) {
|
|
1728
|
+
payload.recentRotationBySession[rotationKey] = ts;
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
await withFileLock(persistencePath, LOCK_OPTIONS, async () => {
|
|
1733
|
+
await writeJsonFileAtomically(persistencePath, payload);
|
|
1734
|
+
});
|
|
1735
|
+
persistenceDirty = false;
|
|
1736
|
+
if (cfg.debug) {
|
|
1737
|
+
api.logger.debug(
|
|
1738
|
+
`topic-shift-reset: state-flushed reason=${reason} sessions=${sessionState.size} rotations=${recentRotationBySession.size}`,
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
})()
|
|
1742
|
+
.catch((error) => {
|
|
1743
|
+
api.logger.warn(`topic-shift-reset: state flush failed err=${String(error)}`);
|
|
1744
|
+
})
|
|
1745
|
+
.finally(() => {
|
|
1746
|
+
persistenceFlushPromise = null;
|
|
1747
|
+
});
|
|
1748
|
+
await persistenceFlushPromise;
|
|
1749
|
+
};
|
|
1750
|
+
|
|
1751
|
+
const schedulePersistentFlush = (urgent = false) => {
|
|
1752
|
+
if (!persistencePath) {
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
persistenceDirty = true;
|
|
1756
|
+
if (urgent) {
|
|
1757
|
+
void flushPersistentState("urgent");
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
if (persistenceTimer) {
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
persistenceTimer = setTimeout(() => {
|
|
1764
|
+
persistenceTimer = null;
|
|
1765
|
+
void flushPersistentState("scheduled");
|
|
1766
|
+
}, PERSISTENCE_FLUSH_DEBOUNCE_MS);
|
|
1767
|
+
persistenceTimer.unref?.();
|
|
1768
|
+
};
|
|
1769
|
+
|
|
1770
|
+
const runSerializedBySession = async <T>(
|
|
1771
|
+
sessionKey: string,
|
|
1772
|
+
fn: () => Promise<T>,
|
|
1773
|
+
): Promise<T> => {
|
|
1774
|
+
const previous = sessionWorkQueue.get(sessionKey) ?? Promise.resolve();
|
|
1775
|
+
const current = previous.catch(() => undefined).then(fn);
|
|
1776
|
+
const tail = current.then(
|
|
1777
|
+
() => undefined,
|
|
1778
|
+
() => undefined,
|
|
1779
|
+
);
|
|
1780
|
+
sessionWorkQueue.set(sessionKey, tail);
|
|
1781
|
+
try {
|
|
1782
|
+
return await current;
|
|
1783
|
+
} finally {
|
|
1784
|
+
if (sessionWorkQueue.get(sessionKey) === tail) {
|
|
1785
|
+
sessionWorkQueue.delete(sessionKey);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
};
|
|
1789
|
+
|
|
1790
|
+
persistenceLoadPromise = (async () => {
|
|
1791
|
+
if (!persistencePath) {
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
try {
|
|
1795
|
+
const loaded = await withFileLock(persistencePath, LOCK_OPTIONS, async () => {
|
|
1796
|
+
return await readJsonFileWithFallback<PersistedRuntimeState | null>(persistencePath, null);
|
|
1797
|
+
});
|
|
1798
|
+
const value = loaded.value;
|
|
1799
|
+
if (!value || typeof value !== "object") {
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
if (value.version !== PERSISTENCE_SCHEMA_VERSION) {
|
|
1803
|
+
api.logger.warn(
|
|
1804
|
+
`topic-shift-reset: state version mismatch expected=${PERSISTENCE_SCHEMA_VERSION} got=${String(value.version)}; ignoring persisted state`,
|
|
1805
|
+
);
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
const restoredSessionStateByKey =
|
|
1810
|
+
value.sessionStateBySessionKey && typeof value.sessionStateBySessionKey === "object"
|
|
1811
|
+
? value.sessionStateBySessionKey
|
|
1812
|
+
: {};
|
|
1813
|
+
for (const [sessionKey, rawState] of Object.entries(restoredSessionStateByKey)) {
|
|
1814
|
+
const trimmedSessionKey = sessionKey.trim();
|
|
1815
|
+
if (!trimmedSessionKey) {
|
|
1816
|
+
continue;
|
|
1817
|
+
}
|
|
1818
|
+
const restored = deserializeSessionState(rawState, cfg);
|
|
1819
|
+
if (!restored) {
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1822
|
+
sessionState.set(trimmedSessionKey, restored);
|
|
1823
|
+
}
|
|
1824
|
+
pruneStateMaps(sessionState);
|
|
1825
|
+
|
|
1826
|
+
const restoredRotationByKey =
|
|
1827
|
+
value.recentRotationBySession && typeof value.recentRotationBySession === "object"
|
|
1828
|
+
? value.recentRotationBySession
|
|
1829
|
+
: {};
|
|
1830
|
+
for (const [key, ts] of Object.entries(restoredRotationByKey)) {
|
|
1831
|
+
if (!key.trim()) {
|
|
1832
|
+
continue;
|
|
1833
|
+
}
|
|
1834
|
+
if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= 0) {
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
recentRotationBySession.set(key, Math.floor(ts));
|
|
1838
|
+
}
|
|
1839
|
+
pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_MESSAGE_EVENTS);
|
|
1840
|
+
|
|
1841
|
+
api.logger.info(
|
|
1842
|
+
`topic-shift-reset: restored state sessions=${sessionState.size} rotations=${recentRotationBySession.size}`,
|
|
1843
|
+
);
|
|
1844
|
+
} catch (error) {
|
|
1845
|
+
api.logger.warn(`topic-shift-reset: state restore failed err=${String(error)}`);
|
|
1846
|
+
}
|
|
1847
|
+
})();
|
|
1429
1848
|
|
|
1430
1849
|
let embeddingBackend: EmbeddingBackend | null = null;
|
|
1431
1850
|
let embeddingInitError: string | null = null;
|
|
@@ -1443,17 +1862,18 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1443
1862
|
api.logger.info(`topic-shift-reset: embedding backend ${embeddingBackend.name}`);
|
|
1444
1863
|
}
|
|
1445
1864
|
|
|
1446
|
-
const
|
|
1447
|
-
source:
|
|
1865
|
+
const classifyAndMaybeRotateInner = async (params: {
|
|
1866
|
+
source: ClassificationSource;
|
|
1448
1867
|
sessionKey: string;
|
|
1449
1868
|
text: string;
|
|
1450
1869
|
messageProvider?: string;
|
|
1451
1870
|
agentId?: string;
|
|
1452
1871
|
}) => {
|
|
1872
|
+
await persistenceLoadPromise;
|
|
1453
1873
|
if (!cfg.enabled) {
|
|
1454
1874
|
return;
|
|
1455
1875
|
}
|
|
1456
|
-
const sessionKey = params.sessionKey
|
|
1876
|
+
const sessionKey = params.sessionKey;
|
|
1457
1877
|
if (!sessionKey) {
|
|
1458
1878
|
return;
|
|
1459
1879
|
}
|
|
@@ -1462,6 +1882,14 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1462
1882
|
if (provider && cfg.ignoredProviders.has(provider)) {
|
|
1463
1883
|
return;
|
|
1464
1884
|
}
|
|
1885
|
+
if (isInternalNonUserProvider(provider)) {
|
|
1886
|
+
if (cfg.debug) {
|
|
1887
|
+
api.logger.debug(
|
|
1888
|
+
`topic-shift-reset: skip-internal-provider source=${params.source} provider=${provider} session=${sessionKey}`,
|
|
1889
|
+
);
|
|
1890
|
+
}
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1465
1893
|
|
|
1466
1894
|
const rawText = params.text.trim();
|
|
1467
1895
|
const text = cfg.stripEnvelope ? stripClassifierEnvelope(rawText, cfg.stripRules) : rawText;
|
|
@@ -1472,7 +1900,7 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1472
1900
|
const tokenList = tokenizeList(text, cfg.minTokenLength);
|
|
1473
1901
|
if (text.length < cfg.minSignalChars || tokenList.length < cfg.minSignalTokenCount) {
|
|
1474
1902
|
if (cfg.debug) {
|
|
1475
|
-
api.logger.
|
|
1903
|
+
api.logger.debug(
|
|
1476
1904
|
[
|
|
1477
1905
|
`topic-shift-reset: skip-low-signal`,
|
|
1478
1906
|
`source=${params.source}`,
|
|
@@ -1487,13 +1915,12 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1487
1915
|
|
|
1488
1916
|
const tokens = new Set(tokenList);
|
|
1489
1917
|
|
|
1918
|
+
const now = Date.now();
|
|
1490
1919
|
const contentHash = hashString(normalizeTextForHash(text));
|
|
1491
1920
|
const lastRotationAt = recentRotationBySession.get(`${sessionKey}:${contentHash}`);
|
|
1492
|
-
if (typeof lastRotationAt === "number" &&
|
|
1921
|
+
if (typeof lastRotationAt === "number" && now - lastRotationAt < ROTATION_DEDUPE_MS) {
|
|
1493
1922
|
return;
|
|
1494
1923
|
}
|
|
1495
|
-
|
|
1496
|
-
const now = Date.now();
|
|
1497
1924
|
const state =
|
|
1498
1925
|
sessionState.get(sessionKey) ??
|
|
1499
1926
|
({
|
|
@@ -1558,7 +1985,7 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1558
1985
|
});
|
|
1559
1986
|
|
|
1560
1987
|
if (cfg.debug) {
|
|
1561
|
-
api.logger.
|
|
1988
|
+
api.logger.debug(
|
|
1562
1989
|
[
|
|
1563
1990
|
`topic-shift-reset: classify`,
|
|
1564
1991
|
`source=${params.source}`,
|
|
@@ -1578,16 +2005,19 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1578
2005
|
}
|
|
1579
2006
|
|
|
1580
2007
|
if (decision.kind === "warmup") {
|
|
2008
|
+
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
1581
2009
|
state.pendingSoftSignals = 0;
|
|
1582
2010
|
state.pendingEntries = [];
|
|
1583
2011
|
state.history = trimHistory([...state.history, entry], cfg.historyWindow);
|
|
1584
2012
|
updateTopicCentroid(state, entry.embedding);
|
|
1585
2013
|
sessionState.set(sessionKey, state);
|
|
1586
2014
|
pruneStateMaps(sessionState);
|
|
2015
|
+
schedulePersistentFlush(false);
|
|
1587
2016
|
return;
|
|
1588
2017
|
}
|
|
1589
2018
|
|
|
1590
2019
|
if (decision.kind === "stable") {
|
|
2020
|
+
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
1591
2021
|
const merged = [...state.history, ...state.pendingEntries, entry];
|
|
1592
2022
|
for (const item of [...state.pendingEntries, entry]) {
|
|
1593
2023
|
updateTopicCentroid(state, item.embedding);
|
|
@@ -1597,14 +2027,24 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1597
2027
|
state.history = trimHistory(merged, cfg.historyWindow);
|
|
1598
2028
|
sessionState.set(sessionKey, state);
|
|
1599
2029
|
pruneStateMaps(sessionState);
|
|
2030
|
+
schedulePersistentFlush(false);
|
|
1600
2031
|
return;
|
|
1601
2032
|
}
|
|
1602
2033
|
|
|
1603
2034
|
if (decision.kind === "suspect") {
|
|
2035
|
+
if (cfg.softSuspect.action === "ask") {
|
|
2036
|
+
pendingSoftSuspectSteeringBySession.set(sessionKey, now);
|
|
2037
|
+
pruneRecentMap(
|
|
2038
|
+
pendingSoftSuspectSteeringBySession,
|
|
2039
|
+
Math.max(cfg.softSuspect.ttlMs * 2, 60_000),
|
|
2040
|
+
MAX_RECENT_MESSAGE_EVENTS,
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
1604
2043
|
state.pendingSoftSignals += 1;
|
|
1605
2044
|
state.pendingEntries = trimHistory([...state.pendingEntries, entry], cfg.softConsecutiveSignals);
|
|
1606
2045
|
sessionState.set(sessionKey, state);
|
|
1607
2046
|
pruneStateMaps(sessionState);
|
|
2047
|
+
schedulePersistentFlush(false);
|
|
1608
2048
|
return;
|
|
1609
2049
|
}
|
|
1610
2050
|
|
|
@@ -1625,14 +2065,42 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1625
2065
|
});
|
|
1626
2066
|
|
|
1627
2067
|
if (rotated) {
|
|
2068
|
+
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
1628
2069
|
if (!cfg.dryRun) {
|
|
1629
2070
|
recentRotationBySession.set(`${sessionKey}:${contentHash}`, Date.now());
|
|
1630
2071
|
}
|
|
2072
|
+
schedulePersistentFlush(true);
|
|
1631
2073
|
}
|
|
1632
2074
|
|
|
1633
2075
|
sessionState.set(sessionKey, state);
|
|
1634
2076
|
pruneStateMaps(sessionState);
|
|
1635
|
-
|
|
2077
|
+
const prunedRotations = pruneRecentMap(
|
|
2078
|
+
recentRotationBySession,
|
|
2079
|
+
ROTATION_DEDUPE_MS * 3,
|
|
2080
|
+
MAX_RECENT_MESSAGE_EVENTS,
|
|
2081
|
+
);
|
|
2082
|
+
if (prunedRotations) {
|
|
2083
|
+
schedulePersistentFlush(false);
|
|
2084
|
+
}
|
|
2085
|
+
};
|
|
2086
|
+
|
|
2087
|
+
const classifyAndMaybeRotate = async (params: {
|
|
2088
|
+
source: ClassificationSource;
|
|
2089
|
+
sessionKey: string;
|
|
2090
|
+
text: string;
|
|
2091
|
+
messageProvider?: string;
|
|
2092
|
+
agentId?: string;
|
|
2093
|
+
}) => {
|
|
2094
|
+
const sessionKey = params.sessionKey.trim();
|
|
2095
|
+
if (!sessionKey) {
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
await runSerializedBySession(sessionKey, async () => {
|
|
2099
|
+
await classifyAndMaybeRotateInner({
|
|
2100
|
+
...params,
|
|
2101
|
+
sessionKey,
|
|
2102
|
+
});
|
|
2103
|
+
});
|
|
1636
2104
|
};
|
|
1637
2105
|
|
|
1638
2106
|
api.on("message_received", async (event, ctx) => {
|
|
@@ -1650,21 +2118,22 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1650
2118
|
return;
|
|
1651
2119
|
}
|
|
1652
2120
|
|
|
1653
|
-
const
|
|
2121
|
+
const userEventKey = [
|
|
1654
2122
|
channelId,
|
|
1655
2123
|
ctx.accountId ?? "",
|
|
1656
2124
|
peer.kind,
|
|
1657
2125
|
peer.id,
|
|
1658
2126
|
hashString(normalizeTextForHash(text)),
|
|
1659
2127
|
].join("|");
|
|
1660
|
-
const seenAt =
|
|
1661
|
-
if (typeof seenAt === "number" && Date.now() - seenAt <
|
|
2128
|
+
const seenAt = recentUserEvents.get(userEventKey);
|
|
2129
|
+
if (typeof seenAt === "number" && Date.now() - seenAt < MESSAGE_EVENT_TTL_MS) {
|
|
1662
2130
|
return;
|
|
1663
2131
|
}
|
|
1664
|
-
|
|
1665
|
-
pruneRecentMap(
|
|
2132
|
+
recentUserEvents.set(userEventKey, Date.now());
|
|
2133
|
+
pruneRecentMap(recentUserEvents, MESSAGE_EVENT_TTL_MS, MAX_RECENT_MESSAGE_EVENTS);
|
|
1666
2134
|
|
|
1667
2135
|
let resolvedSessionKey = "";
|
|
2136
|
+
let resolvedAgentId: string | undefined;
|
|
1668
2137
|
try {
|
|
1669
2138
|
const route = api.runtime.channel.routing.resolveAgentRoute({
|
|
1670
2139
|
cfg: api.config,
|
|
@@ -1673,38 +2142,107 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1673
2142
|
peer,
|
|
1674
2143
|
});
|
|
1675
2144
|
resolvedSessionKey = route.sessionKey;
|
|
2145
|
+
resolvedAgentId = route.agentId;
|
|
1676
2146
|
} catch (error) {
|
|
1677
2147
|
if (cfg.debug) {
|
|
1678
|
-
api.logger.
|
|
1679
|
-
`topic-shift-reset:
|
|
2148
|
+
api.logger.debug(
|
|
2149
|
+
`topic-shift-reset: user-route-skip channel=${channelId} peer=${maybeJson(peer)} err=${String(error)}`,
|
|
1680
2150
|
);
|
|
1681
2151
|
}
|
|
1682
2152
|
return;
|
|
1683
2153
|
}
|
|
1684
2154
|
|
|
1685
2155
|
await classifyAndMaybeRotate({
|
|
1686
|
-
source: "
|
|
2156
|
+
source: "user",
|
|
1687
2157
|
sessionKey: resolvedSessionKey,
|
|
1688
2158
|
text,
|
|
1689
2159
|
messageProvider: channelId,
|
|
2160
|
+
agentId: resolvedAgentId,
|
|
1690
2161
|
});
|
|
1691
2162
|
});
|
|
1692
2163
|
|
|
1693
|
-
api.on("
|
|
2164
|
+
api.on("message_sent", async (event, ctx) => {
|
|
1694
2165
|
if (!cfg.enabled) {
|
|
1695
2166
|
return;
|
|
1696
2167
|
}
|
|
1697
|
-
|
|
1698
|
-
|
|
2168
|
+
if (!event.success) {
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
const channelId = ctx.channelId?.trim();
|
|
2172
|
+
if (!channelId) {
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
const text = event.content?.trim() ?? "";
|
|
2176
|
+
if (!text) {
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
const peer = inferFastPeer({ from: event.to }, { conversationId: ctx.conversationId });
|
|
2181
|
+
const agentEventKey = [
|
|
2182
|
+
channelId,
|
|
2183
|
+
ctx.accountId ?? "",
|
|
2184
|
+
peer.kind,
|
|
2185
|
+
peer.id,
|
|
2186
|
+
hashString(normalizeTextForHash(text)),
|
|
2187
|
+
].join("|");
|
|
2188
|
+
const seenAt = recentAgentEvents.get(agentEventKey);
|
|
2189
|
+
if (typeof seenAt === "number" && Date.now() - seenAt < MESSAGE_EVENT_TTL_MS) {
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
recentAgentEvents.set(agentEventKey, Date.now());
|
|
2193
|
+
pruneRecentMap(recentAgentEvents, MESSAGE_EVENT_TTL_MS, MAX_RECENT_MESSAGE_EVENTS);
|
|
2194
|
+
|
|
2195
|
+
let resolvedSessionKey = "";
|
|
2196
|
+
let resolvedAgentId: string | undefined;
|
|
2197
|
+
try {
|
|
2198
|
+
const route = api.runtime.channel.routing.resolveAgentRoute({
|
|
2199
|
+
cfg: api.config,
|
|
2200
|
+
channel: channelId,
|
|
2201
|
+
accountId: ctx.accountId,
|
|
2202
|
+
peer,
|
|
2203
|
+
});
|
|
2204
|
+
resolvedSessionKey = route.sessionKey;
|
|
2205
|
+
resolvedAgentId = route.agentId;
|
|
2206
|
+
} catch (error) {
|
|
2207
|
+
if (cfg.debug) {
|
|
2208
|
+
api.logger.debug(
|
|
2209
|
+
`topic-shift-reset: agent-route-skip channel=${channelId} peer=${maybeJson(peer)} err=${String(error)}`,
|
|
2210
|
+
);
|
|
2211
|
+
}
|
|
1699
2212
|
return;
|
|
1700
2213
|
}
|
|
1701
2214
|
|
|
1702
2215
|
await classifyAndMaybeRotate({
|
|
1703
|
-
source: "
|
|
1704
|
-
sessionKey,
|
|
1705
|
-
text
|
|
1706
|
-
messageProvider:
|
|
1707
|
-
agentId:
|
|
2216
|
+
source: "agent",
|
|
2217
|
+
sessionKey: resolvedSessionKey,
|
|
2218
|
+
text,
|
|
2219
|
+
messageProvider: channelId,
|
|
2220
|
+
agentId: resolvedAgentId,
|
|
1708
2221
|
});
|
|
1709
2222
|
});
|
|
2223
|
+
|
|
2224
|
+
api.on("before_prompt_build", async (_event, ctx) => {
|
|
2225
|
+
if (!cfg.enabled || cfg.softSuspect.action !== "ask") {
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
const sessionKey = ctx.sessionKey?.trim();
|
|
2229
|
+
if (!sessionKey) {
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
const seenAt = pendingSoftSuspectSteeringBySession.get(sessionKey);
|
|
2233
|
+
if (typeof seenAt !== "number") {
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
if (Date.now() - seenAt > cfg.softSuspect.ttlMs) {
|
|
2237
|
+
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
2241
|
+
return { prependContext: cfg.softSuspect.prompt };
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2244
|
+
api.on("gateway_stop", async () => {
|
|
2245
|
+
clearPersistenceTimer();
|
|
2246
|
+
await flushPersistentState("gateway-stop");
|
|
2247
|
+
});
|
|
1710
2248
|
}
|