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 +56 -1
- package/docs/configuration.md +6 -4
- package/package.json +1 -1
- package/src/index.ts +70 -80
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
|
|
213
|
+
This plugin cannot guarantee 100% that the triggering message becomes the first persisted message of the new session because resets happen in runtime hooks and provider pipelines vary.
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
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=<
|
|
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-
|
|
172
|
+
- `classify` / `skip-low-signal` / `user-route-skip` / `agent-route-skip`: debug-level diagnostics (`debug=true`).
|
package/package.json
CHANGED
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
|
|
324
|
-
const
|
|
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:
|
|
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
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
2181
|
-
if (typeof seenAt === "number" && Date.now() - seenAt <
|
|
2128
|
+
const seenAt = recentUserEvents.get(userEventKey);
|
|
2129
|
+
if (typeof seenAt === "number" && Date.now() - seenAt < MESSAGE_EVENT_TTL_MS) {
|
|
2182
2130
|
return;
|
|
2183
2131
|
}
|
|
2184
|
-
|
|
2185
|
-
pruneRecentMap(
|
|
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:
|
|
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: "
|
|
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("
|
|
2164
|
+
api.on("message_sent", async (event, ctx) => {
|
|
2217
2165
|
if (!cfg.enabled) {
|
|
2218
2166
|
return;
|
|
2219
2167
|
}
|
|
2220
|
-
|
|
2221
|
-
|
|
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: "
|
|
2227
|
-
sessionKey,
|
|
2228
|
-
text
|
|
2229
|
-
messageProvider:
|
|
2230
|
-
agentId:
|
|
2216
|
+
source: "agent",
|
|
2217
|
+
sessionKey: resolvedSessionKey,
|
|
2218
|
+
text,
|
|
2219
|
+
messageProvider: channelId,
|
|
2220
|
+
agentId: resolvedAgentId,
|
|
2231
2221
|
});
|
|
2232
2222
|
});
|
|
2233
2223
|
|