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.12",
"version": "0.7.13",
"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
7 changes: 6 additions & 1 deletion plugins/minimax-ai/minimax-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the

- **Long-lived Bots**: every Bot has a name, a role, instructions and its own Agent session. When its setup, the team roster or its memory changes, the next turn automatically carries the latest context.
- **Chief Bot**: created on first launch to help you build your team. Chief can create, edit and delete Bots and groups; other Bots are refused when they try to do so.
- **Group chats**: the group admin answers by default. `@Name` calls on a specific member, and a Bot that `@`-mentions another member in its reply hands the turn over. Members with nothing to add reply `[PASS]` and are skipped. Each member keeps a separate session per group, and groups cannot see private chats. There is no fixed reply cap by default; a group can set one in the group settings. A group can also keep a short note of what it said, which survives clearing the chat. Discussions finish naturally, or wait for more instructions after repeated handoffs without tool progress.
- **Group chats**: the group admin answers by default. `@Name` calls on a specific member, and a Bot that `@`-mentions another member in its reply hands the turn over. The `@` needs no space in front of it, so `请@某人` calls that member. Members with nothing to add reply `[PASS]` and are skipped. Each member keeps a separate session per group, and groups cannot see private chats. There is no fixed reply cap by default; a group can set one in the group settings. A group can also keep a short note of what it said, which survives clearing the chat. Discussions finish naturally, or wait for more instructions after repeated handoffs without tool progress.
- **Hand-offs between Bots**: `message_bot` hands work to another Bot and immediately returns an admission receipt; the completed result returns to the source chat. `post_group` is **private chat only**: it posts to a group and returns immediately while the discussion continues according to the group's reply mode. From a private chat, Chief can post to any group without joining it; other Bots can only post to groups they belong to. During a group turn, `post_group` is disabled for everyone, including Chief and admins, for both same-group and cross-group posts. Use `@Name` in a reply to hand off within the group; start cross-group work from a private chat.
- **Background work**: when an isolated execution context is useful, a Bot calls `start_helper` to run it in the background in a fresh, separate session; the Bot's turn ends at once and you can keep chatting. The Bot simply says something like "let me look into it" without internal jargon or IDs. When the job finishes, its report comes back to the Bot as a new turn, and the Bot reviews it and passes it on in its own words (in groups too, where it can keep handing off with `@`). At most 2 background jobs run at once (one slot is always kept free for chat) and the rest queue; each Bot can have at most 4 queued or running. Running jobs are shown above the chat input and can be stopped at any time; the strip orders them by how far along they are, the ones already working first, then the ones waiting for another job, then the queued ones; when more than three jobs are listed, it keeps the first three in the open and folds the rest into one line that says how many are left, and one button below the rows opens the whole queue; tool approvals from a background job appear in its chat; jobs still running when the Mini App restarts are marked interrupted. Bots can also manage jobs with `list_helpers` / `stop_helper`. "Allow background helpers automatically" is on by default in Bot settings; when off, a Bot only does so when you explicitly ask.
- **Per-Bot model and reasoning effort**: pick a model and reasoning effort in Bot settings (only the levels the model supports are listed, and thinking can be turned off where supported; Bots that follow the default model can still set their own effort). Chief can also set them with the `model` (a model ID, `provider/model` or display name; `default` follows the default) and `effort` (`low`, `high`, `off`, ...) parameters of `create_bot` / `update_bot`. Requires a MiniMax Code version with `agent.models()` / `run({ model })`; older versions prompt you to upgrade.
Expand Down Expand Up @@ -47,6 +47,11 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the
- 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.

## 0.7.13 A mention that reads like Chinese, and marks that stay out

- 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.

## 0.7.11 The group note, cleaned up

- Switching the note off no longer hides what was recorded. The group settings keep showing the record and its **Clear** button while the record exists, and say that the switch is off. The record itself is kept until you clear it, so switching the note back on hands the same record to the Bots again.
Expand Down
47 changes: 33 additions & 14 deletions plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { selectMemoryContext } from './memory-context.mjs';
import { UserInbox } from './user-inbox.mjs';
import { TaskDispatcher } from './task-dispatcher.mjs';
import { modelKey, newId, withEffort } from './store.mjs';
import { CONVERGE_MARK, CONTEXT_BLOCK_END, CONTEXT_BLOCK_MARK, handoverRequested, handoverSummary, hopLimit, renderHandover } from './relay-guard.mjs';
import { CONVERGE_MARK, CONTEXT_BLOCK_END, CONTEXT_BLOCK_MARK, handoverRequested, handoverSummary, hopLimit, neutralizeMarks, renderHandover } from './relay-guard.mjs';
import { appendNote, notesEnabled, renderNote } from './group-note.mjs';

