Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"name": "minimax-bot",
"displayName": "MiniMax Bot",
"version": "0.7.13",
"version": "0.7.14",
"description": "Turn MiniMax Code into an always-on team of Bots. A Chief Bot helps you build the team; in group chats the admin answers and @mentions hand work to other members. Every Bot has its own octopus avatar and memory, with scheduled tasks and usage stats.",
"author": "MiniMax Code",
"icon": "icon.png",
Expand Down
9 changes: 8 additions & 1 deletion plugins/minimax-ai/minimax-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the
- **Reply actions**: the reaction, copy, quote-reply and open-session buttons sit at the bottom-left of a reply, under its last line. A long reply therefore keeps its actions within reach at the end of the text instead of pushing them off the top of the message. The reaction indicator sits directly above that row and is painted with the app's own tag colours, so it follows the light and dark themes.
- **Working messages of one reply**: the Host records a text part for every turn a Bot takes between two tool calls, so a reply that worked for a while arrives with many parts, and the page draws one bubble per part. The bubbles keep the official shape and order, and a small gap between them tells them apart instead of letting them read as one block of text.

## 0.7.14 Text that rides into the next Bot's prompt

Text that travels from one Bot into another Bot's prompt is cleaned on the way. A reply in a group, a name, a role, a notice, an instruction, a memory entry, the report of a finished dependency and the brief of a background job are all stripped of the marks they may not forge. Two vocabularies are stripped: the protocol marks of the plugin itself, and the block tags the Host writes into its own prompts (`<system-reminder>`, `<agent-context>` and the rest of that vocabulary) — a Bot that copies one of them into its answer would otherwise hand the next Bot a text that reads like a notice the Host has just sent. The report a finished dependency brings back is cleaned the same way, so a child Bot cannot write a block of its own into the prompt of the Bot that has to check its work.

Only the tag goes — the words around it stay — and the plugin's own delivery format (`<media src="…" />`) is not touched, so a report that names a file still reaches the Bot that has to hand it over.

## 0.7.10 Optional group notes

- A group can keep a note of what it said. After every round the group records the closing lines of the replies, and every Bot of the group gets that record in its standing brief.
Expand All @@ -42,7 +48,7 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the
## 0.7.12 The group note, again, and background jobs that count

- A recorded line keeps its Chinese text. Cutting a line to its closing part used to trim up to the first space, and Chinese has no spaces, so a Chinese entry could lose almost all of itself. The trim now only drops a half Latin word, and never more than 20 characters.
- The protocol marks are matched in any spelling: a tag with spaces, with an attribute, in another case, or with a dash that looks like a hyphen is removed as well. The text around it is left alone, and Chinese punctuation is not rewritten.
- The protocol marks are matched in any spelling: a tag with spaces, with an attribute of any length, in another case, with a dash that looks like a hyphen, or with a character that takes no space inside it is removed as well. An attribute is read as one — the characters a tag attribute is made of — so a mark's name written inside a sentence does not take the rest of the sentence with it. The text around it is left alone, and Chinese punctuation is not rewritten.
- The group settings state the limits the plugin actually uses. The panel reads them from the server instead of writing down numbers of its own.
- A background job is an Agent turn like any other, so the usage panel counts it. Before, the panel showed fewer turns than the model actually ran: the job's own turn never reached the ledger, only the turn that relayed its report back.
- The job's row carries the time it took, the tools it called and the reply it produced, and it says how the job ended: a job that failed is an error, one that was stopped or cut off is aborted.
Expand All @@ -51,6 +57,7 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the

