openclaw-topic-shift-reset 0.3.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.
@@ -130,6 +132,59 @@ When the classifier sees a soft topic-shift signal (`suspect`) but not enough co
130
132
  openclaw logs --follow --plain | rg topic-shift-reset
131
133
  ```
132
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
+
133
188
  ## Advanced tuning
134
189
 
135
190
  Use `config.advanced` only if needed. Full reference:
@@ -155,4 +210,4 @@ No build step is required. OpenClaw loads `src/index.ts` via jiti.
155
210
 
156
211
  ## Known tradeoff (plugin-only)
157
212
 
158
- 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.
@@ -28,6 +28,8 @@ This plugin now accepts one canonical key per concept:
28
28
 
29
29
  ## Public options
30
30
 
31
+ Classifier inputs are limited to inbound user message text and successful outbound agent message text.
32
+
31
33
  - `enabled`: plugin on/off.
32
34
  - `preset`: `conservative | balanced | aggressive`.
33
35
  - `embedding.provider`: `auto | openai | ollama | none`.
@@ -128,7 +130,7 @@ Advanced keys:
128
130
  `ignoredProviders` expects canonical provider IDs:
129
131
 
130
132
  - `telegram`, `whatsapp`, `signal`, `discord`, `slack`, `matrix`, `msteams`, `imessage`, `web`, `voice`
131
- - model/provider IDs like `openai`, `anthropic`, `ollama` (for fallback hook contexts)
133
+ - internal/system-style providers like `cron-event`, `heartbeat`, `exec-event`
132
134
 
133
135
  ## Migration note
134
136
 
@@ -145,14 +147,14 @@ Legacy alias keys are not supported in this release. Config validation fails if
145
147
  Classifier runtime state is persisted automatically under the OpenClaw state directory (`plugins/<plugin-id>/runtime-state.v1.json`).
146
148
 
147
149
  - Persisted: per-session topic history, pending soft-signal windows, topic centroid, rotation dedupe map.
148
- - Not persisted: transient fast-event dedupe cache.
150
+ - Not persisted: transient message-event dedupe cache.
149
151
  - No extra config is required.
150
152
 
151
153
  ## Log interpretation
152
154
 
153
155
  Classifier logs look like:
154
156
 
155
- `topic-shift-reset: classify source=<fast|fallback> kind=<...> reason=<...> ...`
157
+ `topic-shift-reset: classify source=<user|agent> kind=<...> reason=<...> ...`
156
158
 
157
159
  Kinds:
158
160
 
@@ -167,4 +169,4 @@ Other lines:
167
169
  - `skip-low-signal`: message skipped by hard signal floor (`minSignalChars`/`minSignalTokenCount`).
168
170
  - `would-rotate`: `dryRun=true` synthetic rotate event (no reset mutation).
169
171
  - `rotated`: actual session rotation happened.
170
- - `classify` / `skip-low-signal` / `skip-cross-path-duplicate`: debug-level diagnostics (`debug=true`).
172
+ - `classify` / `skip-low-signal` / `user-route-skip` / `agent-route-skip`: debug-level diagnostics (`debug=true`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-topic-shift-reset",
3
- "version": "0.3.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",
package/src/index.ts CHANGED
@@ -211,6 +211,8 @@ type FastMessageEventLike = {
211
211
  metadata?: Record<string, unknown>;
212
212
  };
213
213
 
214
+ type ClassificationSource = "user" | "agent";
215
+
214
216
  type TranscriptMessage = {
215
217
  role: string;
216
218
  text: string;
@@ -320,10 +322,9 @@ const LOCK_OPTIONS = {
320
322
 
321
323
  const MAX_TRACKED_SESSIONS = 10_000;
322
324
  const STALE_SESSION_STATE_MS = 4 * 60 * 60 * 1000;
323
- const MAX_RECENT_FAST_EVENTS = 20_000;
324
- 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;
325
327
  const ROTATION_DEDUPE_MS = 25_000;
326
- const CROSS_PATH_DEDUPE_MS = 15_000;
327
328
  const PERSISTENCE_SCHEMA_VERSION = 1;
328
329
  const PERSISTENCE_FILE_NAME = "runtime-state.v1.json";
329
330
  const PERSISTENCE_FLUSH_DEBOUNCE_MS = 1_200;
@@ -1556,40 +1557,12 @@ function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number
1556
1557
  return changed;
1557
1558
  }
1558
1559
 
1559
- function pruneRecentClassifiedMap(
1560
- map: Map<string, { at: number; source: "fast" | "fallback" }>,
1561
- ttlMs: number,
1562
- maxSize: number,
1563
- ): boolean {
1564
- let changed = false;
1565
- const now = Date.now();
1566
- for (const [key, value] of map) {
1567
- if (now - value.at > ttlMs) {
1568
- map.delete(key);
1569
- changed = true;
1570
- }
1571
- }
1572
- if (map.size <= maxSize) {
1573
- return changed;
1574
- }
1575
- const ordered = [...map.entries()].sort((a, b) => a[1].at - b[1].at);
1576
- const toDrop = map.size - maxSize;
1577
- for (let i = 0; i < toDrop; i += 1) {
1578
- const key = ordered[i]?.[0];
1579
- if (key) {
1580
- map.delete(key);
1581
- changed = true;
1582
- }
1583
- }
1584
- return changed;
1585
- }
1586
-
1587
1560
  async function rotateSessionEntry(params: {
1588
1561
  api: OpenClawPluginApi;
1589
1562
  cfg: ResolvedConfig;
1590
1563
  sessionKey: string;
1591
1564
  agentId?: string;
1592
- source: "fast" | "fallback";
1565
+ source: ClassificationSource;
1593
1566
  reason: string;
1594
1567
  metrics: ClassifierMetrics;
1595
1568
  entry: HistoryEntry;
@@ -1694,13 +1667,10 @@ async function rotateSessionEntry(params: {
1694
1667
  export default function register(api: OpenClawPluginApi): void {
1695
1668
  const cfg = resolveConfig(api.pluginConfig);
1696
1669
  const sessionState = new Map<string, SessionState>();
1697
- const recentFastEvents = new Map<string, number>();
1670
+ const recentUserEvents = new Map<string, number>();
1671
+ const recentAgentEvents = new Map<string, number>();
1698
1672
  const recentRotationBySession = new Map<string, number>();
1699
1673
  const pendingSoftSuspectSteeringBySession = new Map<string, number>();
1700
- const recentClassifiedBySessionHash = new Map<
1701
- string,
1702
- { at: number; source: "fast" | "fallback" }
1703
- >();
1704
1674
  const sessionWorkQueue = new Map<string, Promise<unknown>>();
1705
1675
 
1706
1676
  const persistencePath = (() => {
@@ -1741,7 +1711,7 @@ export default function register(api: OpenClawPluginApi): void {
1741
1711
  persistenceFlushPromise = (async () => {
1742
1712
  const now = Date.now();
1743
1713
  pruneStateMaps(sessionState);
1744
- pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
1714
+ pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_MESSAGE_EVENTS);
1745
1715
 
1746
1716
  const payload: PersistedRuntimeState = {
1747
1717
  version: PERSISTENCE_SCHEMA_VERSION,
@@ -1866,7 +1836,7 @@ export default function register(api: OpenClawPluginApi): void {
1866
1836
  }
1867
1837
  recentRotationBySession.set(key, Math.floor(ts));
1868
1838
  }
1869
- pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
1839
+ pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_MESSAGE_EVENTS);
1870
1840
 
1871
1841
  api.logger.info(
1872
1842
  `topic-shift-reset: restored state sessions=${sessionState.size} rotations=${recentRotationBySession.size}`,
@@ -1893,7 +1863,7 @@ export default function register(api: OpenClawPluginApi): void {
1893
1863
  }
1894
1864
 
1895
1865
  const classifyAndMaybeRotateInner = async (params: {
1896
- source: "fast" | "fallback";
1866
+ source: ClassificationSource;
1897
1867
  sessionKey: string;
1898
1868
  text: string;
1899
1869
  messageProvider?: string;
@@ -1947,28 +1917,6 @@ export default function register(api: OpenClawPluginApi): void {
1947
1917
 
1948
1918
  const now = Date.now();
1949
1919
  const contentHash = hashString(normalizeTextForHash(text));
1950
- const classifyDedupeKey = `${sessionKey}:${contentHash}`;
1951
- const recentCrossSourceClassification = recentClassifiedBySessionHash.get(classifyDedupeKey);
1952
- if (
1953
- recentCrossSourceClassification &&
1954
- recentCrossSourceClassification.source !== params.source &&
1955
- now - recentCrossSourceClassification.at < CROSS_PATH_DEDUPE_MS
1956
- ) {
1957
- if (cfg.debug) {
1958
- api.logger.debug(
1959
- [
1960
- `topic-shift-reset: skip-cross-path-duplicate`,
1961
- `session=${sessionKey}`,
1962
- `source=${params.source}`,
1963
- `prevSource=${recentCrossSourceClassification.source}`,
1964
- ].join(" "),
1965
- );
1966
- }
1967
- return;
1968
- }
1969
- recentClassifiedBySessionHash.set(classifyDedupeKey, { at: now, source: params.source });
1970
- pruneRecentClassifiedMap(recentClassifiedBySessionHash, CROSS_PATH_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
1971
-
1972
1920
  const lastRotationAt = recentRotationBySession.get(`${sessionKey}:${contentHash}`);
1973
1921
  if (typeof lastRotationAt === "number" && now - lastRotationAt < ROTATION_DEDUPE_MS) {
1974
1922
  return;
@@ -2089,7 +2037,7 @@ export default function register(api: OpenClawPluginApi): void {
2089
2037
  pruneRecentMap(
2090
2038
  pendingSoftSuspectSteeringBySession,
2091
2039
  Math.max(cfg.softSuspect.ttlMs * 2, 60_000),
2092
- MAX_RECENT_FAST_EVENTS,
2040
+ MAX_RECENT_MESSAGE_EVENTS,
2093
2041
  );
2094
2042
  }
2095
2043
  state.pendingSoftSignals += 1;
@@ -2129,7 +2077,7 @@ export default function register(api: OpenClawPluginApi): void {
2129
2077
  const prunedRotations = pruneRecentMap(
2130
2078
  recentRotationBySession,
2131
2079
  ROTATION_DEDUPE_MS * 3,
2132
- MAX_RECENT_FAST_EVENTS,
2080
+ MAX_RECENT_MESSAGE_EVENTS,
2133
2081
  );
2134
2082
  if (prunedRotations) {
2135
2083
  schedulePersistentFlush(false);
@@ -2137,7 +2085,7 @@ export default function register(api: OpenClawPluginApi): void {
2137
2085
  };
2138
2086
 
2139
2087
  const classifyAndMaybeRotate = async (params: {
2140
- source: "fast" | "fallback";
2088
+ source: ClassificationSource;
2141
2089
  sessionKey: string;
2142
2090
  text: string;
2143
2091
  messageProvider?: string;
@@ -2170,19 +2118,19 @@ export default function register(api: OpenClawPluginApi): void {
2170
2118
  return;
2171
2119
  }
2172
2120
 
2173
- const fastEventKey = [
2121
+ const userEventKey = [
2174
2122
  channelId,
2175
2123
  ctx.accountId ?? "",
2176
2124
  peer.kind,
2177
2125
  peer.id,
2178
2126
  hashString(normalizeTextForHash(text)),
2179
2127
  ].join("|");
2180
- const seenAt = recentFastEvents.get(fastEventKey);
2181
- 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) {
2182
2130
  return;
2183
2131
  }
2184
- recentFastEvents.set(fastEventKey, Date.now());
2185
- 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);
2186
2134
 
2187
2135
  let resolvedSessionKey = "";
2188
2136
  let resolvedAgentId: string | undefined;
@@ -2198,14 +2146,14 @@ export default function register(api: OpenClawPluginApi): void {
2198
2146
  } catch (error) {
2199
2147
  if (cfg.debug) {
2200
2148
  api.logger.debug(
2201
- `topic-shift-reset: fast-route-skip channel=${channelId} peer=${maybeJson(peer)} err=${String(error)}`,
2149
+ `topic-shift-reset: user-route-skip channel=${channelId} peer=${maybeJson(peer)} err=${String(error)}`,
2202
2150
  );
2203
2151
  }
2204
2152
  return;
2205
2153
  }
2206
2154
 
2207
2155
  await classifyAndMaybeRotate({
2208
- source: "fast",
2156
+ source: "user",
2209
2157
  sessionKey: resolvedSessionKey,
2210
2158
  text,
2211
2159
  messageProvider: channelId,
@@ -2213,21 +2161,63 @@ export default function register(api: OpenClawPluginApi): void {
2213
2161
  });
2214
2162
  });
2215
2163
 
2216
- api.on("before_model_resolve", async (event, ctx) => {
2164
+ api.on("message_sent", async (event, ctx) => {
2217
2165
  if (!cfg.enabled) {
2218
2166
  return;
2219
2167
  }
2220
- const sessionKey = ctx.sessionKey?.trim();
2221
- 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
+ }
2222
2212
  return;
2223
2213
  }
2224
2214
 
2225
2215
  await classifyAndMaybeRotate({
2226
- source: "fallback",
2227
- sessionKey,
2228
- text: event.prompt,
2229
- messageProvider: ctx.messageProvider,
2230
- agentId: ctx.agentId,
2216
+ source: "agent",
2217
+ sessionKey: resolvedSessionKey,
2218
+ text,
2219
+ messageProvider: channelId,
2220
+ agentId: resolvedAgentId,
2231
2221
  });
2232
2222
  });
2233
2223