mioku-plugin-mc 2.0.0 → 3.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.
Files changed (64) hide show
  1. package/config.md +157 -3
  2. package/index.ts +38 -2
  3. package/package.json +26 -3
  4. package/play/actions/registry.ts +196 -0
  5. package/play/ai/context-builder.ts +29 -0
  6. package/play/ai/debug-log.ts +135 -0
  7. package/play/ai/main-loop.ts +306 -0
  8. package/play/ai/main-tools.ts +230 -0
  9. package/play/ai/prompt.ts +207 -0
  10. package/play/ai/work-subroutine.ts +487 -0
  11. package/play/behavior/base-behavior.ts +82 -0
  12. package/play/behavior/catalog/approach-player.ts +65 -0
  13. package/play/behavior/catalog/defend.ts +68 -0
  14. package/play/behavior/catalog/explore.ts +72 -0
  15. package/play/behavior/catalog/factory.ts +26 -0
  16. package/play/behavior/catalog/farm-mobs.ts +43 -0
  17. package/play/behavior/catalog/follow.ts +80 -0
  18. package/play/behavior/catalog/gather.ts +135 -0
  19. package/play/behavior/catalog/idle.ts +130 -0
  20. package/play/behavior/catalog/seek-shelter.ts +85 -0
  21. package/play/behavior/engine.ts +243 -0
  22. package/play/behavior/survival/auto-eat.ts +46 -0
  23. package/play/behavior/survival/escape-lava.ts +44 -0
  24. package/play/behavior/survival/escape-water.ts +35 -0
  25. package/play/behavior/survival/flee-creeper.ts +76 -0
  26. package/play/behavior/survival/mlg-fall.ts +56 -0
  27. package/play/bot/bot-controller.ts +276 -0
  28. package/play/bot/play-bus.ts +48 -0
  29. package/play/combat/combat.ts +225 -0
  30. package/play/combat/index.ts +1 -0
  31. package/play/config.ts +39 -0
  32. package/play/context.ts +26 -0
  33. package/play/debug/README.md +74 -0
  34. package/play/debug/commands.ts +289 -0
  35. package/play/index.ts +247 -0
  36. package/play/mineflayer-shims.d.ts +22 -0
  37. package/play/missions/bundles/approach-player.ts +26 -0
  38. package/play/missions/bundles/explore.ts +24 -0
  39. package/play/missions/bundles/farm-mobs.ts +24 -0
  40. package/play/missions/bundles/follow-player.ts +40 -0
  41. package/play/missions/bundles/gather-resource.ts +37 -0
  42. package/play/missions/bundles/idle-wander.ts +24 -0
  43. package/play/missions/bundles/seek-shelter.ts +18 -0
  44. package/play/missions/mission-controller.ts +296 -0
  45. package/play/missions/registry.ts +107 -0
  46. package/play/path-engine/astar.ts +514 -0
  47. package/play/path-engine/goals.ts +84 -0
  48. package/play/path-engine/index.ts +4 -0
  49. package/play/path-engine/movements.ts +67 -0
  50. package/play/path-engine/path-engine.ts +540 -0
  51. package/play/runtime.ts +14 -0
  52. package/play/session.ts +486 -0
  53. package/play/state/cooldowns.ts +40 -0
  54. package/play/state/event-journal.ts +72 -0
  55. package/play/state/memory-bus.ts +115 -0
  56. package/play/state/mode.ts +57 -0
  57. package/play/state/sensors/entity-scanner.ts +275 -0
  58. package/play/state/snapshot.ts +360 -0
  59. package/play/types.ts +284 -0
  60. package/play/util/async.ts +11 -0
  61. package/play/util/endpoint.ts +38 -0
  62. package/play/util/entities.ts +135 -0
  63. package/play/util/inventory.ts +75 -0
  64. package/skills.ts +63 -0
package/config.md CHANGED
@@ -75,12 +75,12 @@ fields:
75
75
 
76
76
  - key: command_user
77
77
  label: 命令白名单
78
- type: text
78
+ type: textarea
79
79
  description: 允许执行 RCON 命令的 QQ 号列表,每行一个
80
80
 
81
81
  - key: rcon_command_whitelist
82
82
  label: RCON 命令白名单
