mioku-plugin-chat 2.0.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.
@@ -0,0 +1,131 @@
1
+ import type { AIInstance } from "mioku";
2
+ import { logger } from "mioki";
3
+ import type { ChatDatabase } from "../db";
4
+ import type { ChatConfig, ChatMessage } from "../types";
5
+
6
+ export interface MemoryUserHistoryChunk {
7
+ userId: number;
8
+ messages: ChatMessage[];
9
+ }
10
+
11
+ export interface MemoryRecallInput {
12
+ sessionId: string;
13
+ question: string;
14
+ groupHistoryMessages?: ChatMessage[];
15
+ userHistories?: MemoryUserHistoryChunk[];
16
+ nowTimestamp?: number;
17
+ }
18
+
19
+ export class MemoryRetrieval {
20
+ private ai: AIInstance;
21
+ private config: ChatConfig;
22
+ private db: ChatDatabase;
23
+
24
+ constructor(ai: AIInstance, config: ChatConfig, db: ChatDatabase) {
25
+ this.ai = ai;
26
+ this.config = config;
27
+ this.db = db;
28
+ }
29
+
30
+ async retrieveByQuestion(input: MemoryRecallInput): Promise<string | null> {
31
+ if (!this.config.memory?.enabled) return null;
32
+
33
+ const question = String(input.question || "").trim();
34
+ if (!question) return null;
35
+
36
+ const nowTs = input.nowTimestamp ?? Date.now();
37
+ const nowText = this.formatTimestamp(nowTs);
38
+ const recentContext = this.formatSessionMessages(
39
+ this.db.getMessages(input.sessionId, 30),
40
+ );
41
+ const groupHistory = this.formatGroupHistory(input.groupHistoryMessages || []);
42
+ const userHistory = this.formatUserHistories(input.userHistories || []);
43
+
44
+ try {
45
+ const result = await this.ai.generateText({
46
+ prompt: `You are the memory worker model. You receive a recall question from the main chat model.
47
+ Current time: ${nowText}
48
+
49
+ Recall question:
50
+ ${question}
51
+
52
+ Important:
53
+ - All provided records are historical logs, NOT messages sent just now.
54
+ - Every log already contains its send time/date. Use those timestamps carefully.
55
+ - If evidence is insufficient, clearly say what is missing instead of guessing.
56
+
57
+ Recent in-context chat snapshot (for orientation only):
58
+ ${recentContext}
59
+
60
+ Group history fetched via message_id pagination (historical):
61
+ ${groupHistory}
62
+
63
+ User history chunks (historical):
64
+ ${userHistory}
65
+
66
+ Output requirements:
67
+ - Give one concise recall result for the main chat model.
68
+ - If useful evidence exists, summarize key facts with timestamps.
69
+ - If no useful memory exists, output exactly: NO_USEFUL_MEMORY_FOUND`,
70
+ messages: [],
71
+ model: this.config.workingModel || this.config.model,
72
+ temperature: 0.2,
73
+ max_tokens: 700,
74
+ });
75
+
76
+ const text = String(result || "").trim();
77
+ if (!text || text.includes("NO_USEFUL_MEMORY_FOUND")) {
78
+ return null;
79
+ }
80
+ return text;
81
+ } catch (err) {
82
+ logger.warn(`[MemoryRetrieval] Worker retrieval failed: ${err}`);
83
+ return null;
84
+ }
85
+ }
86
+
87
+ private formatSessionMessages(messages: ChatMessage[]): string {
88
+ if (!messages.length) return "(none)";
89
+ return messages
90
+ .map((m) => {
91
+ const name = m.userName || "unknown";
92
+ return `[历史消息 | ${this.formatTimestamp(m.timestamp)}] ${name}: ${m.content}(不是刚刚发送)`;
93
+ })
94
+ .join("\n");
95
+ }
96
+
97
+ private formatGroupHistory(messages: ChatMessage[]): string {
98
+ if (!messages.length) return "(none)";
99
+ return messages
100
+ .map((m) => {
101
+ const name = m.userName || "unknown";
102
+ const messageId = m.messageId ?? "unknown";
103
+ return `[历史群聊 | ${this.formatTimestamp(m.timestamp)} | message_id:${messageId}] ${name}: ${m.content}(不是刚刚发送)`;
104
+ })
105
+ .join("\n");
106
+ }
107
+
108
+ private formatUserHistories(chunks: MemoryUserHistoryChunk[]): string {
109
+ if (!chunks.length) return "(none)";
110
+
111
+ const sections = chunks.map((chunk) => {
112
+ if (!chunk.messages.length) {
113
+ return `User ${chunk.userId}: (no historical messages)`;
114
+ }
115
+
116
+ const lines = chunk.messages.map((m) => {
117
+ const name = m.userName || `user:${chunk.userId}`;
118
+ const messageId = m.messageId ?? "unknown";
119
+ return `[历史用户消息 | ${this.formatTimestamp(m.timestamp)} | message_id:${messageId}] ${name}: ${m.content}(不是刚刚发送)`;
120
+ });
121
+
122
+ return `User ${chunk.userId} history:\n${lines.join("\n")}`;
123
+ });
124
+
125
+ return sections.join("\n\n");
126
+ }
127
+
128
+ private formatTimestamp(timestamp: number): string {
129
+ return new Date(timestamp).toLocaleString("zh-CN", { hour12: false });
130
+ }
131
+ }
@@ -0,0 +1,249 @@
1
+ import type { AIInstance } from "mioku";
2
+ import { logger } from "mioki";
3
+ import type {
4
+ ChatConfig,
5
+ ChatMessage,
6
+ PlannerAction,
7
+ PlannerResult,
8
+ } from "../types";
9
+
10
+ export class ActionPlanner {
11
+ private ai: AIInstance;
12
+ private config: ChatConfig;
13
+ private actionHistory: Map<
14
+ string,
15
+ { action: PlannerAction; time: number }[]
16
+ > = new Map();
17
+ private pendingPlans: Map<string, Promise<PlannerResult>> = new Map();
18
+
19
+ constructor(ai: AIInstance, config: ChatConfig) {
20
+ this.ai = ai;
21
+ this.config = config;
22
+ }
23
+
24
+ async plan(
25
+ sessionId: string,
26
+ botName: string,
27
+ recentHistory: ChatMessage[],
28
+ lastTriggerMessage: string,
29
+ isIdleCheck: boolean = false,
30
+ ): Promise<PlannerResult> {
31
+ if (!this.config.planner?.enabled) {
32
+ return { action: "reply", reason: "planner disabled" };
33
+ }
34
+
35
+ // Guard: if a plan call is already in-flight for this session, wait for it
36
+ const existing = this.pendingPlans.get(sessionId);
37
+ if (existing) {
38
+ logger.info(`[ActionPlanner] Session ${sessionId} has a plan already in-flight, waiting...`);
39
+ return existing;
40
+ }
41
+
42
+ const planPromise = this.doPlan(sessionId, botName, recentHistory, lastTriggerMessage, isIdleCheck);
43
+ this.pendingPlans.set(sessionId, planPromise);
44
+
45
+ try {
46
+ return await planPromise;
47
+ } finally {
48
+ this.pendingPlans.delete(sessionId);
49
+ }
50
+ }
51
+
52
+ private async doPlan(
53
+ sessionId: string,
54
+ botName: string,
55
+ recentHistory: ChatMessage[],
56
+ lastTriggerMessage: string,
57
+ isIdleCheck: boolean,
58
+ ): Promise<PlannerResult> {
59
+ if (!this.config.planner?.enabled) {
60
+ return { action: "reply", reason: "planner disabled" };
61
+ }
62
+
63
+ const history = this.actionHistory.get(sessionId) ?? [];
64
+ const recentActions = history.slice(-10);
65
+ const actionsBlock = recentActions
66
+ .map((a) => `[${new Date(a.time).toLocaleTimeString()}] ${a.action}`)
67
+ .join("\n");
68
+
69
+ const chatBlock = recentHistory
70
+ .slice(-100)
71
+ .map((m) => {
72
+ const time = new Date(m.timestamp);
73
+ const timeStr = `${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
74
+ return `[${timeStr}] ${m.userName || (m.role === "assistant" ? botName : "unknown")}: ${m.content}`;
75
+ })
76
+ .join("\n");
77
+
78
+ const now = new Date();
79
+ const timeStr = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, "0")}/${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
80
+
81
+ let prompt: string;
82
+
83
+ if (isIdleCheck) {
84
+ // 空闲检测模式:没有明确触发消息,观察群友聊天并决定是否要融入对话
85
+ prompt = `It is ${timeStr}. Your name is ${botName}.
86
+
87
+ Here is the recent chat content (from oldest to most recent):
88
+ ${chatBlock || "(no chat history)"}
89
+
90
+ IMPORTANT: There is no specific trigger message this time. You are being proactively invited to observe the group chat and decide whether to speak up.
91
+
92
+ REPLY when ANY of these is true:
93
+ - The conversation mentions ${botName} (you), AI, or any topic you know about
94
+ - There's any question unanswered
95
+ - The conversation is about school, life, tech, or anything you can contribute to
96
+ - Even fragmented/random messages - you can start a new topic or comment on something!
97
+
98
+ WAIT only when:
99
+ - The conversation is clearly done and everyone left
100
+ - There's absolutely nothing you can think of to say
101
+
102
+ IMPORTANT: When in doubt, REPLY! Group chats are often casual and fragmented - that's okay! Your message doesn't need to be perfect, just be natural and friendly.
103
+
104
+ Available actions:
105
+
106
+ reply - Send a message to naturally join the conversation (1-2 sentences max, be concise!)
107
+
108
+ wait - Stay silent and continue observing
109
+
110
+ complete - The chat is dead, no one is talking
111
+
112
+ IMPORTANT: You MUST output ONLY valid JSON, no other text. The JSON must be in this exact format:
113
+ {"action": "reply", "reason": "your reason here", "wait_seconds": 0}
114
+
115
+ OR for wait:
116
+ {"action": "wait", "reason": "your reason here", "wait_seconds": 60}
117
+
118
+ OR for complete:
119
+ {"action": "complete", "reason": "your reason here", "wait_seconds": 0}
120
+
121
+ DO NOT include any explanation, markdown formatting, or additional text. Only output the JSON.`;
122
+ } else {
123
+ // 正常模式:由触发消息驱动的 planner
124
+ prompt = `It is ${timeStr}. Your name is ${botName}.
125
+
126
+ Here is the chat content:
127
+ ${chatBlock}
128
+
129
+ Action history:
130
+ ${actionsBlock || "(none)"}
131
+
132
+ Message that triggered you: ${lastTriggerMessage}
133
+
134
+ Available actions:
135
+
136
+ reply - Respond ONLY when truly necessary:
137
+ - Someone directly asked you a question
138
+ - Someone mentioned you specifically
139
+ - Something needs explanation or clarification
140
+ - There's an obvious opportunity to add real value
141
+
142
+ wait - DEFAULT choice. Stay silent when:
143
+ - You have nothing meaningful to add
144
+ - The conversation doesn't need you
145
+ - Someone else is already handling it
146
+ - You're just being polite but have nothing to say
147
+
148
+ complete - The chat is over, no activity for a while
149
+
150
+ IMPORTANT: Silence is golden. When in doubt, WAIT. Don't speak just because you can.
151
+
152
+ IMPORTANT: You MUST output ONLY valid JSON, no other text. The JSON must be in this exact format:
153
+ {"action": "reply", "reason": "your reason here", "wait_seconds": 0}
154
+
155
+ OR for wait:
156
+ {"action": "wait", "reason": "your reason here", "wait_seconds": 30}
157
+
158
+ OR for complete:
159
+ {"action": "complete", "reason": "your reason here", "wait_seconds": 0}
160
+
161
+ DO NOT include any explanation, markdown formatting, or additional text. Only output the JSON.`;
162
+ }
163
+
164
+ try {
165
+ logger.info(
166
+ `[ActionPlanner] Planning action for session ${sessionId}, last message: "${lastTriggerMessage.substring(0, 50)}...", isIdleCheck: ${isIdleCheck}`,
167
+ );
168
+
169
+ const content = await this.ai.generateText({
170
+ prompt,
171
+ messages: [],
172
+ model: this.config.workingModel || this.config.model,
173
+ temperature: isIdleCheck ? 0.3 : 0.2,
174
+ max_tokens: isIdleCheck ? 300 : 500,
175
+ });
176
+
177
+ // 如果返回内容为空,使用默认值
178
+ if (!content || !content.trim()) {
179
+ logger.warn(
180
+ `[ActionPlanner] Empty response from AI, using default reply`,
181
+ );
182
+ return { action: "reply", reason: "empty response" };
183
+ }
184
+
185
+ // 尝试提取 JSON 块
186
+ let jsonStr = "";
187
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
188
+ if (jsonMatch) {
189
+ jsonStr = jsonMatch[0];
190
+ }
191
+
192
+ if (!jsonStr) {
193
+ logger.warn(
194
+ `[ActionPlanner] Failed to find JSON in response: ${content.substring(0, 100)}`,
195
+ );
196
+ return { action: "reply", reason: "parse failed" };
197
+ }
198
+
199
+ // 尝试解析 JSON
200
+ let parsed: any;
201
+ try {
202
+ parsed = JSON.parse(jsonStr);
203
+ } catch {
204
+ // 尝试修复常见的 JSON 错误
205
+ try {
206
+ // 移除可能存在的尾随逗号
207
+ jsonStr = jsonStr.replace(/,\s*}/g, "}").replace(/,\s*]/g, "]");
208
+ parsed = JSON.parse(jsonStr);
209
+ } catch (e) {
210
+ logger.warn(
211
+ `[ActionPlanner] Failed to parse JSON: ${jsonStr.substring(0, 100)}`,
212
+ );
213
+ return { action: "reply", reason: "parse failed" };
214
+ }
215
+ }
216
+ const action: PlannerAction =
217
+ parsed.action === "wait"
218
+ ? "wait"
219
+ : parsed.action === "complete"
220
+ ? "complete"
221
+ : "reply";
222
+
223
+ const result: PlannerResult = {
224
+ action,
225
+ reason: parsed.reason || "",
226
+ waitMs:
227
+ action === "wait"
228
+ ? Math.min(
229
+ Math.max((parsed.wait_seconds || 30) * 1000, 10000),
230
+ 300000,
231
+ )
232
+ : undefined,
233
+ };
234
+
235
+ const actions = this.actionHistory.get(sessionId) ?? [];
236
+ actions.push({ action, time: Date.now() });
237
+ if (actions.length > 20) actions.splice(0, actions.length - 20);
238
+ this.actionHistory.set(sessionId, actions);
239
+
240
+ logger.info(
241
+ `[ActionPlanner] Session ${sessionId}: action=${action}, reason="${result.reason}"${result.waitMs ? `, waitMs=${result.waitMs}` : ""}`,
242
+ );
243
+ return result;
244
+ } catch (err) {
245
+ logger.error(`[ActionPlanner] Error: ${err}`);
246
+ return { action: "reply", reason: "error fallback" };
247
+ }
248
+ }
249
+ }
@@ -0,0 +1,205 @@
1
+ import type { AIInstance } from "mioku";
2
+ import { logger } from "mioki";
3
+ import type { ChatDatabase } from "../db";
4
+ import type { ChatConfig } from "../types";
5
+
6
+ export class TopicTracker {
7
+ private ai: AIInstance;
8
+ private config: ChatConfig;
9
+ private db: ChatDatabase;
10
+ private lastAnalyzedWindowEnd: Map<string, number> = new Map();
11
+
12
+ constructor(ai: AIInstance, config: ChatConfig, db: ChatDatabase) {
13
+ this.ai = ai;
14
+ this.config = config;
15
+ this.db = db;
16
+ }
17
+
18
+ async onMessage(sessionId: string): Promise<void> {
19
+ if (!this.config.topic?.enabled) return;
20
+ if (!sessionId.startsWith("group:")) return;
21
+
22
+ const windowMs = this.getWindowMs();
23
+ const completedWindowEnd = Math.floor(Date.now() / windowMs) * windowMs;
24
+ if (completedWindowEnd <= 0) return;
25
+
26
+ const lastAnalyzedEnd = this.lastAnalyzedWindowEnd.get(sessionId) ?? 0;
27
+ if (completedWindowEnd <= lastAnalyzedEnd) {
28
+ return;
29
+ }
30
+
31
+ this.lastAnalyzedWindowEnd.set(sessionId, completedWindowEnd);
32
+ const windowStartAt = completedWindowEnd - windowMs;
33
+
34
+ this.analyzeWindow(sessionId, windowStartAt, completedWindowEnd).catch(
35
+ (err) => logger.warn(`[TopicTracker] Analysis failed: ${err}`),
36
+ );
37
+ }
38
+
39
+ getTopicContext(sessionId: string, historyStartAt?: number): string {
40
+ if (!this.config.topic?.enabled) return "";
41
+ if (!sessionId.startsWith("group:")) return "";
42
+ if (!historyStartAt) return "";
43
+
44
+ const windowMs = this.getWindowMs();
45
+ const windowCount = this.getHistoryWindowCount();
46
+ const firstWindowEnd = Math.floor(historyStartAt / windowMs) * windowMs;
47
+ if (firstWindowEnd <= 0) return "";
48
+
49
+ const lines: string[] = [];
50
+ for (let i = 0; i < windowCount; i++) {
51
+ const windowEndAt = firstWindowEnd - i * windowMs;
52
+ const windowStartAt = windowEndAt - windowMs;
53
+ if (windowStartAt <= 0) break;
54
+
55
+ const topic = this.db.getTopicByWindow(sessionId, windowStartAt, windowEndAt);
56
+ if (!topic?.summary) continue;
57
+
58
+ const keywords = this.parseKeywords(topic.keywords);
59
+ const timeRange = `${this.formatTime(windowStartAt)} ~ ${this.formatTime(windowEndAt)}`;
60
+ const keywordsLine =
61
+ keywords.length > 0 ? ` | 关键词: ${keywords.join(", ")}` : "";
62
+ lines.push(
63
+ `- ${timeRange}: ${topic.summary.trim()}${keywordsLine}`,
64
+ );
65
+ }
66
+
67
+ if (lines.length === 0) return "";
68
+
69
+ return [
70
+ "## Background Topics Outside Visible History (Reference Only)",
71
+ "These summaries are rough references about older group discussions.",
72
+ "Do not proactively bring them up unless users explicitly ask.",
73
+ ...lines,
74
+ ].join("\n");
75
+ }
76
+
77
+ private async analyzeWindow(
78
+ sessionId: string,
79
+ windowStartAt: number,
80
+ windowEndAt: number,
81
+ ): Promise<void> {
82
+ if (windowEndAt <= windowStartAt) return;
83
+
84
+ const userMessages = this.db
85
+ .getMessagesByTimeRange(sessionId, windowStartAt, windowEndAt)
86
+ .filter((message) => message.role === "user" && message.content.trim());
87
+
88
+ if (userMessages.length < 5) return;
89
+
90
+ const existing = this.db.getTopicByWindow(sessionId, windowStartAt, windowEndAt);
91
+ const messagesBlock = userMessages
92
+ .map((message, index) => {
93
+ const time = this.formatTime(message.timestamp);
94
+ return `[${index + 1}][${time}] ${message.userName || "unknown"}: ${message.content}`;
95
+ })
96
+ .join("\n");
97
+
98
+ try {
99
+ const content = await this.ai.generateText({
100
+ prompt: `You are a concise topic summarization assistant.
101
+
102
+ Only summarize the provided chat log within this exact time window:
103
+ ${this.formatTime(windowStartAt)} ~ ${this.formatTime(windowEndAt)}
104
+
105
+ Rules:
106
+ 1. Use ONLY messages from this log. Do not infer anything outside this 5-hour window.
107
+ 2. The topic must be high-level, abstract, and concise.
108
+ 3. The summary must be brief, not detailed.
109
+ 4. Keep the output in the same language as the chat log.
110
+
111
+ Chat log:
112
+ ${messagesBlock}
113
+
114
+ Output strictly in JSON format:
115
+ {
116
+ "title": "short topic title",
117
+ "keywords": ["keyword1", "keyword2"],
118
+ "summary": "one concise abstract summary sentence"
119
+ }`,
120
+ messages: [],
121
+ model: this.config.workingModel || this.config.model,
122
+ temperature: 0.2,
123
+ max_tokens: 300,
124
+ });
125
+
126
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
127
+ if (!jsonMatch) return;
128
+
129
+ const parsed = JSON.parse(jsonMatch[0]);
130
+ const summary = String(parsed.summary || "").trim();
131
+ if (!summary) return;
132
+
133
+ const keywords = this.normalizeKeywords(parsed.keywords);
134
+ const title =
135
+ String(parsed.title || "").trim() ||
136
+ `${this.formatTime(windowStartAt)} 话题概览`;
137
+
138
+ if (existing?.id) {
139
+ this.db.updateTopic(existing.id, {
140
+ summary,
141
+ keywords: JSON.stringify(keywords),
142
+ messageCount: userMessages.length,
143
+ updatedAt: windowEndAt,
144
+ });
145
+ } else {
146
+ this.db.saveTopic({
147
+ sessionId,
148
+ title,
149
+ keywords: JSON.stringify(keywords),
150
+ summary,
151
+ messageCount: userMessages.length,
152
+ windowStartAt,
153
+ windowEndAt,
154
+ createdAt: windowEndAt,
155
+ updatedAt: windowEndAt,
156
+ });
157
+ }
158
+
159
+ logger.info(
160
+ `[TopicTracker] Session ${sessionId}: summarized window ${this.formatTime(windowStartAt)} ~ ${this.formatTime(windowEndAt)} (${userMessages.length} user messages)`,
161
+ );
162
+ } catch (err) {
163
+ logger.warn(`[TopicTracker] Analysis failed: ${err}`);
164
+ }
165
+ }
166
+
167
+ private getWindowMs(): number {
168
+ const hours = Number(this.config.topic?.windowHours);
169
+ const safeHours = Number.isFinite(hours) && hours > 0 ? hours : 5;
170
+ return Math.max(1, Math.floor(safeHours)) * 3600_000;
171
+ }
172
+
173
+ private getHistoryWindowCount(): number {
174
+ const count = Number(this.config.topic?.historyWindowCount);
175
+ if (!Number.isFinite(count) || count <= 0) return 3;
176
+ return Math.max(1, Math.floor(count));
177
+ }
178
+
179
+ private normalizeKeywords(input: unknown): string[] {
180
+ if (!Array.isArray(input)) return [];
181
+ const cleaned = input
182
+ .map((item) => String(item || "").trim())
183
+ .filter(Boolean);
184
+ return [...new Set(cleaned)].slice(0, 8);
185
+ }
186
+
187
+ private parseKeywords(raw: string): string[] {
188
+ try {
189
+ const parsed = JSON.parse(raw);
190
+ return this.normalizeKeywords(parsed);
191
+ } catch {
192
+ return [];
193
+ }
194
+ }
195
+
196
+ private formatTime(timestamp: number): string {
197
+ const date = new Date(timestamp);
198
+ const year = date.getFullYear();
199
+ const month = String(date.getMonth() + 1).padStart(2, "0");
200
+ const day = String(date.getDate()).padStart(2, "0");
201
+ const hour = String(date.getHours()).padStart(2, "0");
202
+ const minute = String(date.getMinutes()).padStart(2, "0");
203
+ return `${year}-${month}-${day} ${hour}:${minute}`;
204
+ }
205
+ }
@@ -0,0 +1,28 @@
1
+ import type { ChatConfig } from "../types";
2
+
3
+ export function pickReplyStyle(config: ChatConfig): string {
4
+ const { replyStyle } = config;
5
+ if (!replyStyle) return "";
6
+
7
+ const base = replyStyle.baseStyle || "";
8
+ const styles = replyStyle.multipleStyles || [];
9
+ const prob = replyStyle.multipleProbability ?? 0;
10
+
11
+ if (styles.length > 0 && prob > 0 && Math.random() < prob) {
12
+ return styles[Math.floor(Math.random() * styles.length)];
13
+ }
14
+ return base;
15
+ }
16
+
17
+ export function pickPersonalityState(config: ChatConfig): string | null {
18
+ const { personality } = config;
19
+ if (!personality) return null;
20
+
21
+ const states = personality.states || [];
22
+ const prob = personality.stateProbability ?? 0;
23
+
24
+ if (states.length > 0 && prob > 0 && Math.random() < prob) {
25
+ return states[Math.floor(Math.random() * states.length)];
26
+ }
27
+ return null;
28
+ }