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 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 improves timing with fast path + fallback, but cannot guarantee 100% that the triggering message becomes the first persisted message of the new session without core pre-session hooks.
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.
@@ -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
- - model/provider IDs like `openai`, `anthropic`, `ollama` (for fallback hook contexts)
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=<fast|fallback> kind=<...> reason=<...> ...`
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`).
@@ -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.2.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: 45_000,
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 MAX_RECENT_FAST_EVENTS = 20_000;
283
- const FAST_EVENT_TTL_MS = 5 * 60 * 1000;
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>): void {
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): void {
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: "fast" | "fallback";
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 recentFastEvents = new Map<string, number>();
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 classifyAndMaybeRotate = async (params: {
1447
- source: "fast" | "fallback";
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.trim();
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.info(
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" && Date.now() - lastRotationAt < ROTATION_DEDUPE_MS) {
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.info(
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
- pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
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 fastEventKey = [
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 = recentFastEvents.get(fastEventKey);
1661
- if (typeof seenAt === "number" && Date.now() - seenAt < FAST_EVENT_TTL_MS) {
2128
+ const seenAt = recentUserEvents.get(userEventKey);
2129
+ if (typeof seenAt === "number" && Date.now() - seenAt < MESSAGE_EVENT_TTL_MS) {
1662
2130
  return;
1663
2131
  }
1664
- recentFastEvents.set(fastEventKey, Date.now());
1665
- pruneRecentMap(recentFastEvents, FAST_EVENT_TTL_MS, MAX_RECENT_FAST_EVENTS);
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.info(
1679
- `topic-shift-reset: fast-route-skip channel=${channelId} peer=${maybeJson(peer)} err=${String(error)}`,
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: "fast",
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("before_model_resolve", async (event, ctx) => {
2164
+ api.on("message_sent", async (event, ctx) => {
1694
2165
  if (!cfg.enabled) {
1695
2166
  return;
1696
2167
  }
1697
- const sessionKey = ctx.sessionKey?.trim();
1698
- if (!sessionKey) {
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: "fallback",
1704
- sessionKey,
1705
- text: event.prompt,
1706
- messageProvider: ctx.messageProvider,
1707
- agentId: ctx.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
  }