openclaw-topic-shift-reset 0.2.0 → 0.3.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 +23 -0
- package/docs/configuration.md +18 -0
- package/openclaw.plugin.json +25 -0
- package/package.json +7 -1
- package/src/index.ts +562 -14
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ This plugin tries to prevent that by detecting topic shifts and rotating to a ne
|
|
|
11
11
|
- fewer prompt tokens per turn after a shift
|
|
12
12
|
- less stale context bleeding into new questions
|
|
13
13
|
- lower chance of overflow/compaction churn on long chats
|
|
14
|
+
- classifier state persisted across gateway restarts (no cold start after reboot)
|
|
14
15
|
|
|
15
16
|
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
17
|
|
|
@@ -42,6 +43,10 @@ Add this plugin entry in `~/.openclaw/openclaw.json` (or merge into your existin
|
|
|
42
43
|
"lastN": 6,
|
|
43
44
|
"maxChars": 220
|
|
44
45
|
},
|
|
46
|
+
"softSuspect": {
|
|
47
|
+
"action": "ask",
|
|
48
|
+
"ttlSeconds": 120
|
|
49
|
+
},
|
|
45
50
|
"dryRun": false,
|
|
46
51
|
"debug": false
|
|
47
52
|
}
|
|
@@ -101,6 +106,24 @@ Provider options:
|
|
|
101
106
|
- `ollama`
|
|
102
107
|
- `none` (lexical only)
|
|
103
108
|
|
|
109
|
+
## Soft suspect clarification
|
|
110
|
+
|
|
111
|
+
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.
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"softSuspect": {
|
|
116
|
+
"action": "ask",
|
|
117
|
+
"prompt": "Potential topic shift detected. Ask one short clarification question to confirm the user's new goal before proceeding.",
|
|
118
|
+
"ttlSeconds": 120
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- `action`: `ask` (default) or `none`.
|
|
124
|
+
- `prompt`: optional custom steer text.
|
|
125
|
+
- `ttlSeconds`: max age before a pending steer expires.
|
|
126
|
+
|
|
104
127
|
## Logs
|
|
105
128
|
|
|
106
129
|
```bash
|
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
|
}
|
|
@@ -34,6 +38,9 @@ This plugin now accepts one canonical key per concept:
|
|
|
34
38
|
- `handoff.mode`: `none | summary | verbatim_last_n`.
|
|
35
39
|
- `handoff.lastN`: number of transcript messages to include in handoff.
|
|
36
40
|
- `handoff.maxChars`: per-message truncation cap in handoff text.
|
|
41
|
+
- `softSuspect.action`: `ask | none`.
|
|
42
|
+
- `softSuspect.prompt`: optional steer text injected on soft-suspect.
|
|
43
|
+
- `softSuspect.ttlSeconds`: expiry for pending soft-suspect steer.
|
|
37
44
|
- `dryRun`: logs would-rotate events without session resets.
|
|
38
45
|
- `debug`: emits per-message classifier diagnostics.
|
|
39
46
|
|
|
@@ -61,6 +68,8 @@ This plugin now accepts one canonical key per concept:
|
|
|
61
68
|
- `handoff.mode`: `summary`
|
|
62
69
|
- `handoff.lastN`: `6`
|
|
63
70
|
- `handoff.maxChars`: `220`
|
|
71
|
+
- `softSuspect.action`: `ask`
|
|
72
|
+
- `softSuspect.ttlSeconds`: `120`
|
|
64
73
|
- `advanced.minSignalChars`: `20`
|
|
65
74
|
- `advanced.minSignalTokenCount`: `3`
|
|
66
75
|
- `advanced.minSignalEntropy`: `1.2`
|
|
@@ -131,6 +140,14 @@ Legacy alias keys are not supported in this release. Config validation fails if
|
|
|
131
140
|
- `advanced.embedding`, `advanced.embeddings`, `advanced.handoff*`
|
|
132
141
|
- previous top-level tuning keys
|
|
133
142
|
|
|
143
|
+
## Runtime persistence
|
|
144
|
+
|
|
145
|
+
Classifier runtime state is persisted automatically under the OpenClaw state directory (`plugins/<plugin-id>/runtime-state.v1.json`).
|
|
146
|
+
|
|
147
|
+
- Persisted: per-session topic history, pending soft-signal windows, topic centroid, rotation dedupe map.
|
|
148
|
+
- Not persisted: transient fast-event dedupe cache.
|
|
149
|
+
- No extra config is required.
|
|
150
|
+
|
|
134
151
|
## Log interpretation
|
|
135
152
|
|
|
136
153
|
Classifier logs look like:
|
|
@@ -150,3 +167,4 @@ Other lines:
|
|
|
150
167
|
- `skip-low-signal`: message skipped by hard signal floor (`minSignalChars`/`minSignalTokenCount`).
|
|
151
168
|
- `would-rotate`: `dryRun=true` synthetic rotate event (no reset mutation).
|
|
152
169
|
- `rotated`: actual session rotation happened.
|
|
170
|
+
- `classify` / `skip-low-signal` / `skip-cross-path-duplicate`: 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.3.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;
|
|
@@ -245,6 +282,10 @@ const DEFAULTS = {
|
|
|
245
282
|
handoffMode: "summary" as HandoffMode,
|
|
246
283
|
handoffLastN: 6,
|
|
247
284
|
handoffMaxChars: 220,
|
|
285
|
+
softSuspectAction: "ask" as SoftSuspectAction,
|
|
286
|
+
softSuspectPrompt:
|
|
287
|
+
"Potential topic shift detected. Ask one short clarification question to confirm the user's new goal before proceeding.",
|
|
288
|
+
softSuspectTtlSeconds: 120,
|
|
248
289
|
embeddingProvider: "auto" as EmbeddingProvider,
|
|
249
290
|
embeddingTimeoutMs: 7000,
|
|
250
291
|
minSignalChars: 20,
|
|
@@ -274,7 +315,7 @@ const LOCK_OPTIONS = {
|
|
|
274
315
|
maxTimeout: 250,
|
|
275
316
|
randomize: true,
|
|
276
317
|
},
|
|
277
|
-
stale:
|
|
318
|
+
stale: 30 * 60 * 1000,
|
|
278
319
|
} as const;
|
|
279
320
|
|
|
280
321
|
const MAX_TRACKED_SESSIONS = 10_000;
|
|
@@ -282,6 +323,13 @@ const STALE_SESSION_STATE_MS = 4 * 60 * 60 * 1000;
|
|
|
282
323
|
const MAX_RECENT_FAST_EVENTS = 20_000;
|
|
283
324
|
const FAST_EVENT_TTL_MS = 5 * 60 * 1000;
|
|
284
325
|
const ROTATION_DEDUPE_MS = 25_000;
|
|
326
|
+
const CROSS_PATH_DEDUPE_MS = 15_000;
|
|
327
|
+
const PERSISTENCE_SCHEMA_VERSION = 1;
|
|
328
|
+
const PERSISTENCE_FILE_NAME = "runtime-state.v1.json";
|
|
329
|
+
const PERSISTENCE_FLUSH_DEBOUNCE_MS = 1_200;
|
|
330
|
+
const MAX_TOKENS_PER_PERSISTED_ENTRY = 256;
|
|
331
|
+
const MAX_PERSISTED_EMBEDDING_DIM = 8_192;
|
|
332
|
+
const MAX_PERSISTED_PENDING_ENTRIES = 8;
|
|
285
333
|
|
|
286
334
|
function clampInt(value: unknown, fallback: number, min: number, max: number): number {
|
|
287
335
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
@@ -348,6 +396,17 @@ function normalizeHandoffMode(value: unknown): HandoffMode {
|
|
|
348
396
|
return DEFAULTS.handoffMode;
|
|
349
397
|
}
|
|
350
398
|
|
|
399
|
+
function normalizeSoftSuspectAction(value: unknown): SoftSuspectAction {
|
|
400
|
+
if (typeof value !== "string") {
|
|
401
|
+
return DEFAULTS.softSuspectAction;
|
|
402
|
+
}
|
|
403
|
+
const normalized = value.trim().toLowerCase();
|
|
404
|
+
if (normalized === "none" || normalized === "ask") {
|
|
405
|
+
return normalized;
|
|
406
|
+
}
|
|
407
|
+
return DEFAULTS.softSuspectAction;
|
|
408
|
+
}
|
|
409
|
+
|
|
351
410
|
function compileRegexList(values: unknown, fallback: readonly string[]): RegExp[] {
|
|
352
411
|
const source = Array.isArray(values) ? values : fallback;
|
|
353
412
|
const out: RegExp[] = [];
|
|
@@ -376,6 +435,156 @@ function normalizeStringList(values: unknown, fallback: readonly string[]): stri
|
|
|
376
435
|
.filter(Boolean);
|
|
377
436
|
}
|
|
378
437
|
|
|
438
|
+
function sanitizeTimestamp(value: unknown, fallback: number): number {
|
|
439
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
440
|
+
return fallback;
|
|
441
|
+
}
|
|
442
|
+
return Math.floor(value);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function sanitizeEmbeddingVector(value: unknown): number[] | undefined {
|
|
446
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
const out: number[] = [];
|
|
450
|
+
for (const item of value) {
|
|
451
|
+
if (typeof item !== "number" || !Number.isFinite(item)) {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
out.push(item);
|
|
455
|
+
if (out.length >= MAX_PERSISTED_EMBEDDING_DIM) {
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return out.length > 0 ? out : undefined;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function normalizeTokenArray(values: unknown): string[] {
|
|
463
|
+
if (!Array.isArray(values)) {
|
|
464
|
+
return [];
|
|
465
|
+
}
|
|
466
|
+
const out: string[] = [];
|
|
467
|
+
for (const value of values) {
|
|
468
|
+
if (typeof value !== "string") {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const token = value.trim();
|
|
472
|
+
if (!token) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
out.push(token);
|
|
476
|
+
if (out.length >= MAX_TOKENS_PER_PERSISTED_ENTRY) {
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return out;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function serializeHistoryEntry(entry: HistoryEntry, includeEmbedding: boolean): PersistedHistoryEntry {
|
|
484
|
+
const tokens = [...entry.tokens]
|
|
485
|
+
.map((token) => token.trim())
|
|
486
|
+
.filter(Boolean)
|
|
487
|
+
.slice(0, MAX_TOKENS_PER_PERSISTED_ENTRY);
|
|
488
|
+
const persisted: PersistedHistoryEntry = {
|
|
489
|
+
tokens,
|
|
490
|
+
at: sanitizeTimestamp(entry.at, Date.now()),
|
|
491
|
+
};
|
|
492
|
+
if (includeEmbedding && Array.isArray(entry.embedding) && entry.embedding.length > 0) {
|
|
493
|
+
persisted.embedding = entry.embedding.filter(Number.isFinite).slice(0, MAX_PERSISTED_EMBEDDING_DIM);
|
|
494
|
+
}
|
|
495
|
+
return persisted;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function deserializeHistoryEntry(
|
|
499
|
+
value: unknown,
|
|
500
|
+
includeEmbedding: boolean,
|
|
501
|
+
): HistoryEntry | null {
|
|
502
|
+
if (!value || typeof value !== "object") {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
const record = value as Partial<PersistedHistoryEntry>;
|
|
506
|
+
const tokens = normalizeTokenArray(record.tokens);
|
|
507
|
+
if (tokens.length === 0) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
const entry: HistoryEntry = {
|
|
511
|
+
tokens: new Set(tokens),
|
|
512
|
+
at: sanitizeTimestamp(record.at, Date.now()),
|
|
513
|
+
};
|
|
514
|
+
if (includeEmbedding) {
|
|
515
|
+
entry.embedding = sanitizeEmbeddingVector(record.embedding);
|
|
516
|
+
}
|
|
517
|
+
return entry;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function serializeSessionState(state: SessionState): PersistedSessionState {
|
|
521
|
+
const history = trimHistory(state.history, 40).map((entry) => serializeHistoryEntry(entry, false));
|
|
522
|
+
const pendingEntries = trimHistory(state.pendingEntries, MAX_PERSISTED_PENDING_ENTRIES).map((entry) =>
|
|
523
|
+
serializeHistoryEntry(entry, true),
|
|
524
|
+
);
|
|
525
|
+
const topicCentroid =
|
|
526
|
+
Array.isArray(state.topicCentroid) && state.topicCentroid.length > 0
|
|
527
|
+
? state.topicCentroid.filter(Number.isFinite).slice(0, MAX_PERSISTED_EMBEDDING_DIM)
|
|
528
|
+
: undefined;
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
history,
|
|
532
|
+
pendingSoftSignals: clampInt(state.pendingSoftSignals, 0, 0, 16),
|
|
533
|
+
pendingEntries,
|
|
534
|
+
lastResetAt:
|
|
535
|
+
typeof state.lastResetAt === "number" && Number.isFinite(state.lastResetAt)
|
|
536
|
+
? Math.floor(state.lastResetAt)
|
|
537
|
+
: undefined,
|
|
538
|
+
topicCentroid,
|
|
539
|
+
topicCount: clampInt(state.topicCount, topicCentroid ? 1 : 0, 0, Number.MAX_SAFE_INTEGER),
|
|
540
|
+
topicDim:
|
|
541
|
+
typeof state.topicDim === "number" && Number.isFinite(state.topicDim) && state.topicDim > 0
|
|
542
|
+
? Math.floor(state.topicDim)
|
|
543
|
+
: topicCentroid?.length,
|
|
544
|
+
lastSeenAt: sanitizeTimestamp(state.lastSeenAt, Date.now()),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function deserializeSessionState(value: unknown, cfg: ResolvedConfig): SessionState | null {
|
|
549
|
+
if (!value || typeof value !== "object") {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
const record = value as Partial<PersistedSessionState>;
|
|
553
|
+
const historySource = Array.isArray(record.history) ? record.history : [];
|
|
554
|
+
const pendingSource = Array.isArray(record.pendingEntries) ? record.pendingEntries : [];
|
|
555
|
+
|
|
556
|
+
const history = trimHistory(
|
|
557
|
+
historySource
|
|
558
|
+
.map((entry) => deserializeHistoryEntry(entry, false))
|
|
559
|
+
.filter((entry): entry is HistoryEntry => !!entry),
|
|
560
|
+
cfg.historyWindow,
|
|
561
|
+
);
|
|
562
|
+
const pendingEntries = trimHistory(
|
|
563
|
+
pendingSource
|
|
564
|
+
.map((entry) => deserializeHistoryEntry(entry, true))
|
|
565
|
+
.filter((entry): entry is HistoryEntry => !!entry),
|
|
566
|
+
Math.max(MAX_PERSISTED_PENDING_ENTRIES, cfg.softConsecutiveSignals),
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const topicCentroid = sanitizeEmbeddingVector(record.topicCentroid);
|
|
570
|
+
const topicCount = clampInt(record.topicCount, topicCentroid ? 1 : 0, 0, Number.MAX_SAFE_INTEGER);
|
|
571
|
+
const topicDim = clampInt(record.topicDim, topicCentroid?.length ?? 0, 0, MAX_PERSISTED_EMBEDDING_DIM);
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
history,
|
|
575
|
+
pendingSoftSignals: clampInt(record.pendingSoftSignals, 0, 0, 16),
|
|
576
|
+
pendingEntries,
|
|
577
|
+
lastResetAt:
|
|
578
|
+
typeof record.lastResetAt === "number" && Number.isFinite(record.lastResetAt)
|
|
579
|
+
? Math.floor(record.lastResetAt)
|
|
580
|
+
: undefined,
|
|
581
|
+
topicCentroid,
|
|
582
|
+
topicCount,
|
|
583
|
+
topicDim: topicDim > 0 ? topicDim : topicCentroid?.length,
|
|
584
|
+
lastSeenAt: sanitizeTimestamp(record.lastSeenAt, Date.now()),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
379
588
|
function normalizeProviderId(raw: string): string {
|
|
380
589
|
const provider = raw.trim().toLowerCase();
|
|
381
590
|
if (!provider) {
|
|
@@ -427,6 +636,19 @@ function normalizeProviderId(raw: string): string {
|
|
|
427
636
|
return provider;
|
|
428
637
|
}
|
|
429
638
|
|
|
639
|
+
function isInternalNonUserProvider(provider: string): boolean {
|
|
640
|
+
if (!provider) {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
return (
|
|
644
|
+
provider === "heartbeat" ||
|
|
645
|
+
provider === "exec-event" ||
|
|
646
|
+
provider.startsWith("cron") ||
|
|
647
|
+
provider.includes("heartbeat") ||
|
|
648
|
+
provider.includes("cron")
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
430
652
|
function resolveConfig(raw: unknown): ResolvedConfig {
|
|
431
653
|
const obj = raw && typeof raw === "object" ? (raw as TopicShiftResetConfig) : {};
|
|
432
654
|
const advanced =
|
|
@@ -437,6 +659,10 @@ function resolveConfig(raw: unknown): ResolvedConfig {
|
|
|
437
659
|
obj.embedding && typeof obj.embedding === "object" ? (obj.embedding as EmbeddingConfig) : {};
|
|
438
660
|
const handoff =
|
|
439
661
|
obj.handoff && typeof obj.handoff === "object" ? (obj.handoff as HandoffConfig) : {};
|
|
662
|
+
const softSuspect =
|
|
663
|
+
obj.softSuspect && typeof obj.softSuspect === "object"
|
|
664
|
+
? (obj.softSuspect as SoftSuspectConfig)
|
|
665
|
+
: {};
|
|
440
666
|
const stripRules =
|
|
441
667
|
advanced.stripRules && typeof advanced.stripRules === "object"
|
|
442
668
|
? (advanced.stripRules as StripRulesConfig)
|
|
@@ -561,6 +787,14 @@ function resolveConfig(raw: unknown): ResolvedConfig {
|
|
|
561
787
|
lastN: clampInt(handoff.lastN, DEFAULTS.handoffLastN, 1, 20),
|
|
562
788
|
maxChars: clampInt(handoff.maxChars, DEFAULTS.handoffMaxChars, 60, 800),
|
|
563
789
|
},
|
|
790
|
+
softSuspect: {
|
|
791
|
+
action: normalizeSoftSuspectAction(softSuspect.action),
|
|
792
|
+
prompt:
|
|
793
|
+
typeof softSuspect.prompt === "string" && softSuspect.prompt.trim()
|
|
794
|
+
? softSuspect.prompt.trim()
|
|
795
|
+
: DEFAULTS.softSuspectPrompt,
|
|
796
|
+
ttlMs: clampInt(softSuspect.ttlSeconds, DEFAULTS.softSuspectTtlSeconds, 10, 1_800) * 1000,
|
|
797
|
+
},
|
|
564
798
|
embedding: {
|
|
565
799
|
provider: normalizeEmbeddingProvider(embedding.provider),
|
|
566
800
|
model: (() => {
|
|
@@ -1274,15 +1508,17 @@ async function buildHandoffEventFromPreviousSession(params: {
|
|
|
1274
1508
|
}
|
|
1275
1509
|
}
|
|
1276
1510
|
|
|
1277
|
-
function pruneStateMaps(stateBySession: Map<string, SessionState>):
|
|
1511
|
+
function pruneStateMaps(stateBySession: Map<string, SessionState>): boolean {
|
|
1512
|
+
let changed = false;
|
|
1278
1513
|
const now = Date.now();
|
|
1279
1514
|
for (const [sessionKey, state] of stateBySession) {
|
|
1280
1515
|
if (now - state.lastSeenAt > STALE_SESSION_STATE_MS) {
|
|
1281
1516
|
stateBySession.delete(sessionKey);
|
|
1517
|
+
changed = true;
|
|
1282
1518
|
}
|
|
1283
1519
|
}
|
|
1284
1520
|
if (stateBySession.size <= MAX_TRACKED_SESSIONS) {
|
|
1285
|
-
return;
|
|
1521
|
+
return changed;
|
|
1286
1522
|
}
|
|
1287
1523
|
const ordered = [...stateBySession.entries()].sort((a, b) => a[1].lastSeenAt - b[1].lastSeenAt);
|
|
1288
1524
|
const toDrop = stateBySession.size - MAX_TRACKED_SESSIONS;
|
|
@@ -1290,19 +1526,23 @@ function pruneStateMaps(stateBySession: Map<string, SessionState>): void {
|
|
|
1290
1526
|
const sessionKey = ordered[i]?.[0];
|
|
1291
1527
|
if (sessionKey) {
|
|
1292
1528
|
stateBySession.delete(sessionKey);
|
|
1529
|
+
changed = true;
|
|
1293
1530
|
}
|
|
1294
1531
|
}
|
|
1532
|
+
return changed;
|
|
1295
1533
|
}
|
|
1296
1534
|
|
|
1297
|
-
function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number):
|
|
1535
|
+
function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number): boolean {
|
|
1536
|
+
let changed = false;
|
|
1298
1537
|
const now = Date.now();
|
|
1299
1538
|
for (const [key, ts] of map) {
|
|
1300
1539
|
if (now - ts > ttlMs) {
|
|
1301
1540
|
map.delete(key);
|
|
1541
|
+
changed = true;
|
|
1302
1542
|
}
|
|
1303
1543
|
}
|
|
1304
1544
|
if (map.size <= maxSize) {
|
|
1305
|
-
return;
|
|
1545
|
+
return changed;
|
|
1306
1546
|
}
|
|
1307
1547
|
const ordered = [...map.entries()].sort((a, b) => a[1] - b[1]);
|
|
1308
1548
|
const toDrop = map.size - maxSize;
|
|
@@ -1310,8 +1550,38 @@ function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number
|
|
|
1310
1550
|
const key = ordered[i]?.[0];
|
|
1311
1551
|
if (key) {
|
|
1312
1552
|
map.delete(key);
|
|
1553
|
+
changed = true;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
return changed;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
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;
|
|
1313
1582
|
}
|
|
1314
1583
|
}
|
|
1584
|
+
return changed;
|
|
1315
1585
|
}
|
|
1316
1586
|
|
|
1317
1587
|
async function rotateSessionEntry(params: {
|
|
@@ -1426,6 +1696,185 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1426
1696
|
const sessionState = new Map<string, SessionState>();
|
|
1427
1697
|
const recentFastEvents = new Map<string, number>();
|
|
1428
1698
|
const recentRotationBySession = new Map<string, number>();
|
|
1699
|
+
const pendingSoftSuspectSteeringBySession = new Map<string, number>();
|
|
1700
|
+
const recentClassifiedBySessionHash = new Map<
|
|
1701
|
+
string,
|
|
1702
|
+
{ at: number; source: "fast" | "fallback" }
|
|
1703
|
+
>();
|
|
1704
|
+
const sessionWorkQueue = new Map<string, Promise<unknown>>();
|
|
1705
|
+
|
|
1706
|
+
const persistencePath = (() => {
|
|
1707
|
+
try {
|
|
1708
|
+
const stateDir = api.runtime.state.resolveStateDir();
|
|
1709
|
+
return path.join(stateDir, "plugins", api.id, PERSISTENCE_FILE_NAME);
|
|
1710
|
+
} catch (error) {
|
|
1711
|
+
api.logger.warn(`topic-shift-reset: persistence disabled (state path): ${String(error)}`);
|
|
1712
|
+
return null;
|
|
1713
|
+
}
|
|
1714
|
+
})();
|
|
1715
|
+
|
|
1716
|
+
let persistenceDirty = false;
|
|
1717
|
+
let persistenceTimer: NodeJS.Timeout | null = null;
|
|
1718
|
+
let persistenceFlushPromise: Promise<void> | null = null;
|
|
1719
|
+
let persistenceLoadPromise: Promise<void> = Promise.resolve();
|
|
1720
|
+
|
|
1721
|
+
const clearPersistenceTimer = () => {
|
|
1722
|
+
if (!persistenceTimer) {
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
clearTimeout(persistenceTimer);
|
|
1726
|
+
persistenceTimer = null;
|
|
1727
|
+
};
|
|
1728
|
+
|
|
1729
|
+
const flushPersistentState = async (reason: string): Promise<void> => {
|
|
1730
|
+
if (!persistencePath) {
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
await persistenceLoadPromise;
|
|
1734
|
+
if (!persistenceDirty) {
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
if (persistenceFlushPromise) {
|
|
1738
|
+
await persistenceFlushPromise;
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
persistenceFlushPromise = (async () => {
|
|
1742
|
+
const now = Date.now();
|
|
1743
|
+
pruneStateMaps(sessionState);
|
|
1744
|
+
pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
|
|
1745
|
+
|
|
1746
|
+
const payload: PersistedRuntimeState = {
|
|
1747
|
+
version: PERSISTENCE_SCHEMA_VERSION,
|
|
1748
|
+
savedAt: now,
|
|
1749
|
+
sessionStateBySessionKey: {},
|
|
1750
|
+
recentRotationBySession: {},
|
|
1751
|
+
};
|
|
1752
|
+
|
|
1753
|
+
for (const [sessionKey, state] of sessionState) {
|
|
1754
|
+
payload.sessionStateBySessionKey[sessionKey] = serializeSessionState(state);
|
|
1755
|
+
}
|
|
1756
|
+
for (const [rotationKey, ts] of recentRotationBySession) {
|
|
1757
|
+
if (now - ts <= ROTATION_DEDUPE_MS * 3) {
|
|
1758
|
+
payload.recentRotationBySession[rotationKey] = ts;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
await withFileLock(persistencePath, LOCK_OPTIONS, async () => {
|
|
1763
|
+
await writeJsonFileAtomically(persistencePath, payload);
|
|
1764
|
+
});
|
|
1765
|
+
persistenceDirty = false;
|
|
1766
|
+
if (cfg.debug) {
|
|
1767
|
+
api.logger.debug(
|
|
1768
|
+
`topic-shift-reset: state-flushed reason=${reason} sessions=${sessionState.size} rotations=${recentRotationBySession.size}`,
|
|
1769
|
+
);
|
|
1770
|
+
}
|
|
1771
|
+
})()
|
|
1772
|
+
.catch((error) => {
|
|
1773
|
+
api.logger.warn(`topic-shift-reset: state flush failed err=${String(error)}`);
|
|
1774
|
+
})
|
|
1775
|
+
.finally(() => {
|
|
1776
|
+
persistenceFlushPromise = null;
|
|
1777
|
+
});
|
|
1778
|
+
await persistenceFlushPromise;
|
|
1779
|
+
};
|
|
1780
|
+
|
|
1781
|
+
const schedulePersistentFlush = (urgent = false) => {
|
|
1782
|
+
if (!persistencePath) {
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
persistenceDirty = true;
|
|
1786
|
+
if (urgent) {
|
|
1787
|
+
void flushPersistentState("urgent");
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
if (persistenceTimer) {
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
persistenceTimer = setTimeout(() => {
|
|
1794
|
+
persistenceTimer = null;
|
|
1795
|
+
void flushPersistentState("scheduled");
|
|
1796
|
+
}, PERSISTENCE_FLUSH_DEBOUNCE_MS);
|
|
1797
|
+
persistenceTimer.unref?.();
|
|
1798
|
+
};
|
|
1799
|
+
|
|
1800
|
+
const runSerializedBySession = async <T>(
|
|
1801
|
+
sessionKey: string,
|
|
1802
|
+
fn: () => Promise<T>,
|
|
1803
|
+
): Promise<T> => {
|
|
1804
|
+
const previous = sessionWorkQueue.get(sessionKey) ?? Promise.resolve();
|
|
1805
|
+
const current = previous.catch(() => undefined).then(fn);
|
|
1806
|
+
const tail = current.then(
|
|
1807
|
+
() => undefined,
|
|
1808
|
+
() => undefined,
|
|
1809
|
+
);
|
|
1810
|
+
sessionWorkQueue.set(sessionKey, tail);
|
|
1811
|
+
try {
|
|
1812
|
+
return await current;
|
|
1813
|
+
} finally {
|
|
1814
|
+
if (sessionWorkQueue.get(sessionKey) === tail) {
|
|
1815
|
+
sessionWorkQueue.delete(sessionKey);
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
};
|
|
1819
|
+
|
|
1820
|
+
persistenceLoadPromise = (async () => {
|
|
1821
|
+
if (!persistencePath) {
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
try {
|
|
1825
|
+
const loaded = await withFileLock(persistencePath, LOCK_OPTIONS, async () => {
|
|
1826
|
+
return await readJsonFileWithFallback<PersistedRuntimeState | null>(persistencePath, null);
|
|
1827
|
+
});
|
|
1828
|
+
const value = loaded.value;
|
|
1829
|
+
if (!value || typeof value !== "object") {
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
if (value.version !== PERSISTENCE_SCHEMA_VERSION) {
|
|
1833
|
+
api.logger.warn(
|
|
1834
|
+
`topic-shift-reset: state version mismatch expected=${PERSISTENCE_SCHEMA_VERSION} got=${String(value.version)}; ignoring persisted state`,
|
|
1835
|
+
);
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
const restoredSessionStateByKey =
|
|
1840
|
+
value.sessionStateBySessionKey && typeof value.sessionStateBySessionKey === "object"
|
|
1841
|
+
? value.sessionStateBySessionKey
|
|
1842
|
+
: {};
|
|
1843
|
+
for (const [sessionKey, rawState] of Object.entries(restoredSessionStateByKey)) {
|
|
1844
|
+
const trimmedSessionKey = sessionKey.trim();
|
|
1845
|
+
if (!trimmedSessionKey) {
|
|
1846
|
+
continue;
|
|
1847
|
+
}
|
|
1848
|
+
const restored = deserializeSessionState(rawState, cfg);
|
|
1849
|
+
if (!restored) {
|
|
1850
|
+
continue;
|
|
1851
|
+
}
|
|
1852
|
+
sessionState.set(trimmedSessionKey, restored);
|
|
1853
|
+
}
|
|
1854
|
+
pruneStateMaps(sessionState);
|
|
1855
|
+
|
|
1856
|
+
const restoredRotationByKey =
|
|
1857
|
+
value.recentRotationBySession && typeof value.recentRotationBySession === "object"
|
|
1858
|
+
? value.recentRotationBySession
|
|
1859
|
+
: {};
|
|
1860
|
+
for (const [key, ts] of Object.entries(restoredRotationByKey)) {
|
|
1861
|
+
if (!key.trim()) {
|
|
1862
|
+
continue;
|
|
1863
|
+
}
|
|
1864
|
+
if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= 0) {
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1867
|
+
recentRotationBySession.set(key, Math.floor(ts));
|
|
1868
|
+
}
|
|
1869
|
+
pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
|
|
1870
|
+
|
|
1871
|
+
api.logger.info(
|
|
1872
|
+
`topic-shift-reset: restored state sessions=${sessionState.size} rotations=${recentRotationBySession.size}`,
|
|
1873
|
+
);
|
|
1874
|
+
} catch (error) {
|
|
1875
|
+
api.logger.warn(`topic-shift-reset: state restore failed err=${String(error)}`);
|
|
1876
|
+
}
|
|
1877
|
+
})();
|
|
1429
1878
|
|
|
1430
1879
|
let embeddingBackend: EmbeddingBackend | null = null;
|
|
1431
1880
|
let embeddingInitError: string | null = null;
|
|
@@ -1443,17 +1892,18 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1443
1892
|
api.logger.info(`topic-shift-reset: embedding backend ${embeddingBackend.name}`);
|
|
1444
1893
|
}
|
|
1445
1894
|
|
|
1446
|
-
const
|
|
1895
|
+
const classifyAndMaybeRotateInner = async (params: {
|
|
1447
1896
|
source: "fast" | "fallback";
|
|
1448
1897
|
sessionKey: string;
|
|
1449
1898
|
text: string;
|
|
1450
1899
|
messageProvider?: string;
|
|
1451
1900
|
agentId?: string;
|
|
1452
1901
|
}) => {
|
|
1902
|
+
await persistenceLoadPromise;
|
|
1453
1903
|
if (!cfg.enabled) {
|
|
1454
1904
|
return;
|
|
1455
1905
|
}
|
|
1456
|
-
const sessionKey = params.sessionKey
|
|
1906
|
+
const sessionKey = params.sessionKey;
|
|
1457
1907
|
if (!sessionKey) {
|
|
1458
1908
|
return;
|
|
1459
1909
|
}
|
|
@@ -1462,6 +1912,14 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1462
1912
|
if (provider && cfg.ignoredProviders.has(provider)) {
|
|
1463
1913
|
return;
|
|
1464
1914
|
}
|
|
1915
|
+
if (isInternalNonUserProvider(provider)) {
|
|
1916
|
+
if (cfg.debug) {
|
|
1917
|
+
api.logger.debug(
|
|
1918
|
+
`topic-shift-reset: skip-internal-provider source=${params.source} provider=${provider} session=${sessionKey}`,
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1465
1923
|
|
|
1466
1924
|
const rawText = params.text.trim();
|
|
1467
1925
|
const text = cfg.stripEnvelope ? stripClassifierEnvelope(rawText, cfg.stripRules) : rawText;
|
|
@@ -1472,7 +1930,7 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1472
1930
|
const tokenList = tokenizeList(text, cfg.minTokenLength);
|
|
1473
1931
|
if (text.length < cfg.minSignalChars || tokenList.length < cfg.minSignalTokenCount) {
|
|
1474
1932
|
if (cfg.debug) {
|
|
1475
|
-
api.logger.
|
|
1933
|
+
api.logger.debug(
|
|
1476
1934
|
[
|
|
1477
1935
|
`topic-shift-reset: skip-low-signal`,
|
|
1478
1936
|
`source=${params.source}`,
|
|
@@ -1487,13 +1945,34 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1487
1945
|
|
|
1488
1946
|
const tokens = new Set(tokenList);
|
|
1489
1947
|
|
|
1948
|
+
const now = Date.now();
|
|
1490
1949
|
const contentHash = hashString(normalizeTextForHash(text));
|
|
1491
|
-
const
|
|
1492
|
-
|
|
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
|
+
}
|
|
1493
1967
|
return;
|
|
1494
1968
|
}
|
|
1969
|
+
recentClassifiedBySessionHash.set(classifyDedupeKey, { at: now, source: params.source });
|
|
1970
|
+
pruneRecentClassifiedMap(recentClassifiedBySessionHash, CROSS_PATH_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
|
|
1495
1971
|
|
|
1496
|
-
const
|
|
1972
|
+
const lastRotationAt = recentRotationBySession.get(`${sessionKey}:${contentHash}`);
|
|
1973
|
+
if (typeof lastRotationAt === "number" && now - lastRotationAt < ROTATION_DEDUPE_MS) {
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1497
1976
|
const state =
|
|
1498
1977
|
sessionState.get(sessionKey) ??
|
|
1499
1978
|
({
|
|
@@ -1558,7 +2037,7 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1558
2037
|
});
|
|
1559
2038
|
|
|
1560
2039
|
if (cfg.debug) {
|
|
1561
|
-
api.logger.
|
|
2040
|
+
api.logger.debug(
|
|
1562
2041
|
[
|
|
1563
2042
|
`topic-shift-reset: classify`,
|
|
1564
2043
|
`source=${params.source}`,
|
|
@@ -1578,16 +2057,19 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1578
2057
|
}
|
|
1579
2058
|
|
|
1580
2059
|
if (decision.kind === "warmup") {
|
|
2060
|
+
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
1581
2061
|
state.pendingSoftSignals = 0;
|
|
1582
2062
|
state.pendingEntries = [];
|
|
1583
2063
|
state.history = trimHistory([...state.history, entry], cfg.historyWindow);
|
|
1584
2064
|
updateTopicCentroid(state, entry.embedding);
|
|
1585
2065
|
sessionState.set(sessionKey, state);
|
|
1586
2066
|
pruneStateMaps(sessionState);
|
|
2067
|
+
schedulePersistentFlush(false);
|
|
1587
2068
|
return;
|
|
1588
2069
|
}
|
|
1589
2070
|
|
|
1590
2071
|
if (decision.kind === "stable") {
|
|
2072
|
+
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
1591
2073
|
const merged = [...state.history, ...state.pendingEntries, entry];
|
|
1592
2074
|
for (const item of [...state.pendingEntries, entry]) {
|
|
1593
2075
|
updateTopicCentroid(state, item.embedding);
|
|
@@ -1597,14 +2079,24 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1597
2079
|
state.history = trimHistory(merged, cfg.historyWindow);
|
|
1598
2080
|
sessionState.set(sessionKey, state);
|
|
1599
2081
|
pruneStateMaps(sessionState);
|
|
2082
|
+
schedulePersistentFlush(false);
|
|
1600
2083
|
return;
|
|
1601
2084
|
}
|
|
1602
2085
|
|
|
1603
2086
|
if (decision.kind === "suspect") {
|
|
2087
|
+
if (cfg.softSuspect.action === "ask") {
|
|
2088
|
+
pendingSoftSuspectSteeringBySession.set(sessionKey, now);
|
|
2089
|
+
pruneRecentMap(
|
|
2090
|
+
pendingSoftSuspectSteeringBySession,
|
|
2091
|
+
Math.max(cfg.softSuspect.ttlMs * 2, 60_000),
|
|
2092
|
+
MAX_RECENT_FAST_EVENTS,
|
|
2093
|
+
);
|
|
2094
|
+
}
|
|
1604
2095
|
state.pendingSoftSignals += 1;
|
|
1605
2096
|
state.pendingEntries = trimHistory([...state.pendingEntries, entry], cfg.softConsecutiveSignals);
|
|
1606
2097
|
sessionState.set(sessionKey, state);
|
|
1607
2098
|
pruneStateMaps(sessionState);
|
|
2099
|
+
schedulePersistentFlush(false);
|
|
1608
2100
|
return;
|
|
1609
2101
|
}
|
|
1610
2102
|
|
|
@@ -1625,14 +2117,42 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1625
2117
|
});
|
|
1626
2118
|
|
|
1627
2119
|
if (rotated) {
|
|
2120
|
+
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
1628
2121
|
if (!cfg.dryRun) {
|
|
1629
2122
|
recentRotationBySession.set(`${sessionKey}:${contentHash}`, Date.now());
|
|
1630
2123
|
}
|
|
2124
|
+
schedulePersistentFlush(true);
|
|
1631
2125
|
}
|
|
1632
2126
|
|
|
1633
2127
|
sessionState.set(sessionKey, state);
|
|
1634
2128
|
pruneStateMaps(sessionState);
|
|
1635
|
-
|
|
2129
|
+
const prunedRotations = pruneRecentMap(
|
|
2130
|
+
recentRotationBySession,
|
|
2131
|
+
ROTATION_DEDUPE_MS * 3,
|
|
2132
|
+
MAX_RECENT_FAST_EVENTS,
|
|
2133
|
+
);
|
|
2134
|
+
if (prunedRotations) {
|
|
2135
|
+
schedulePersistentFlush(false);
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
|
|
2139
|
+
const classifyAndMaybeRotate = async (params: {
|
|
2140
|
+
source: "fast" | "fallback";
|
|
2141
|
+
sessionKey: string;
|
|
2142
|
+
text: string;
|
|
2143
|
+
messageProvider?: string;
|
|
2144
|
+
agentId?: string;
|
|
2145
|
+
}) => {
|
|
2146
|
+
const sessionKey = params.sessionKey.trim();
|
|
2147
|
+
if (!sessionKey) {
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
await runSerializedBySession(sessionKey, async () => {
|
|
2151
|
+
await classifyAndMaybeRotateInner({
|
|
2152
|
+
...params,
|
|
2153
|
+
sessionKey,
|
|
2154
|
+
});
|
|
2155
|
+
});
|
|
1636
2156
|
};
|
|
1637
2157
|
|
|
1638
2158
|
api.on("message_received", async (event, ctx) => {
|
|
@@ -1665,6 +2185,7 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1665
2185
|
pruneRecentMap(recentFastEvents, FAST_EVENT_TTL_MS, MAX_RECENT_FAST_EVENTS);
|
|
1666
2186
|
|
|
1667
2187
|
let resolvedSessionKey = "";
|
|
2188
|
+
let resolvedAgentId: string | undefined;
|
|
1668
2189
|
try {
|
|
1669
2190
|
const route = api.runtime.channel.routing.resolveAgentRoute({
|
|
1670
2191
|
cfg: api.config,
|
|
@@ -1673,9 +2194,10 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1673
2194
|
peer,
|
|
1674
2195
|
});
|
|
1675
2196
|
resolvedSessionKey = route.sessionKey;
|
|
2197
|
+
resolvedAgentId = route.agentId;
|
|
1676
2198
|
} catch (error) {
|
|
1677
2199
|
if (cfg.debug) {
|
|
1678
|
-
api.logger.
|
|
2200
|
+
api.logger.debug(
|
|
1679
2201
|
`topic-shift-reset: fast-route-skip channel=${channelId} peer=${maybeJson(peer)} err=${String(error)}`,
|
|
1680
2202
|
);
|
|
1681
2203
|
}
|
|
@@ -1687,6 +2209,7 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1687
2209
|
sessionKey: resolvedSessionKey,
|
|
1688
2210
|
text,
|
|
1689
2211
|
messageProvider: channelId,
|
|
2212
|
+
agentId: resolvedAgentId,
|
|
1690
2213
|
});
|
|
1691
2214
|
});
|
|
1692
2215
|
|
|
@@ -1707,4 +2230,29 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1707
2230
|
agentId: ctx.agentId,
|
|
1708
2231
|
});
|
|
1709
2232
|
});
|
|
2233
|
+
|
|
2234
|
+
api.on("before_prompt_build", async (_event, ctx) => {
|
|
2235
|
+
if (!cfg.enabled || cfg.softSuspect.action !== "ask") {
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
const sessionKey = ctx.sessionKey?.trim();
|
|
2239
|
+
if (!sessionKey) {
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
const seenAt = pendingSoftSuspectSteeringBySession.get(sessionKey);
|
|
2243
|
+
if (typeof seenAt !== "number") {
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
if (Date.now() - seenAt > cfg.softSuspect.ttlMs) {
|
|
2247
|
+
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
2250
|
+
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
2251
|
+
return { prependContext: cfg.softSuspect.prompt };
|
|
2252
|
+
});
|
|
2253
|
+
|
|
2254
|
+
api.on("gateway_stop", async () => {
|
|
2255
|
+
clearPersistenceTimer();
|
|
2256
|
+
await flushPersistentState("gateway-stop");
|
|
2257
|
+
});
|
|
1710
2258
|
}
|