83
- type: text
83
+ type: textarea
84
84
  description: 允许执行的 RCON 命令列表,每行一个
85
85
 
86
86
  - key: sync_enabled
@@ -125,6 +125,145 @@ fields:
125
125
  label: RCON 密码
126
126
  type: secret
127
127
  description: RCON 密码
128
+
129
+ - key: play.servers
130
+ label: 游玩服务器列表
131
+ type: array
132
+ description: bot 可加入游玩的 Minecraft 服务器(原版/leaves)
133
+ itemFields:
134
+ - key: id
135
+ label: 服务器 ID
136
+ type: text
137
+ description: 唯一标识,AI 工具按此选择服务器
138
+ placeholder: survival
139
+ - key: name
140
+ label: 显示名称
141
+ type: text
142
+ description: 服务器显示名
143
+ placeholder: 生存服
144
+ - key: host
145
+ label: 地址
146
+ type: text
147
+ description: 服务器地址。可写 host[:port](如 play.example.com:25565),省略端口时会自动尝试 _minecraft._tcp.<host> 的 SRV 解析,失败则默认 25565
148
+ placeholder: play.example.com
149
+ - key: version
150
+ label: 版本
151
+ type: text
152
+ description: Minecraft 版本,留空自动协商
153
+ placeholder: "1.20.4"
154
+ - key: username
155
+ label: 假人名称
156
+ type: text
157
+ description: bot 加入服务器使用的名字
158
+ placeholder: MikuBot
159
+ - key: auth
160
+ label: 认证方式
161
+ type: text
162
+ description: offline 或 microsoft,默认 offline
163
+ placeholder: offline
164
+ - key: password
165
+ label: 认证密码
166
+ type: secret
167
+ description: microsoft 认证时使用
168
+ - key: maxPlayMs
169
+ label: 最长游玩时长(ms)
170
+ type: number
171
+ description: 单次游玩最长时长,到点自动下线
172
+ placeholder: 1800000
173
+
174
+ - key: joinCommands
175
+ label: 加入后自动执行的命令
176
+ type: textarea
177
+ description: bot 第一次进入该服务器后自动发送的命令(每行一条,如 /login xxx),仅在首次 spawn 时执行,重生不重复
178
+
179
+ - key: allowedCommands
180
+ label: AI 命令白名单
181
+ type: textarea
182
+ description: Working AI 可发送的斜杠命令前缀,每行一条;未列出的命令会被拒绝
183
+
184
+ - key: play.groups
185
+ label: 群聊绑定
186
+ type: array
187
+ description: 配置哪些群允许触发 bot 游玩及可选服务器
188
+ itemFields:
189
+ - key: groupId
190
+ label: 群号
191
+ type: text
192
+ description: QQ 群号
193
+ placeholder: "518680610"
194
+ - key: botSelfId
195
+ label: 机器人账号
196
+ type: text
197
+ description: 该群使用的 bot QQ 号
198
+ - key: allowedServerIds
199
+ label: 允许的服务器
200
+ type: textarea
201
+ description: 允许该群触发的服务器 ID,每行一个
202
+
203
+ - key: play.toolPermission
204
+ label: 工具权限
205
+ type: text
206
+ description: 谁可触发 bot 进出服(owner/admin/member,需重启生效)
207
+ placeholder: admin
208
+ - key: play.mainChatDebounceMs
209
+ label: 主 AI 防抖(ms)
210
+ type: number
211
+ description: 同一玩家连续触发主 AI 的最小间隔(仅 @bot/名字时才生效)
212
+ placeholder: 1000
213
+ - key: play.mainConversationFocusMs
214
+ label: 游戏连续对话窗口(ms)
215
+ type: number
216
+ description: 玩家明确呼叫 bot 后,后续消息可继续触发主 AI 的时间窗口
217
+ placeholder: 120000
218
+ - key: play.workTerminationCheckMs
219
+ label: 工作终止检查间隔(ms)
220
+ type: number
221
+ description: session watchdog 检查工作目标终止条件的频率
222
+ placeholder: 1000
223
+ - key: play.workStaleMs
224
+ label: 工作停滞超时(ms)
225
+ type: number
226
+ description: 工作目标在该时长内无进展时,自动拉起 work agent 重新评估
227
+ placeholder: 600000
228
+ - key: play.workGoalTimeoutMs
229
+ label: 工作目标总超时(ms)
230
+ type: number
231
+ description: 单个工作目标最长允许存活的时间,到点自动标记失败
232
+ placeholder: 1800000
233
+ - key: play.maxMissionMs
234
+ label: Mission 最大时长(ms)
235
+ type: number
236
+ description: 单个 mission 最大运行时间,到点强制结束(防 isFinished 缺失或抛错导致永久占用)
237
+ placeholder: 1800000
238
+ - key: play.behaviorTickIntervalMs
239
+ label: 行为引擎 tick(ms)
240
+ type: number
241
+ description: 行为引擎 tick 间隔
242
+ placeholder: 200
243
+ - key: play.maxPlayBudgetWarnRatio
244
+ label: 预算提醒比例
245
+ type: number
246
+ description: 到该比例时提醒主模型收尾
247
+ placeholder: 0.85
248
+ - key: play.goodbyeTimeoutMs
249
+ label: 告别超时(ms)
250
+ type: number
251
+ description: 生成告别语的超时时间
252
+ placeholder: 8000
253
+ - key: play.qqSendPerMinute
254
+ label: QQ 每分钟上限
255
+ type: number
256
+ description: bot 向 QQ 群发送消息的每分钟上限
257
+ placeholder: 3
258
+ - key: play.gameChatMinIntervalMs
259
+ label: 游戏发言间隔(ms)
260
+ type: number
261
+ description: bot 游戏内连续发言最小间隔
262
+ placeholder: 1500
263
+ - key: play.debug.enabled
264
+ label: 调试模式
265
+ type: switch
266
+ description: 开启后主人可用 /join /say /motion 等命令手动测试行为(不启动 AI 循环),详见 play/debug/README.md
128
267
  ---