/** @typedef {import('./miniapp-api.js').MiniAppAgentClient} MiniAppAgentClient */
Expand All @@ -18,6 +18,17 @@ import { appendNote, notesEnabled, renderNote } from './group-note.mjs';
/** @typedef {import('./store.mjs').Store} Store */
/** @typedef {import('./memory.mjs').MemoryStore} MemoryStore */

/**
* A field a Bot (or the user) wrote: names, roles, notices and instructions travel into the
* standing brief of every Bot of the group. A protocol mark in one of them would close the
* brief's block early and let the rest of the text read as if the plugin had said it, so the
* marks go the same way as in a reply: out of the text, the words around them untouched.
* @param {unknown} value
*/
function clean(value) {
return neutralizeMarks(String(value ?? ''));
}

const TOP_LEVEL_SLOTS = 3; // Leave Host capacity while all chat and task turns share these slots.
const GROUP_CONTEXT_MESSAGES = 30;
const MESSAGE_CLIP = 4000;
Expand Down Expand Up @@ -252,15 +263,15 @@ export class Engine {
roster() {
const { bots, groups } = this.store.state;
const botLines = bots.map((bot) =>
t('brief.botLine', { name: bot.name, id: bot.id, main: bot.isMain ? t('brief.mainTag') : '', role: bot.role || t('brief.noRole') }),
t('brief.botLine', { name: clean(bot.name), id: bot.id, main: bot.isMain ? t('brief.mainTag') : '', role: clean(bot.role) || t('brief.noRole') }),
);
const groupLines = groups.map((group) => {
const admin = this.store.getBot(group.adminId)?.name ?? t('brief.none');
const admin = clean(this.store.getBot(group.adminId)?.name) || t('brief.none');
const members = group.memberIds
.map((id) => this.store.getBot(id)?.name)
.map((id) => clean(this.store.getBot(id)?.name))
.filter(Boolean)
.join(t('list.sep'));
return t('brief.groupLine', { name: group.name, id: group.id, admin, members });
return t('brief.groupLine', { name: clean(group.name), id: group.id, admin, members });
});
return { botLines, groupLines };
}
Expand Down Expand Up @@ -316,7 +327,7 @@ export class Engine {
// This build keeps no group notes, so the hand-over carries the tail of the reply.
const text = handoverSummary({ note: '', reply: last.text });
if (!text) return undefined;
return { from: last.name, text };
return { from: clean(last.name), text };
}


Expand Down Expand Up @@ -405,17 +416,17 @@ export class Engine {
const note = group ? renderNote(group) : '';
const lines = [
CONTEXT_BLOCK_MARK,
t('brief.intro', { name: bot.name, id: bot.id }),
bot.role ? t('brief.role', { role: bot.role }) : '',
t('brief.intro', { name: clean(bot.name), id: bot.id }),
bot.role ? t('brief.role', { role: clean(bot.role) }) : '',
bot.isMain ? t('brief.mainPower') : '',
'',
t('brief.hInstructions'),
bot.instructions?.trim() || t('brief.noInstructions'),
clean(bot.instructions?.trim()) || t('brief.noInstructions'),
'',
t('brief.hChat'),
group
? [
t('brief.inGroup', { name: group.name, notice: group.notice || t('brief.none') }),
t('brief.inGroup', { name: clean(group.name), notice: clean(group.notice) || t('brief.none') }),
isAdmin ? t('brief.groupAdmin') : t('brief.groupMember'),
t('brief.groupPass'),
t('brief.groupNoPost'),
Expand Down Expand Up @@ -1165,10 +1176,15 @@ export class Engine {
const members = group.memberIds.map((id) => this.store.getBot(id)).filter(Boolean)
.map((bot) => ({ id: bot.id, name: bot.name.toLowerCase() }))
.sort((a, b) => b.name.length - a.name.length);
// A word character of any script: it ends a name, so "Alicex" is not "Alice".
const isWord = (value) => Boolean(value && /[\p{L}\p{N}\p{M}_]/u.test(value));
// An ASCII word character is what makes an '@' part of a handle: the local part of an
// email address. Chinese has no spaces between words, so a character before the '@' is a
// word of its own there ("请@某人"), and it must not swallow the mention.
const isAsciiWord = (value) => Boolean(value && /[A-Za-z0-9_]/.test(value));
const found = [];
for (let index = lower.indexOf('@'); index >= 0; index = lower.indexOf('@', index + 1)) {
if (isWord(lower[index - 1])) continue;
if (isAsciiWord(lower[index - 1])) continue;
const member = members.find(({ name }) => lower.startsWith(name, index + 1) && !isWord(lower[index + 1 + name.length]));
if (!member) continue;
if (member.id !== excludeId && !found.includes(member.id)) found.push(member.id);
Expand Down Expand Up @@ -1330,14 +1346,17 @@ export class Engine {
: m.role === 'relay'
? m.fromName ?? t('bot.deleted')
: this.store.getBot(m.botId)?.name ?? t('bot.deleted');
return `[${who}] ${clip(m.role === 'user' ? agentTextOf(m.text, m.attachments) : m.text)}`;
// The text of a message in the group is text that rides along into the next Bot's
// prompt, and it comes from a reply or from a user. A protocol mark in it would let
// the text after it read as if the plugin had written it.
return `[${clean(who)}] ${clip(clean(m.role === 'user' ? agentTextOf(m.text, m.attachments) : m.text))}`;
});
const text = [
...head,
t('group.newMessages', { name: group.name }),
t('group.newMessages', { name: clean(group.name) }),
...(lines.length ? lines : [t('group.noNew')]),
'',
t('group.yourTurn', { name: bot.name }),
t('group.yourTurn', { name: clean(bot.name) }),
].join('\n');
const lastIncluded = recent.at(-1)?.seq ?? lastSeenSeq;
// Another member's streaming reply is not readable yet. Do not advance past that hole:
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.12' },
serverInfo: { name: 'minimax-bot', version: '0.7.13' },
instructions: t('mcp.instructions'),
};
break;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// @ts-check
// Selection affects one prompt only. Storage and the caller's entries remain untouched.
import { neutralizeMarks } from './relay-guard.mjs';

export const MEMORY_CONTEXT_MAX = 6000;

function queryTerms(query) {
Expand All @@ -24,7 +26,11 @@ function relevance(entry, terms) {
function serializeEntry(entry) {
return JSON.stringify({
scope: entry.scope, id: entry.id, key: entry.key ?? '', revision: entry.revision ?? '',
pinned: Boolean(entry.pinned), text: entry.text,
pinned: Boolean(entry.pinned),
// A memory entry is written by a Bot (or by the user) and travels into the memory block of
// every Bot that reads it. A protocol mark in it would close that block early, so the marks
// go the way they go in a reply: out of the text, the words around them untouched.
text: neutralizeMarks(String(entry.text ?? '')),
});
}

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.12",
"version": "0.7.13",
"private": true,
"type": "module",
"mcode": {
Expand Down
97 changes: 97 additions & 0 deletions plugins/minimax-ai/minimax-bot/tests/mention-text.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// @ts-check
// The "@" of a mention, read the way Chinese and English write it.
//
// A mention is "@" plus a member's name. Chinese has no spaces between words, so the character
// in front of the "@" is a word of its own ("请@某人") and must not swallow the mention the way
// it does in "mail@alice.com". What makes an "@" part of a handle is an ASCII word character in
// front of it, so that is what the guard looks at. English keeps the old rule: an ASCII letter,
// digit or underscore in front of the "@" still ends the mention, and the address, the longer
// name and the name that runs into more words are all as they were.

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { Store } from '../miniapp/node/store.mjs';
import { MemoryStore } from '../miniapp/node/memory.mjs';
import { Engine } from '../miniapp/node/engine.mjs';

async function fixture(t) {
const dir = await mkdtemp(join(tmpdir(), 'bot-mention-'));
const store = new Store(dir);
const memory = new MemoryStore(dir);
await store.init();
await memory.init();
const engine = new Engine({
store, memory, logger: { warn() {} }, broadcast: () => {}, retryDelaysMs: [],
});
t.after(async () => {
engine.dispose();
await rm(dir, { recursive: true, force: true });
});
store.state.bots = [
{ id: 'designer', name: '设计师' },
{ id: 'alice', name: 'Alice' },
{ id: 'alice_smith', name: 'Alice Smith' },
{ id: 'bob', name: 'Bob' },
];
return { store, engine, group: { memberIds: ['designer', 'alice', 'alice_smith', 'bob'] } };
}

test('a mention works right after a Chinese word, the way Chinese is written', async (t) => {
const { engine, group } = await fixture(t);
assert.deepEqual(engine.mentionsIn('@设计师,请回答。', group), ['designer']);
assert.deepEqual(engine.mentionsIn('请@设计师,请回答。', group), ['designer']);
assert.deepEqual(engine.mentionsIn('好的@设计师', group), ['designer']);
assert.deepEqual(engine.mentionsIn('麻烦@设计师 看一下', group), ['designer']);
assert.deepEqual(engine.mentionsIn('请 @设计师', group), ['designer']);
// The same rule in the other scripts that write without spaces.
assert.deepEqual(engine.mentionsIn('日本語@alice', group), ['alice']);
assert.deepEqual(engine.mentionsIn('한국어@alice', group), ['alice']);
});

test('a mention in English stays the way it was', async (t) => {
const { engine, group } = await fixture(t);
assert.deepEqual(engine.mentionsIn('@alice', group), ['alice']);
assert.deepEqual(engine.mentionsIn('hi @alice', group), ['alice']);
assert.deepEqual(engine.mentionsIn('@alice please review the plan', group), ['alice']);
assert.deepEqual(engine.mentionsIn('(@alice)', group), ['alice']);
assert.deepEqual(engine.mentionsIn('@alice.', group), ['alice']);
assert.deepEqual(engine.mentionsIn('@alice!', group), ['alice']);
assert.deepEqual(engine.mentionsIn('@alice @bob', group), ['alice', 'bob']);
assert.deepEqual(engine.mentionsIn('@alice,@bob', group), ['alice', 'bob']);
assert.deepEqual(engine.mentionsIn('@alice @alice', group), ['alice']);
assert.deepEqual(engine.mentionsIn('@ALICE SMITH!', group), ['alice_smith']);
// The longest name wins, and a name that runs into more words is not a mention.
assert.deepEqual(engine.mentionsIn('@alice smith', group), ['alice_smith']);
assert.deepEqual(engine.mentionsIn('@Alicex', group), []);
assert.deepEqual(engine.mentionsIn('@alice_x', group), []);
});

test('an English address is still not a mention', async (t) => {
const { engine, group } = await fixture(t);
// The local part of an address is ASCII, so it ends the mention the way it always did.
assert.deepEqual(engine.mentionsIn('mail@alice.com', group), []);
assert.deepEqual(engine.mentionsIn('user.name@alice.com', group), []);
assert.deepEqual(engine.mentionsIn('mail@设计师', group), []);
assert.deepEqual(engine.mentionsIn('ok@alice ok', group), []);
assert.deepEqual(engine.mentionsIn('2@alice', group), []);
assert.deepEqual(engine.mentionsIn('mail@alice.com @ALICE SMITH! @Alice', group), ['alice_smith', 'alice']);
});

test('a name that runs into more words is still not a mention', async (t) => {
const { engine, group } = await fixture(t);
// "Alicex" is another word, not Alice.
assert.deepEqual(engine.mentionsIn('@Alicex', group), []);
// A full stop in Chinese prose ends a sentence; it is not a domain.
assert.deepEqual(engine.mentionsIn('@Alice。然后呢', group), ['alice']);
assert.deepEqual(engine.mentionsIn('@设计师傅', group), []);
});

test('the Bot that sent the message does not call itself', async (t) => {
const { engine, group } = await fixture(t);
assert.deepEqual(engine.mentionsIn('@alice @bob', group, 'alice'), ['bob']);
assert.deepEqual(engine.mentionsIn('@alice @bob', group, 'bob'), ['alice']);
});
Loading
Loading