- A mention works right after a Chinese word. The character in front of the `@` used to end the mention whenever it was a letter of any script, and Chinese has no spaces between words, so `请@某人` and `好的@某人` reached nobody. Only an ASCII letter, digit or underscore in front of the `@` ends it now, which is what the local part of an email address is made of. An address is still not a mention, a name that runs into more words (`@Alicex`) still is not one either, and English is unchanged: `mail@alice.com` and `ok@alice` reach nobody, while `@alice`, `@alice.` and `@Alice Smith` do.
- A name, a role, a notice, an instruction or a memory entry cannot close a block of the plugin's own. Those travel into the standing brief of every Bot of the group and into the memory block, and a protocol mark in one of them used to close the block early and let the text after it read as if the plugin had written it. A reply in a group travels into the next Bot's prompt the same way, so a mark in it cannot open a block of its own there either. The marks go the way they go in a reply: out of the text, the words around them untouched.
- The brief of a background job gets the same treatment. The Bot's name and role are written into that brief's block, so a protocol mark in either of them used to close it early as well; both are now cleaned the same way before they go in.

## 0.7.11 The group note, cleaned up

Expand Down
10 changes: 6 additions & 4 deletions plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1775,7 +1775,9 @@ export class Engine {
this.broadcastSummary(helper.chatId);
}
};
const text = t('helper.brief', { bot: bot?.name ?? 'Bot', role: bot?.role || '-', task: helper.task });
// The name, the role and the task travel into the helper's block, so a mark in any of
// them must not close it.
const text = t('helper.brief', { bot: clean(bot?.name) || 'Bot', role: clean(bot?.role) || '-', task: clean(helper.task) });
turnStartedAt = Date.now();
turnBotName = bot?.name ?? '';
try {
Expand Down Expand Up @@ -1877,10 +1879,10 @@ export class Engine {
// A stopped helper was stopped on purpose; there is nothing for the Bot to relay.
if (helper.status === 'stopped') return;
const text = t(`helper.trigger.${helper.status}`, {
title: helper.title,
title: clean(helper.title),
id: helper.id,
report: helper.report || '-',
error: helper.error || '-',
report: clean(helper.report) || '-',
error: clean(helper.error) || '-',
});
const result = await this.runBotTurn({ bot, chatId, text, kind: group ? 'group' : 'helper' });
if (result.messageId) {
Expand Down
2 changes: 1 addition & 1 deletion plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ export function createMcpHandler({ team, engine }) {
result = {
protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0],
capabilities: { tools: { listChanged: false } },
serverInfo: { name: 'minimax-bot', version: '0.7.13' },
serverInfo: { name: 'minimax-bot', version: '0.7.14' },
instructions: t('mcp.instructions'),
};
break;
Expand Down
118 changes: 99 additions & 19 deletions plugins/minimax-ai/minimax-bot/miniapp/node/relay-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,72 @@ export const HANDOVER_TAIL = 400;
*
* The pattern matches any `minimax-bot-…` tag rather than a fixed list, so a mark that is
* spelled differently — with spaces, with an attribute, in another case — is caught as well.
* The attribute part is bounded, so a stray `<` cannot swallow the text behind it.
*
* The attribute is read as an attribute — the characters a tag attribute is made of — rather
* than as "anything up to the next `>`. A model that writes `<minimax-bot-context` into a
* sentence and closes the thought with a `>` a line later would otherwise lose that whole
* stretch of its own text, so only the name of the mark is taken when no tag-shaped attribute
* follows it.
*/
const MARK_PATTERN = new RegExp(
`<\\s*\\/?\\s*minimax-bot-[a-z0-9-]+(?:[ \\t]+[A-Za-z0-9_:.=/"'()\\-]*)?\\s*>`
+ `|<\\s*\\/?\\s*minimax-bot-[a-z0-9-]+`,
'gi',
);

/**
* The block tags the Host itself writes into its prompts.
*
* A Bot that copies one of them into its answer hands the next Bot a text that reads like a
* notice the Host has just sent. The copy is model-written text, so the neutralising removes the
* tag from it the same way it removes a protocol mark — the tag goes, the words around it stay.
*
* The list holds the Host's own vocabulary, not the plugin's: the plugin's wire format
* (`minimax-bot-…`) and its delivery format (`<media src="…"/>`) are not in it and stay.
*/
export const HOST_TAGS = [
'system-reminder',
'agent-context',
'async-audit',
'media-output-reminder',
'background-task-finished',
'mcode-tools-master-reminder',
'task-completion-reminder',
'environment_details',
];

/**
* The Host's block tags, matched the way the protocol marks are: with an optional closing
* slash, with an optional attribute made of the characters an attribute is made of, and in any
* case. Without such an attribute only the name goes, so a sentence that mentions one of these
* words keeps the rest of itself.
*/
const HOST_TAG_PATTERN = new RegExp(
`<\\s*\\/?\\s*(?:${HOST_TAGS.join('|')})(?:[ \\t]+[A-Za-z0-9_:.=/"'()\\-]*)?\\s*>`
+ `|<\\s*\\/?\\s*(?:${HOST_TAGS.join('|')})`,
'gi',
);

/**
* Everything the neutralising removes in one pass: the protocol marks and the Host's block tags.
*/
const MARK_PATTERN = /<\s*\/?\s*minimax-bot-[a-z0-9-]+(?:\s[^>]{0,40})?>/gi;
const STRIP_PATTERN = new RegExp(`${MARK_PATTERN.source}|${HOST_TAG_PATTERN.source}`, 'gi');

/** A dash that looks like the hyphen of a protocol mark. */
const LOOKALIKE_DASH = /[‐-―]/g;
const LOOKALIKE_DASH = /[‐-―]/;

/** A full-width ASCII character, which a Host that folds them would read as ASCII. */
const FULLWIDTH_ASCII = /[!-~]/g;
/** A full-width ASCII character, which reads as its ASCII form. */
const FULLWIDTH_ASCII = /[!-~]/;

/**
* A character that takes no space, so a mark split by one still reads as that mark.
*
* The format characters (a zero-width space or joiner, a soft hyphen, a directional mark, a
* byte-order mark) and the nonspacing marks (a combining accent, a variation selector) are the
* ones that take no room: a mark written with one of them in it still reads as that mark to the
* model, so it is read the same way here.
*/
const INVISIBLE = /[\p{Cf}\p{Mn}]/u;

/** The largest hop limit a group can hold. The panel offers the same range. */
export const HOP_LIMIT_MAX = 20;
Expand Down Expand Up @@ -95,34 +152,57 @@ export function handoverRequested(group) {
}

/**
* A copy of the text with the characters that can stand in for a hyphen or an ASCII letter
* folded to their ASCII form.
* One character, with a dash that looks like a hyphen folded to a hyphen and a full-width ASCII
* character folded to the ASCII form it reads as.
* @param {string} char
* @returns {string}
*/
function foldChar(char) {
if (FULLWIDTH_ASCII.test(char)) return String.fromCharCode(char.charCodeAt(0) - 0xfee0);
return LOOKALIKE_DASH.test(char) ? '-' : char;
}

/**
* The text as the pattern reads it, and where each of its characters came from.
*
* Every fold replaces one character with one character, so a mark found in the folded text
* points at the same characters in the original and the text itself stays untouched.
* Folding a character keeps its place and dropping one does not, so `origin[i]` is the position
* in the original text of the i-th character of the folded text. A mark found in the folded
* text therefore points at the same characters of the original, and the text itself is never
* rewritten.
* @param {string} text
* @returns {string}
* @returns {{ folded: string, origin: number[] }}
*/
function foldMarks(text) {
return text
.replace(LOOKALIKE_DASH, '-')
.replace(FULLWIDTH_ASCII, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0));
let folded = '';
const origin = [];
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
if (INVISIBLE.test(char)) continue;
folded += foldChar(char);
origin.push(i);
}
return { folded, origin };
}

/**
* Removes the protocol marks from a text, so a reply can never pretend to open or close a
* block of its own.
* Removes the protocol marks and the Host's block tags from a text, so a reply can never
* pretend to open or close a block of its own and can never pass a copied Host notice on as
* one.
*
* Only the tags go; the text around them is left as it was.
* @param {string} text
* @returns {string}
*/
export function neutralizeMarks(text) {
const value = String(text ?? '');
const folded = foldMarks(value);
const { folded, origin } = foldMarks(value);
let out = '';
let last = 0;
for (const match of folded.matchAll(MARK_PATTERN)) {
out += value.slice(last, match.index) + ' ';
last = match.index + match[0].length;
for (const match of folded.matchAll(STRIP_PATTERN)) {
const start = origin[match.index];
const end = origin[match.index + match[0].length - 1] + 1;
out += value.slice(last, start) + ' ';
last = end;
}
return out + value.slice(last);
}
Expand Down
25 changes: 19 additions & 6 deletions plugins/minimax-ai/minimax-bot/miniapp/node/task-dispatcher.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@
import { createHash } from "node:crypto";
import { newId } from "./store.mjs";
import { t } from "./i18n.mjs";
import { neutralizeMarks } from "./relay-guard.mjs";

const ACTIVE = new Set(["queued", "running"]);
const UNSETTLED = new Set([...ACTIVE, "waiting_for_tasks", "waiting_for_user", "unknown", "paused"]);
const MAX_RUNNING = 2; // Share Engine's Host slots; leave capacity for the caller and replies.
const clip = (text, size = 8000) => String(text ?? "").slice(0, size);

/**
* A report is source data, never instructions. The protocol marks are stripped from the whole
* report, so neither a child Bot that writes one into its result nor a name that carries one
* can open a context block of its own in the prompt of the Bot that reads the report. Only the
* marks go; the words around them stay.
* @param {any} text
* @returns {string}
*/
const reportBody = (text) => neutralizeMarks(String(text ?? ""));

/** Durable delegations. Network observers never own the lifetime of an admitted task. */
export class TaskDispatcher {
constructor(engine, ErrorType) {
Expand Down Expand Up @@ -93,16 +104,16 @@ export class TaskDispatcher {
}

batchReport(batch) {
return t("task.results", { id: batch.id }) + "\n" + batch.tasks.map((task) =>
return reportBody(t("task.results", { id: batch.id }) + "\n" + batch.tasks.map((task) =>
`${this.store.getBot(task.botId)?.name ?? task.title} [${task.status}] (${task.id})\n${clip(task.result || task.error || "-", 4000)}`,
).join("\n\n");
).join("\n\n"));
}

helperReport(helper) {
if (helper.status === "stopped") return `${helper.title} [stopped]`;
return t(`helper.trigger.${helper.status}`, {
if (helper.status === "stopped") return reportBody(`${helper.title} [stopped]`);
return reportBody(t(`helper.trigger.${helper.status}`, {
title: helper.title, id: helper.id, report: helper.report || "-", error: helper.error || "-",
});
}));
}

async receiveDependency(parentId, id, text) {
Expand Down Expand Up @@ -455,8 +466,10 @@ export class TaskDispatcher {
if (task.status === "unknown") { void this.save().catch(() => {}); continue; }
const dependencies = this.pendingDependencies(task);
if (dependencies.length && dependencies.every((dependency) => dependency.report !== undefined)) {
// The one place a dependency report reaches the Bot that verifies it. A report kept
// from an earlier run is stripped here as well, so no mark survives into the prompt.
task.review = { botId: dependencies[0].botId, ids: dependencies.map((dependency) => dependency.id),
text: t("task.dependencyPrompt") + "\n\n" + dependencies.map((dependency) => dependency.report).join("\n\n") };
text: t("task.dependencyPrompt") + "\n\n" + dependencies.map((dependency) => reportBody(dependency.report)).join("\n\n") };
task.status = "queued";
}
}
Expand Down
2 changes: 1 addition & 1 deletion plugins/minimax-ai/minimax-bot/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "minimax-bot",
"version": "0.7.13",
"version": "0.7.14",
"private": true,
"type": "module",
"mcode": {
Expand Down
Loading
Loading