129
268
 
130
269
  ```mioku-fields
@@ -136,4 +275,19 @@ keys:
136
275
  - base.reverse_ws_path
137
276
  - base.reverse_ws_password
138
277
  - base.servers
139
- ```
278
+ - play.servers
279
+ - play.groups
280
+ - play.toolPermission
281
+ - play.mainChatDebounceMs
282
+ - play.mainConversationFocusMs
283
+ - play.workTerminationCheckMs
284
+ - play.workStaleMs
285
+ - play.workGoalTimeoutMs
286
+ - play.maxMissionMs
287
+ - play.behaviorTickIntervalMs
288
+ - play.maxPlayBudgetWarnRatio
289
+ - play.goodbyeTimeoutMs
290
+ - play.qqSendPerMinute
291
+ - play.gameChatMinIntervalMs
292
+ - play.debug.enabled
293
+ ```
package/index.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { definePlugin, type MiokiContext } from "mioki";
2
- import type { ConfigService } from "mioku";
2
+ import type { AIService, ConfigService } from "mioku";
3
3
  import { createConfigHandler } from "./utils/config-handler";
4
+ import { createPlayConfigHandler } from "./play/config";
5
+ import { createPlayManager } from "./play";
6
+ import { setMcPlayState } from "./play/runtime";
7
+ import { handleDebugCommand } from "./play/debug/commands";
4
8
  import { createServerManager } from "./utils/server-manager";
5
9
  import { parseMcCommand } from "./utils/command-router";
6
10
  import { handleStatus } from "./handlers/status";
@@ -28,6 +32,21 @@ export default definePlugin({
28
32
 
29
33
  const config = configHandler.getConfig();
30
34
 
35
+ const playConfigHandler = createPlayConfigHandler(configService);
36
+ await playConfigHandler.register();
37
+
38
+ const aiService = ctx.services?.ai as AIService | undefined;
39
+
40
+ const playManager = createPlayManager({
41
+ ctx,
42
+ aiService,
43
+ configService,
44
+ playConfigHandler,
45
+ syncConfigHandler: configHandler,
46
+ });
47
+ setMcPlayState({ playManager });
48
+ ctx.logger.info("Minecraft 游玩子系统已就绪");
49
+
31
50
  const serverManager = createServerManager(
32
51
  (serverName, status) => {
33
52
  ctx.logger.info(`[MC] 服务器 ${serverName} 状态: ${status}`);
@@ -44,10 +63,26 @@ export default definePlugin({
44
63
 
45
64
  ctx.handle("message", async (event: any) => {
46
65
  const text = ctx.text(event).trim();
66
+
67
+ if (event.group_id) {
68
+ const reply = await handleDebugCommand({
69
+ text,
70
+ isOwner: ctx.isOwner?.(event) ?? false,
71
+ debugEnabled: playConfigHandler.getConfig().debug.enabled,
72
+ playManager,
73
+ groupId: Number(event.group_id),
74
+ });
75
+ if (reply !== null) {
76
+ await event.reply(reply);
77
+ return;
78
+ }
79
+ }
80
+
47
81
  const parsed = parseMcCommand(text);
48
82
 
49
83
  if (!parsed || parsed.action === "") {
50
84
  if (event.group_id) {
85
+ playManager.onQqMessage(event);
51
86
  await forwardToMc(ctx, event, config, configHandler, serverManager);
52
87
  }
53
88
  return;
@@ -93,8 +128,9 @@ export default definePlugin({
93
128
 
94
129
  ctx.logger.info("Minecraft插件加载成功");
95
130
 
96
- return () => {
131
+ return async () => {
97
132
  serverManager.stopServers();
133
+ await playManager.dispose();
98
134
  ctx.logger.info("Minecraft插件已卸载");
99
135
  };
100
136
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-mc",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Minecraft与QQ群消息互通插件,支持服务器状态监控与RCON命令",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -13,7 +13,26 @@
13
13
  },
14
14
  "mioku": {
15
15
  "services": [
16
- "config"
16
+ "config",
17
+ "ai"
18
+ ],
19
+ "accessHooks": [
20
+ {
21
+ "id": "/mc 状态",
22
+ "match": "/^\\/mc\\s*状态/"
23
+ },
24
+ {
25
+ "id": "/mc 开启同步",
26
+ "match": "/^\\/mc\\s*开启同步/"
27
+ },
28
+ {
29
+ "id": "/mc 关闭同步",
30
+ "match": "/^\\/mc\\s*关闭同步/"
31
+ },
32
+ {
33
+ "id": "/mc 重连",
34
+ "match": "/^\\/mc\\s*重连/"
35
+ }
17
36
  ],
18
37
  "help": {
19
38
  "title": "Minecraft",
@@ -45,7 +64,11 @@
45
64
  }
46
65
  },
47
66
  "dependencies": {
48
- "@cikeyqi/queqiao-node-sdk": "^0.1.3"
67
+ "@cikeyqi/queqiao-node-sdk": "^0.1.3",
68
+ "minecraft-data": "^3.111.0",
69
+ "mineflayer": "^4.37.1",
70
+ "vec3": "^0.2.0",
71
+ "zod": "^3.24.1"
49
72
  },
50
73
  "peerDependencies": {
51
74
  "mioku": "^0.8.0",
@@ -0,0 +1,196 @@
1
+ import { z } from "zod";
2
+ import type { MissionErrorCode } from "../state/mode";
3
+ import type { PlayServerConfig } from "../types";
4
+
5
+ export interface ActionOutcome {
6
+ action: string;
7
+ status: "succeeded" | "failed";
8
+ code?: MissionErrorCode;
9
+ detail: string;
10
+ data?: unknown;
11
+ at: number;
12
+ directiveId?: string;
13
+ completesDirectiveOnSuccess: boolean;
14
+ }
15
+
16
+ export interface ActionContext {
17
+ bot: any;
18
+ server: PlayServerConfig;
19
+ stopCurrentTask: (reason?: string) => void;
20
+ }
21
+
22
+ interface ActionDefinition {
23
+ name: string;
24
+ description: string;
25
+ paramsSchema: z.ZodTypeAny;
26
+ run(
27
+ ctx: ActionContext,
28
+ params: any,
29
+ ): Promise<{ detail: string; data?: unknown }>;
30
+ }
31
+
32
+ export class ActionRegistry {
33
+ private actions = new Map<string, ActionDefinition>();
34
+
35
+ constructor() {
36
+ this.register(defaultDropItemAction);
37
+ this.register(defaultSendCommandAction);
38
+ this.register(defaultStopTaskAction);
39
+ }
40
+
41
+ register(action: ActionDefinition): void {
42
+ this.actions.set(action.name, action);
43
+ }
44
+
45
+ list(): Array<{ name: string; description: string }> {
46
+ return [...this.actions.values()]
47
+ .map((action) => ({ name: action.name, description: action.description }))
48
+ .sort((a, b) => a.name.localeCompare(b.name));
49
+ }
50
+
51
+ async execute(
52
+ name: string,
53
+ params: unknown,
54
+ ctx: ActionContext,
55
+ meta: { directiveId?: string; completesDirectiveOnSuccess?: boolean } = {},
56
+ ): Promise<ActionOutcome> {
57
+ const action = this.actions.get(name);
58
+ if (!action) {
59
+ return failed(name, "unknown", `未知动作: ${name}`, meta);
60
+ }
61
+ const parsed = action.paramsSchema.safeParse(params ?? {});
62
+ if (!parsed.success) {
63
+ return failed(
64
+ name,
65
+ "unknown",
66
+ parsed.error.issues
67
+ .map(
68
+ (issue) => `${issue.path.join(".") || "params"}: ${issue.message}`,
69
+ )
70
+ .join("; "),
71
+ meta,
72
+ );
73
+ }
74
+ try {
75
+ const result = await action.run(ctx, parsed.data);
76
+ return {
77
+ action: name,
78
+ status: "succeeded",
79
+ detail: result.detail,
80
+ data: result.data,
81
+ at: Date.now(),
82
+ directiveId: meta.directiveId,
83
+ completesDirectiveOnSuccess: meta.completesDirectiveOnSuccess ?? true,
84
+ };
85
+ } catch (error) {
86
+ const typed = normalizeActionError(error);
87
+ return failed(name, typed.code, typed.detail, meta);
88
+ }
89
+ }
90
+ }
91
+
92
+ const defaultDropItemAction: ActionDefinition = {
93
+ name: "drop_item",
94
+ description: "丢出指定数量的背包物品,可选先面向附近玩家。",
95
+ paramsSchema: z.object({
96
+ item: z.string().trim().min(1),
97
+ count: z.number().int().min(1).max(64).default(1),
98
+ target: z.string().trim().min(1).optional(),
99
+ }),
100
+ async run(ctx, params) {
101
+ const candidates =
102
+ ctx.bot.inventory
103
+ ?.items?.()
104
+ .filter((item: any) => item.name === params.item) ?? [];
105
+ const available = candidates.reduce(
106
+ (sum: number, item: any) => sum + Number(item.count ?? 0),
107
+ 0,
108
+ );
109
+ if (available < params.count) {
110
+ throw actionError(
111
+ "missing_item",
112
+ `物品 ${params.item} 不足,需要 ${params.count},现有 ${available}`,
113
+ );
114
+ }
115
+ if (params.target) {
116
+ const player = ctx.bot.players?.[params.target]?.entity;
117
+ if (!player?.position)
118
+ throw actionError("target_not_found", `找不到玩家 ${params.target}`);
119
+ await ctx.bot.lookAt(player.position.offset(0, 1, 0), true);
120
+ }
121
+ let remaining = params.count;
122
+ for (const item of candidates) {
123
+ if (remaining <= 0) break;
124
+ const amount = Math.min(remaining, Number(item.count ?? 0));
125
+ await ctx.bot.toss(item.type, item.metadata ?? null, amount);
126
+ remaining -= amount;
127
+ }
128
+ return { detail: `已丢出 ${params.count} 个 ${params.item}`, data: params };
129
+ },
130
+ };
131
+
132
+ const defaultSendCommandAction: ActionDefinition = {
133
+ name: "send_command",
134
+ description: "发送服务器允许列表中的斜杠命令。",
135
+ paramsSchema: z.object({ command: z.string().trim().min(2).max(256) }),
136
+ async run(ctx, params) {
137
+ const command = params.command.startsWith("/")
138
+ ? params.command
139
+ : `/${params.command}`;
140
+ const allowed = ctx.server.allowedCommands.some(
141
+ (entry) => command === entry || command.startsWith(`${entry} `),
142
+ );
143
+ if (!allowed)
144
+ throw actionError("permission_denied", `命令不在允许列表中: ${command}`);
145
+ ctx.bot.chat(command);
146
+ return { detail: `已发送命令 ${command}` };
147
+ },
148
+ };
149
+
150
+ const defaultStopTaskAction: ActionDefinition = {
151
+ name: "stop_current_task",
152
+ description: "停止当前高层任务并回到 idle。",
153
+ paramsSchema: z.object({ reason: z.string().trim().max(256).optional() }),
154
+ async run(ctx, params) {
155
+ ctx.stopCurrentTask(params.reason ?? "work_action_stop");
156
+ return { detail: "已停止当前任务" };
157
+ },
158
+ };
159
+
160
+ function actionError(
161
+ code: MissionErrorCode,
162
+ detail: string,
163
+ ): Error & { code: MissionErrorCode } {
164
+ return Object.assign(new Error(detail), { code });
165
+ }
166
+
167
+ function normalizeActionError(error: unknown): {
168
+ code: MissionErrorCode;
169
+ detail: string;
170
+ } {
171
+ if (error && typeof error === "object" && "code" in error) {
172
+ const typed = error as { code: MissionErrorCode; message?: string };
173
+ return {
174
+ code: typed.code,
175
+ detail: String(typed.message || error),
176
+ };
177
+ }
178
+ return { code: "unknown", detail: String(error) };
179
+ }
180
+
181
+ function failed(
182
+ action: string,
183
+ code: MissionErrorCode,
184
+ detail: string,
185
+ meta: { directiveId?: string; completesDirectiveOnSuccess?: boolean },
186
+ ): ActionOutcome {
187
+ return {
188
+ action,
189
+ status: "failed",
190
+ code,
191
+ detail,
192
+ at: Date.now(),
193
+ directiveId: meta.directiveId,
194
+ completesDirectiveOnSuccess: meta.completesDirectiveOnSuccess ?? true,
195
+ };
196
+ }
@@ -0,0 +1,29 @@
1
+ export function stableStringify(value: unknown): string {
2
+ return JSON.stringify(sortValue(value));
3
+ }
4
+
5
+ function sortValue(value: unknown): unknown {
6
+ if (Array.isArray(value)) return value.map(sortValue);
7
+ if (!value || typeof value !== "object") return value;
8
+ const source = value as Record<string, unknown>;
9
+ const sorted: Record<string, unknown> = {};
10
+ for (const key of Object.keys(source).sort()) {
11
+ const next = source[key];
12
+ if (next !== undefined) sorted[key] = sortValue(next);
13
+ }
14
+ return sorted;
15
+ }
16
+
17
+ export class SectionRevisionTracker {
18
+ private values = new Map<string, string>();
19
+ private revisions = new Map<string, number>();
20
+
21
+ revision(key: string, value: unknown): number {
22
+ const serialized = stableStringify(value);
23
+ if (this.values.get(key) !== serialized) {
24
+ this.values.set(key, serialized);
25
+ this.revisions.set(key, (this.revisions.get(key) ?? 0) + 1);
26
+ }
27
+ return this.revisions.get(key) ?? 0;
28
+ }
29
+ }
@@ -0,0 +1,135 @@
1
+ import type { PlayConfig } from "../types";
2
+
3
+ type LoggerLike = {
4
+ info: (msg: string) => void;
5
+ warn: (msg: string) => void;
6
+ };
7
+
8
+ export interface AiLogRequest {
9
+ messages: Array<{ role: string; content: unknown }>;
10
+ tools?: Array<{ function?: { name?: string } } | undefined>;
11
+ temperature?: number;
12
+ max_tokens?: number;
13
+ trigger?: string;
14
+ triggerEvents?: unknown;
15
+ }
16
+
17
+ export interface AiLogResponse {
18
+ content?: string | null;
19
+ reasoning?: string | null;
20
+ toolCalls?: Array<{ name: string; arguments: string }>;
21
+ durationMs: number;
22
+ error?: unknown;
23
+ }
24
+
25
+ export function logAiRequest(
26
+ logger: LoggerLike,
27
+ config: PlayConfig,
28
+ tag: "main" | "work" | "goodbye",
29
+ payload: AiLogRequest,
30
+ ): void {
31
+ if (!config.debug.enabled) return;
32
+ const toolNames = (payload.tools ?? [])
33
+ .map((t) => t?.function?.name)
34
+ .filter((name): name is string => typeof name === "string")
35
+ .join(", ");
36
+ const lines: string[] = [];
37
+ lines.push(
38
+ `[MC/play] ▶ AI req (${tag})` +
39
+ ` T=${payload.temperature ?? "?"}` +
40
+ ` max=${payload.max_tokens ?? "?"}` +
41
+ (toolNames ? ` tools=[${toolNames}]` : "") +
42
+ (payload.trigger ? ` trigger=${payload.trigger}` : "") +
43
+ (Array.isArray(payload.triggerEvents)
44
+ ? ` events=${payload.triggerEvents.length}`
45
+ : ""),
46
+ );
47
+ for (const message of payload.messages) {
48
+ const role = String(message.role ?? "unknown");
49
+ const text = stringifyContent(message.content);
50
+ lines.push(` ↳ ${role} (${text.length}c): ${truncate(text, 600)}`);
51
+ }
52
+ logger.info(lines.join("\n"));
53
+ }
54
+
55
+ export function logAiResponse(
56
+ logger: LoggerLike,
57
+ config: PlayConfig,
58
+ tag: "main" | "work" | "goodbye",
59
+ payload: AiLogResponse,
60
+ ): void {
61
+ if (!config.debug.enabled) return;
62
+ if (payload.error !== undefined) {
63
+ logger.warn(
64
+ `[MC/play] ◀ AI resp (${tag}) failed in ${payload.durationMs}ms: ${stringifyError(payload.error)}`,
65
+ );
66
+ return;
67
+ }
68
+ const parts: string[] = [];
69
+ parts.push(`[MC/play] ◀ AI resp (${tag}) ${payload.durationMs}ms`);
70
+ const contentText = safeText(payload.content);
71
+ if (contentText && contentText.length > 0) {
72
+ parts.push(`text=${truncate(contentText, 600)}`);
73
+ }
74
+ const reasoningText = safeText(payload.reasoning);
75
+ if (reasoningText && reasoningText.length > 0) {
76
+ parts.push(`reasoning=${truncate(reasoningText, 300)}`);
77
+ }
78
+ if (payload.toolCalls && payload.toolCalls.length > 0) {
79
+ for (const call of payload.toolCalls) {
80
+ const name = safeText(call.name) ?? "(unnamed)";
81
+ const args = safeText(call.arguments) ?? "{}";
82
+ parts.push(`tool=${name}(${truncate(args, 400)})`);
83
+ }
84
+ }
85
+ if (parts.length === 1) parts.push("(empty)");
86
+ logger.info(parts.join(" | "));
87
+ }
88
+
89
+ function stringifyContent(content: unknown): string {
90
+ if (typeof content === "string") return content;
91
+ if (content == null) return "";
92
+ if (Array.isArray(content)) {
93
+ return content
94
+ .map((part) => {
95
+ if (!part || typeof part !== "object") return String(part);
96
+ const obj = part as Record<string, unknown>;
97
+ if (obj.type === "text" && typeof obj.text === "string") return obj.text;
98
+ try {
99
+ return JSON.stringify(part);
100
+ } catch {
101
+ return String(part);
102
+ }
103
+ })
104
+ .join(" ");
105
+ }
106
+ if (typeof content === "object") {
107
+ try {
108
+ return JSON.stringify(content);
109
+ } catch {
110
+ return "[unserializable]";
111
+ }
112
+ }
113
+ return String(content);
114
+ }
115
+
116
+ function safeText(value: unknown): string | null {
117
+ if (typeof value !== "string") return null;
118
+ return value;
119
+ }
120
+
121
+ function truncate(text: unknown, max: number): string {
122
+ const raw = typeof text === "string" ? text : safeText(text) ?? "";
123
+ const collapsed = raw.replace(/\s+/g, " ").trim();
124
+ return collapsed.length > max ? collapsed.slice(0, max) + "…" : collapsed;
125
+ }
126
+
127
+ function stringifyError(error: unknown): string {
128
+ if (error instanceof Error) return `${error.name}: ${error.message}`;
129
+ if (typeof error === "string") return error;
130
+ try {
131
+ return JSON.stringify(error);
132
+ } catch {
133
+ return String(error);
134
+ }
135
+ }