@hmanlab/mmx 0.4.3
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/opencode/plugin/mmx-tools.ts +362 -0
- package/opencode/skill/mmx/SKILL.md +64 -0
- package/package.json +42 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// mmx-tools.ts
|
|
2
|
+
//
|
|
3
|
+
// OpenCode plugin that wraps the official MiniMax mmx-cli so the LLM can
|
|
4
|
+
// generate images, video, music, speech, run web search, describe images,
|
|
5
|
+
// and check Token Plan quota — all from inside an OpenCode session without
|
|
6
|
+
// leaving the chat.
|
|
7
|
+
//
|
|
8
|
+
// Requires once on the machine:
|
|
9
|
+
// npm install -g mmx-cli
|
|
10
|
+
// mmx auth login --api-key sk-xxxxx
|
|
11
|
+
// After that, no key is needed in opencode.
|
|
12
|
+
//
|
|
13
|
+
// All generated files default to ~/Desktop/mmx-output/. The user can override
|
|
14
|
+
// the default directory permanently via the MMX_OUTPUT_DIR env var; the LLM
|
|
15
|
+
// should never pass out_dir / out_path unless the user explicitly asked.
|
|
16
|
+
|
|
17
|
+
import { tool } from "@opencode-ai/plugin"
|
|
18
|
+
import { homedir } from "node:os"
|
|
19
|
+
import { basename, dirname, join, resolve, isAbsolute } from "node:path"
|
|
20
|
+
import { mkdirSync, existsSync } from "node:fs"
|
|
21
|
+
|
|
22
|
+
const DEFAULT_OUT_DIR = join(homedir(), "Desktop", "mmx-output")
|
|
23
|
+
|
|
24
|
+
function resolveOutDir(outDir: string | undefined, worktree: string): string {
|
|
25
|
+
const target = outDir ?? process.env.MMX_OUTPUT_DIR ?? DEFAULT_OUT_DIR
|
|
26
|
+
return isAbsolute(target) ? target : resolve(worktree, target)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isSuspiciousOutDir(absPath: string): boolean {
|
|
30
|
+
const home = homedir()
|
|
31
|
+
const suspects = [home, join(home, "Desktop"), "/tmp", "/private/tmp", "."]
|
|
32
|
+
return absPath === "" || suspects.includes(absPath)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function warnSuspiciousOutDir(originalArg: string, usedInstead: string): string {
|
|
36
|
+
return `Note: out_dir/out_path "${originalArg}" looks like a mistake (home directory, Desktop root, /tmp, or cwd). Saving to "${usedInstead}" instead. Pass an explicit subdirectory to override.`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveFilePath(
|
|
40
|
+
argsOutPath: string | undefined,
|
|
41
|
+
worktree: string,
|
|
42
|
+
defaultFileName: string,
|
|
43
|
+
): { filePath: string; wasSuspicious: boolean; originalArg: string | undefined } {
|
|
44
|
+
const envDir = process.env.MMX_OUTPUT_DIR ?? DEFAULT_OUT_DIR
|
|
45
|
+
const requested = argsOutPath ?? join(envDir, defaultFileName)
|
|
46
|
+
const requestedDirAbs = isAbsolute(requested) ? dirname(requested) : resolve(worktree, dirname(requested))
|
|
47
|
+
if (isSuspiciousOutDir(requestedDirAbs)) {
|
|
48
|
+
return {
|
|
49
|
+
filePath: join(DEFAULT_OUT_DIR, basename(requested)),
|
|
50
|
+
wasSuspicious: true,
|
|
51
|
+
originalArg: argsOutPath,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
filePath: isAbsolute(requested) ? requested : join(requestedDirAbs, basename(requested)),
|
|
56
|
+
wasSuspicious: false,
|
|
57
|
+
originalArg: argsOutPath,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ensureDir(dir: string): void {
|
|
62
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function runMmx(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
66
|
+
const proc = Bun.spawn(["mmx", ...args], { stdout: "pipe", stderr: "pipe" })
|
|
67
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
68
|
+
new Response(proc.stdout).text(),
|
|
69
|
+
new Response(proc.stderr).text(),
|
|
70
|
+
proc.exited,
|
|
71
|
+
])
|
|
72
|
+
return { stdout, stderr, exitCode }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default async () => {
|
|
76
|
+
return {
|
|
77
|
+
tool: {
|
|
78
|
+
// ──────────────────────────────────────────────────────────────────
|
|
79
|
+
// IMAGE GENERATION (primary tool)
|
|
80
|
+
// ──────────────────────────────────────────────────────────────────
|
|
81
|
+
mmx_image: tool({
|
|
82
|
+
description:
|
|
83
|
+
"Generate one or more images from a text prompt using MiniMax's image-01 model via mmx-cli. Use this whenever the user asks for an image, illustration, logo, artwork, photo, or any visual asset. Aspect ratios: 1:1, 16:9, 9:16, 4:3, 3:4, 21:9. Pass seed for reproducible results. Returns the saved file path.",
|
|
84
|
+
args: {
|
|
85
|
+
prompt: tool.schema
|
|
86
|
+
.string()
|
|
87
|
+
.describe(
|
|
88
|
+
"Detailed text description of the image. Be specific about subject, style, lighting, composition, mood.",
|
|
89
|
+
),
|
|
90
|
+
aspect_ratio: tool.schema
|
|
91
|
+
.string()
|
|
92
|
+
.optional()
|
|
93
|
+
.describe("Aspect ratio. One of: 1:1, 16:9, 9:16, 4:3, 3:4, 21:9. Default 1:1."),
|
|
94
|
+
n: tool.schema.number().optional().describe("Number of images to generate. Default 1, max 4."),
|
|
95
|
+
seed: tool.schema.number().optional().describe("Random seed for reproducible generation."),
|
|
96
|
+
out_dir: tool.schema
|
|
97
|
+
.string()
|
|
98
|
+
.optional()
|
|
99
|
+
.describe(
|
|
100
|
+
"Override save directory. Leave unset unless the user explicitly asked for a different save location in this conversation. Default: ~/Desktop/mmx-output/ (or $MMX_OUTPUT_DIR if set). Suspicious paths fall back to the default with a warning.",
|
|
101
|
+
),
|
|
102
|
+
optimize_prompt: tool.schema
|
|
103
|
+
.boolean()
|
|
104
|
+
.optional()
|
|
105
|
+
.describe("Auto-optimize the prompt for better quality."),
|
|
106
|
+
filename_prefix: tool.schema
|
|
107
|
+
.string()
|
|
108
|
+
.optional()
|
|
109
|
+
.describe(
|
|
110
|
+
"Filename prefix. Defaults to a unique per-call value (image-<timestamp>) so back-to-back calls don't overwrite each other. Pass an explicit value for predictable sequential naming.",
|
|
111
|
+
),
|
|
112
|
+
},
|
|
113
|
+
async execute(args, ctx) {
|
|
114
|
+
const requestedDir = resolveOutDir(args.out_dir, ctx.worktree)
|
|
115
|
+
const outDir = isSuspiciousOutDir(requestedDir) ? DEFAULT_OUT_DIR : requestedDir
|
|
116
|
+
ensureDir(outDir)
|
|
117
|
+
const filenamePrefix = args.filename_prefix ?? `image-${Date.now()}`
|
|
118
|
+
const cliArgs = ["image", "generate", "--prompt", args.prompt]
|
|
119
|
+
if (args.aspect_ratio) cliArgs.push("--aspect-ratio", args.aspect_ratio)
|
|
120
|
+
if (args.n) cliArgs.push("--n", String(args.n))
|
|
121
|
+
if (args.seed != null) cliArgs.push("--seed", String(args.seed))
|
|
122
|
+
if (args.optimize_prompt) cliArgs.push("--prompt-optimizer")
|
|
123
|
+
cliArgs.push("--out-dir", outDir, "--out-prefix", filenamePrefix, "--non-interactive")
|
|
124
|
+
const { stdout, stderr, exitCode } = await runMmx(cliArgs)
|
|
125
|
+
if (exitCode !== 0) {
|
|
126
|
+
return `mmx image generate failed (exit ${exitCode}):\n${stderr || stdout || "(no output)"}`
|
|
127
|
+
}
|
|
128
|
+
let msg = `Image generation complete.\n\n${stdout.trim()}\n\nSaved to: ${outDir}\nFilename prefix: ${filenamePrefix}`
|
|
129
|
+
if (outDir !== requestedDir && args.out_dir) {
|
|
130
|
+
msg += `\n\n${warnSuspiciousOutDir(args.out_dir, outDir)}`
|
|
131
|
+
}
|
|
132
|
+
return msg
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
|
|
136
|
+
// ──────────────────────────────────────────────────────────────────
|
|
137
|
+
// SPEECH SYNTHESIS
|
|
138
|
+
// ──────────────────────────────────────────────────────────────────
|
|
139
|
+
mmx_speech: tool({
|
|
140
|
+
description:
|
|
141
|
+
"Synthesize speech from text using MiniMax's speech-2.8-hd model. Use when the user wants a voiceover, narration, audio file, TTS, or to read text aloud. Saves an MP3 and returns the path. 40+ languages. Default voice: English_expressive_narrator.",
|
|
142
|
+
args: {
|
|
143
|
+
text: tool.schema.string().describe("The text to speak. Up to 10,000 characters."),
|
|
144
|
+
voice: tool.schema.string().optional().describe("Voice ID. Default English_expressive_narrator."),
|
|
145
|
+
speed: tool.schema.number().optional().describe("Speech speed multiplier. Default 1.0."),
|
|
146
|
+
out_path: tool.schema
|
|
147
|
+
.string()
|
|
148
|
+
.optional()
|
|
149
|
+
.describe(
|
|
150
|
+
"Override output .mp3 path. Leave unset unless the user explicitly asked for a different save location in this conversation. Default: ~/Desktop/mmx-output/speech-<timestamp>.mp3 (or $MMX_OUTPUT_DIR if set). Suspicious parent directories fall back to the default with a warning.",
|
|
151
|
+
),
|
|
152
|
+
},
|
|
153
|
+
async execute(args, ctx) {
|
|
154
|
+
const {
|
|
155
|
+
filePath: outPath,
|
|
156
|
+
wasSuspicious,
|
|
157
|
+
originalArg,
|
|
158
|
+
} = resolveFilePath(args.out_path, ctx.worktree, `speech-${Date.now()}.mp3`)
|
|
159
|
+
ensureDir(dirname(outPath))
|
|
160
|
+
const cliArgs = ["speech", "synthesize", "--text", args.text]
|
|
161
|
+
if (args.voice) cliArgs.push("--voice", args.voice)
|
|
162
|
+
if (args.speed != null) cliArgs.push("--speed", String(args.speed))
|
|
163
|
+
cliArgs.push("--out", outPath, "--non-interactive")
|
|
164
|
+
const { stdout, stderr, exitCode } = await runMmx(cliArgs)
|
|
165
|
+
if (exitCode !== 0) {
|
|
166
|
+
return `mmx speech synthesize failed (exit ${exitCode}):\n${stderr || stdout || "(no output)"}`
|
|
167
|
+
}
|
|
168
|
+
let msg = `Speech synthesized.\n\nSaved to: ${outPath}`
|
|
169
|
+
if (wasSuspicious && originalArg) {
|
|
170
|
+
msg += `\n\n${warnSuspiciousOutDir(originalArg, outPath)}`
|
|
171
|
+
}
|
|
172
|
+
return msg
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
|
|
176
|
+
// ──────────────────────────────────────────────────────────────────
|
|
177
|
+
// VIDEO GENERATION
|
|
178
|
+
// ──────────────────────────────────────────────────────────────────
|
|
179
|
+
mmx_video: tool({
|
|
180
|
+
description:
|
|
181
|
+
"Generate a short video from a text prompt using MiniMax's Hailuo-2.3 model. Use when the user wants a video clip, animation, or motion graphic. Generation can take 1-3 minutes. Returns the output MP4 file path.",
|
|
182
|
+
args: {
|
|
183
|
+
prompt: tool.schema
|
|
184
|
+
.string()
|
|
185
|
+
.describe("Detailed description of the video scene, including camera movement and action."),
|
|
186
|
+
model: tool.schema
|
|
187
|
+
.string()
|
|
188
|
+
.optional()
|
|
189
|
+
.describe(
|
|
190
|
+
"Model ID. Default MiniMax-Hailuo-2.3. Use MiniMax-Hailuo-2.3-Fast for quicker lower-quality results.",
|
|
191
|
+
),
|
|
192
|
+
out_path: tool.schema
|
|
193
|
+
.string()
|
|
194
|
+
.optional()
|
|
195
|
+
.describe(
|
|
196
|
+
"Override output .mp4 path. Leave unset unless the user explicitly asked for a different save location in this conversation. Default: ~/Desktop/mmx-output/video-<timestamp>.mp4 (or $MMX_OUTPUT_DIR if set). Suspicious parent directories fall back to the default with a warning.",
|
|
197
|
+
),
|
|
198
|
+
},
|
|
199
|
+
async execute(args, ctx) {
|
|
200
|
+
const {
|
|
201
|
+
filePath: outPath,
|
|
202
|
+
wasSuspicious,
|
|
203
|
+
originalArg,
|
|
204
|
+
} = resolveFilePath(args.out_path, ctx.worktree, `video-${Date.now()}.mp4`)
|
|
205
|
+
ensureDir(dirname(outPath))
|
|
206
|
+
const cliArgs = ["video", "generate", "--prompt", args.prompt]
|
|
207
|
+
if (args.model) cliArgs.push("--model", args.model)
|
|
208
|
+
cliArgs.push("--download", outPath, "--non-interactive")
|
|
209
|
+
const { stdout, stderr, exitCode } = await runMmx(cliArgs)
|
|
210
|
+
if (exitCode !== 0) {
|
|
211
|
+
return `mmx video generate failed (exit ${exitCode}):\n${stderr || stdout || "(no output)"}`
|
|
212
|
+
}
|
|
213
|
+
let msg = `Video generation complete.\n\nSaved to: ${outPath}`
|
|
214
|
+
if (wasSuspicious && originalArg) {
|
|
215
|
+
msg += `\n\n${warnSuspiciousOutDir(originalArg, outPath)}`
|
|
216
|
+
}
|
|
217
|
+
return msg
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
|
|
221
|
+
// ──────────────────────────────────────────────────────────────────
|
|
222
|
+
// MUSIC GENERATION
|
|
223
|
+
// ──────────────────────────────────────────────────────────────────
|
|
224
|
+
mmx_music: tool({
|
|
225
|
+
description:
|
|
226
|
+
"Generate a song or instrumental music from a style prompt using MiniMax's music-2.6 model. Use when the user wants background music, a theme song, a jingle, or instrumental music. Either supply lyrics or set instrumental=true.",
|
|
227
|
+
args: {
|
|
228
|
+
prompt: tool.schema
|
|
229
|
+
.string()
|
|
230
|
+
.describe(
|
|
231
|
+
"Style description: genre, mood, instruments, tempo. E.g. 'cinematic orchestral, building tension'.",
|
|
232
|
+
),
|
|
233
|
+
lyrics: tool.schema
|
|
234
|
+
.string()
|
|
235
|
+
.optional()
|
|
236
|
+
.describe("Song lyrics with structure tags like [Verse], [Chorus]. Omit for instrumental."),
|
|
237
|
+
instrumental: tool.schema
|
|
238
|
+
.boolean()
|
|
239
|
+
.optional()
|
|
240
|
+
.describe("If true, generate instrumental music with no vocals."),
|
|
241
|
+
vocals: tool.schema.string().optional().describe("Vocal style hint, e.g. 'warm male baritone'."),
|
|
242
|
+
bpm: tool.schema.number().optional().describe("Exact tempo in BPM."),
|
|
243
|
+
out_path: tool.schema
|
|
244
|
+
.string()
|
|
245
|
+
.optional()
|
|
246
|
+
.describe(
|
|
247
|
+
"Override output .mp3 path. Leave unset unless the user explicitly asked for a different save location in this conversation. Default: ~/Desktop/mmx-output/music-<timestamp>.mp3 (or $MMX_OUTPUT_DIR if set). Suspicious parent directories fall back to the default with a warning.",
|
|
248
|
+
),
|
|
249
|
+
},
|
|
250
|
+
async execute(args, ctx) {
|
|
251
|
+
const {
|
|
252
|
+
filePath: outPath,
|
|
253
|
+
wasSuspicious,
|
|
254
|
+
originalArg,
|
|
255
|
+
} = resolveFilePath(args.out_path, ctx.worktree, `music-${Date.now()}.mp3`)
|
|
256
|
+
ensureDir(dirname(outPath))
|
|
257
|
+
const cliArgs = ["music", "generate", "--prompt", args.prompt]
|
|
258
|
+
if (args.lyrics) cliArgs.push("--lyrics", args.lyrics)
|
|
259
|
+
if (args.instrumental) cliArgs.push("--instrumental")
|
|
260
|
+
if (args.vocals) cliArgs.push("--vocals", args.vocals)
|
|
261
|
+
if (args.bpm != null) cliArgs.push("--bpm", String(args.bpm))
|
|
262
|
+
cliArgs.push("--out", outPath, "--non-interactive")
|
|
263
|
+
const { stdout, stderr, exitCode } = await runMmx(cliArgs)
|
|
264
|
+
if (exitCode !== 0) {
|
|
265
|
+
return `mmx music generate failed (exit ${exitCode}):\n${stderr || stdout || "(no output)"}`
|
|
266
|
+
}
|
|
267
|
+
let msg = `Music generation complete.\n\nSaved to: ${outPath}`
|
|
268
|
+
if (wasSuspicious && originalArg) {
|
|
269
|
+
msg += `\n\n${warnSuspiciousOutDir(originalArg, outPath)}`
|
|
270
|
+
}
|
|
271
|
+
return msg
|
|
272
|
+
},
|
|
273
|
+
}),
|
|
274
|
+
|
|
275
|
+
// ──────────────────────────────────────────────────────────────────
|
|
276
|
+
// WEB SEARCH
|
|
277
|
+
// ──────────────────────────────────────────────────────────────────
|
|
278
|
+
mmx_search: tool({
|
|
279
|
+
description:
|
|
280
|
+
"Search the web using MiniMax's search API. Use when the user wants current information, news, facts, or anything time-sensitive. Returns a textual summary of search results.",
|
|
281
|
+
args: {
|
|
282
|
+
query: tool.schema.string().describe("The search query."),
|
|
283
|
+
},
|
|
284
|
+
async execute(args, ctx) {
|
|
285
|
+
const { stdout, stderr, exitCode } = await runMmx([
|
|
286
|
+
"search",
|
|
287
|
+
"query",
|
|
288
|
+
"--q",
|
|
289
|
+
args.query,
|
|
290
|
+
"--output",
|
|
291
|
+
"json",
|
|
292
|
+
"--non-interactive",
|
|
293
|
+
])
|
|
294
|
+
if (exitCode !== 0) {
|
|
295
|
+
return `mmx search failed (exit ${exitCode}):\n${stderr || stdout || "(no output)"}`
|
|
296
|
+
}
|
|
297
|
+
return stdout.trim() || "(no results)"
|
|
298
|
+
},
|
|
299
|
+
}),
|
|
300
|
+
|
|
301
|
+
// ──────────────────────────────────────────────────────────────────
|
|
302
|
+
// VISION (image understanding)
|
|
303
|
+
// ──────────────────────────────────────────────────────────────────
|
|
304
|
+
mmx_vision: tool({
|
|
305
|
+
description:
|
|
306
|
+
"Describe or analyze an image using MiniMax's vision model. Pass a local file path or URL. Returns a textual description. Useful when the user uploads an image and wants analysis, OCR, or a description.",
|
|
307
|
+
args: {
|
|
308
|
+
image: tool.schema.string().describe("Local file path or URL of the image."),
|
|
309
|
+
prompt: tool.schema
|
|
310
|
+
.string()
|
|
311
|
+
.optional()
|
|
312
|
+
.describe("Custom question about the image. Default 'Describe the image.'"),
|
|
313
|
+
},
|
|
314
|
+
async execute(args, ctx) {
|
|
315
|
+
const cliArgs = ["vision", "describe", "--image", args.image]
|
|
316
|
+
if (args.prompt) cliArgs.push("--prompt", args.prompt)
|
|
317
|
+
cliArgs.push("--non-interactive")
|
|
318
|
+
const { stdout, stderr, exitCode } = await runMmx(cliArgs)
|
|
319
|
+
if (exitCode !== 0) {
|
|
320
|
+
return `mmx vision describe failed (exit ${exitCode}):\n${stderr || stdout || "(no output)"}`
|
|
321
|
+
}
|
|
322
|
+
return stdout.trim() || "(no description)"
|
|
323
|
+
},
|
|
324
|
+
}),
|
|
325
|
+
|
|
326
|
+
// ──────────────────────────────────────────────────────────────────
|
|
327
|
+
// QUOTA CHECK
|
|
328
|
+
// ──────────────────────────────────────────────────────────────────
|
|
329
|
+
mmx_quota: tool({
|
|
330
|
+
description:
|
|
331
|
+
"Show current Token Plan usage and remaining quota (5-hour rolling and weekly windows). Use when the user asks about quota, usage, limits, or how many calls they have left.",
|
|
332
|
+
args: {},
|
|
333
|
+
async execute(_args, ctx) {
|
|
334
|
+
let { stdout, stderr, exitCode } = await runMmx(["quota"])
|
|
335
|
+
if (exitCode !== 0) {
|
|
336
|
+
const fallback = await runMmx(["quota", "show", "--non-interactive"])
|
|
337
|
+
if (fallback.exitCode === 0) {
|
|
338
|
+
stdout = fallback.stdout
|
|
339
|
+
stderr = fallback.stderr
|
|
340
|
+
exitCode = fallback.exitCode
|
|
341
|
+
} else {
|
|
342
|
+
return `mmx quota failed (exit ${exitCode}):\n${stderr || "(no stderr)"}`
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const raw = stdout.trim()
|
|
346
|
+
let data: any
|
|
347
|
+
try {
|
|
348
|
+
data = JSON.parse(raw)
|
|
349
|
+
} catch {
|
|
350
|
+
return raw || "(no quota info)"
|
|
351
|
+
}
|
|
352
|
+
const rows = (data.model_remains ?? []).map((m: any) => {
|
|
353
|
+
const reset = new Date(m.end_time).toUTCString().replace(/^[^,]+,\s*/, "")
|
|
354
|
+
return `| ${m.model_name} | ${m.current_interval_remaining_percent}% left | ${m.current_weekly_remaining_percent}% left | resets ${reset} |`
|
|
355
|
+
})
|
|
356
|
+
const header = "| Model | 5h window | Weekly | Next reset |\n|---|---|---|---|"
|
|
357
|
+
return `Quota — Token Plan\n\n${header}\n${rows.join("\n")}\n`
|
|
358
|
+
},
|
|
359
|
+
}),
|
|
360
|
+
},
|
|
361
|
+
}
|
|
362
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mmx
|
|
3
|
+
description: Use when the user wants to generate images, video, music, speech, run web search, analyze images, or check Token Plan quota. Wraps the official mmx-cli so multimodal MiniMax capabilities can be invoked directly from inside an OpenCode session without leaving the chat. Front-load keywords: image, picture, illustration, logo, artwork, generate, mmx, MiniMax, video, music, song, voiceover, TTS, speech, search, vision, quota, usage.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# mmx — MiniMax multimodal tools
|
|
7
|
+
|
|
8
|
+
The `mmx-tools` plugin exposes seven tools that wrap the official MiniMax CLI:
|
|
9
|
+
|
|
10
|
+
| Tool | What it does |
|
|
11
|
+
| ----------------- | ---------------------------------------------------- |
|
|
12
|
+
| `mmx_image` | Text → image (image-01 model) |
|
|
13
|
+
| `mmx_speech` | Text → MP3 voiceover (speech-2.8-hd) |
|
|
14
|
+
| `mmx_video` | Text → MP4 short video (Hailuo-2.3) |
|
|
15
|
+
| `mmx_music` | Text → song / instrumental (music-2.6) |
|
|
16
|
+
| `mmx_search` | Web search via MiniMax search API |
|
|
17
|
+
| `mmx_vision` | Image → text description / OCR / Q&A |
|
|
18
|
+
| `mmx_quota` | Show Token Plan usage and remaining quota |
|
|
19
|
+
|
|
20
|
+
## Setup (one-time, on the machine)
|
|
21
|
+
|
|
22
|
+
1. Install the CLI: `npm install -g mmx-cli`
|
|
23
|
+
2. Authenticate with the user's API key: `mmx auth login --api-key sk-xxxxx`
|
|
24
|
+
The key is stored locally — never paste it into chat.
|
|
25
|
+
3. If calls return 401, the region auto-detect failed. Set it manually:
|
|
26
|
+
- Overseas: `mmx config set --key region --value global`
|
|
27
|
+
- Mainland China: `mmx config set --key region --value cn`
|
|
28
|
+
4. Confirm with: `mmx quota` and `mmx auth status`
|
|
29
|
+
|
|
30
|
+
## Output location
|
|
31
|
+
|
|
32
|
+
All generated files save to `~/Desktop/mmx-output/` by default — regardless of which repo or directory OpenCode was launched from. This is intentional; do not override it on your own.
|
|
33
|
+
|
|
34
|
+
**Never pass `out_dir` / `out_path` unless the user explicitly asked for a different save location in this conversation.** Passing these args based on your own inference (e.g. "save to the current repo") is wrong and will be silently corrected to the default with a warning in the tool output.
|
|
35
|
+
|
|
36
|
+
Two ways the user can legitimately override:
|
|
37
|
+
|
|
38
|
+
1. **Per-call:** tell the agent explicitly in chat ("save this to my Pictures folder"), and the agent will pass the right `out_dir` / `out_path`.
|
|
39
|
+
2. **Permanent:** set `MMX_OUTPUT_DIR=/some/path` in the user's shell rc (`~/.zshrc`, `~/.bashrc`) before launching OpenCode. This changes the default for all mmx tools.
|
|
40
|
+
|
|
41
|
+
Suspicious paths (`$HOME`, `~/Desktop`, `/tmp`, `.`) passed via `out_dir` / `out_path` are always rejected and fall back to `~/Desktop/mmx-output/` — even when the user explicitly asks for them. (Users wanting those locations should use `MMX_OUTPUT_DIR` for the legitimate case.)
|
|
42
|
+
|
|
43
|
+
`mmx_image` defaults to a unique filename prefix per call (`image-<timestamp>`) so back-to-back calls don't overwrite each other. Pass an explicit `filename_prefix` when you want predictable sequential naming (e.g. `logo_001.jpg`, `logo_002.jpg`).
|
|
44
|
+
|
|
45
|
+
## Common patterns
|
|
46
|
+
|
|
47
|
+
- **Cover image for a slide / social post** — call `mmx_image` with a detailed style prompt and `aspect_ratio: "16:9"` or `"1:1"`.
|
|
48
|
+
- **Voiceover for a video** — `mmx_speech` with the script as `text`. Override voice ID if the default English narrator doesn't fit.
|
|
49
|
+
- **Background music** — `mmx_music` with `instrumental: true` and a style prompt like "calm ambient pad, lo-fi beat, 70 bpm".
|
|
50
|
+
- **Verify what's left in the quota** — `mmx_quota` before starting a long batch.
|
|
51
|
+
- **Describe a screenshot the user dropped in** — `mmx_vision` with the local path.
|
|
52
|
+
|
|
53
|
+
## Tips
|
|
54
|
+
|
|
55
|
+
- Image prompts should be **specific and detailed**: subject, style, lighting, composition, mood. Vague prompts produce vague results.
|
|
56
|
+
- For reproducible images, pass `seed`. Same prompt + same seed = same image.
|
|
57
|
+
- Video and music calls block for 1–3 minutes — call them only when the user actually wants the output.
|
|
58
|
+
- If `mmx_image` returns a failed exit code, surface the stderr verbatim — that's where mmx-cli writes quota errors, region errors, and validation issues.
|
|
59
|
+
|
|
60
|
+
## When NOT to use these tools
|
|
61
|
+
|
|
62
|
+
- For diagrams, charts, tables, or anything textual — render with HTML/JSX instead.
|
|
63
|
+
- For tiny UI icons — too heavy; use SVG.
|
|
64
|
+
- If the user is on the free tier or hasn't authenticated, point them at the setup section above before retrying.
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hmanlab/mmx",
|
|
3
|
+
"version": "0.4.3",
|
|
4
|
+
"description": "Multimodal generation via MiniMax (image, video, music, speech, search, vision, quota).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"opencode"
|
|
8
|
+
],
|
|
9
|
+
"hl-plugins": {
|
|
10
|
+
"opencodePlugin": "./opencode/plugin/mmx-tools.ts",
|
|
11
|
+
"opencodeSkill": "./opencode/skill/mmx/SKILL.md",
|
|
12
|
+
"defaultInstall": true,
|
|
13
|
+
"permission": "mmx *",
|
|
14
|
+
"requires": [
|
|
15
|
+
{
|
|
16
|
+
"name": "mmx-cli",
|
|
17
|
+
"type": "binary",
|
|
18
|
+
"check": "mmx --version",
|
|
19
|
+
"install": "npm install -g mmx-cli",
|
|
20
|
+
"update": "npm update -g mmx-cli"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"auth": {
|
|
24
|
+
"check": "mmx auth status",
|
|
25
|
+
"login": {
|
|
26
|
+
"cmd": "mmx auth login",
|
|
27
|
+
"args": [
|
|
28
|
+
"--api-key",
|
|
29
|
+
{
|
|
30
|
+
"var": "key"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"verify": "mmx quota",
|
|
35
|
+
"keyLabel": "MiniMax API key",
|
|
36
|
+
"envVar": "MMX_API_KEY"
|
|
37
|
+
},
|
|
38
|
+
"postInstall": [
|
|
39
|
+
"mmx quota"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
}
|