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.11",
"version": "0.7.12",
"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
12 changes: 10 additions & 2 deletions plugins/minimax-ai/minimax-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the
- The note is off unless you switch it on in the group settings, and a group without a note gets exactly the prompt it got before. Only you can set it: a Bot cannot switch it on, and the record itself is written by the plugin — a Bot cannot write into it.
- The note holds the most recent rounds and stays within a small size limit. The group settings show what was recorded, and you can clear it there. Switching the note off stops the recording and stops handing the record to the Bots, but what was already recorded stays until you clear it.

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

## 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 Expand Up @@ -119,8 +127,8 @@ Avatars are stored in the Mini App's `dataDir/avatars/`. `GET /api/avatars` list

## Data & access

- **Agent turns**: every Bot reply, scheduled task and background job is an Agent turn run through `context.agent`, with the Agent's full capabilities (files, shell, Skills and MCP, including this plugin's own MCP tools). A tool call that needs approval is shown in the page or in the session's sidebar tab; it runs only after you choose **Allow once**. Nothing is approved automatically. A group with a relay limit spends one more Agent turn on the round that reaches the limit: the wrap-up, which replaces the blocked hand-off. A group note spends no turn at all: it is written from the replies the round already produced.
- **Files written**: only inside the Mini App's `dataDir`: `state.json`, `messages/`, `usage.jsonl`, `memory/` (including its journal, archive and backups), `uploads/` (your attachments) and `avatars/`. A group's note is part of that group's record in `state.json`, so it is removed with the group.
- **Agent turns**: every Bot reply, scheduled task and background job is an Agent turn run through `context.agent`, with the Agent's full capabilities (files, shell, Skills and MCP, including this plugin's own MCP tools). A tool call that needs approval is shown in the page or in the session's sidebar tab; it runs only after you choose **Allow once**. Nothing is approved automatically. A group with a relay limit spends one more Agent turn on the round that reaches the limit: the wrap-up, which replaces the blocked hand-off. A group note spends no turn at all: it is written from the replies the round already produced. Every turn is counted once in the usage panel, background jobs included.
- **Files written**: only inside the Mini App's `dataDir`: `state.json`, `messages/`, `usage.jsonl` (one row per Agent turn), `memory/` (including its journal, archive and backups), `uploads/` (your attachments) and `avatars/`. A group's note is part of that group's record in `state.json`, so it is removed with the group.
- **Files read outside `dataDir`**:
- `/api/media` serves a local file (up to 1 GiB) only when its absolute path appears in a Bot message of that same chat.
- An avatar given as a local image path (by you or by Chief through `update_bot`) is read once and copied into `avatars/`.
Expand Down
3 changes: 3 additions & 0 deletions plugins/minimax-ai/minimax-bot/miniapp/client/panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,9 @@ function groupOverview(group) {
h('span', null, t('panels.group.notes')),
),
h('p', { class: 'hint', style: { marginTop: '8px' } }, t('panels.group.notesHint')),
// The limits come from the server, so the panel cannot state numbers the plugin does
// not use.
...(state.noteLimits ? [h('p', { class: 'hint' }, t('panels.group.notesLimit', state.noteLimits))] : []),
// The record stays visible while it exists, even with the switch off: what was recorded
// has to be readable and clearable, otherwise turning the switch off would hide it.
...(Array.isArray(group.noteEntries) && group.noteEntries.length
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ export default {
'panels.group.handoverHint': '下一位的提示词里会带上上一位回复的结论(就是它的尾部),省得下一位再翻一遍工具。上一位的原话本来就在「新消息」里,这里是再强调一遍结论。只多一段提示词,不多花回合。',
'panels.group.notesHeading': '群纪要',
'panels.group.notes': '把本群说过的话记下来',
'panels.group.notesHint': '每轮结束后,群会把各 Bot 回复的结尾记成一份纪要,写在群资料里:清空聊天记录(新建对话)之后纪要还在,新加入的 Bot 也能看到。纪要只留最近几轮(最多 6 条 / 1200 字),更早的会掉出去;关掉开关只是不再记、也不再给 Bot 看,记下的内容会保留到你清空为止。纪要只是记录,不代表群里已经达成共识。只多一段提示词,不多花回合。',
'panels.group.notesHint': '每轮结束后,群会把各 Bot 回复的结尾记成一份纪要,写在群资料里:清空聊天记录(新建对话)之后纪要还在,新加入的 Bot 也能看到。关掉开关只是不再记、也不再给 Bot 看,记下的内容会保留到你清空为止。纪要只是记录,不代表群里已经达成共识。只多一段提示词,不多花回合。',
'panels.group.notesLimit': '纪要只留最近几轮(最多 {entries} 条 / {chars} 字),更早的会掉出去。',
'panels.group.notesList': '已记录的纪要:',
'panels.group.notesKept': '开关关着,这份纪要保留着,但不再记录、也不再给 Bot 看。',
'panels.group.notesClear': '清空纪要',
Expand Down Expand Up @@ -544,7 +545,8 @@ export default {
'panels.group.handoverHint': "The next Bot's prompt carries the conclusion of the previous reply (its tail), so the next Bot does not have to dig the same ground out of the tools again. The previous reply is in the new messages anyway — this repeats the conclusion on purpose. It is prompt text only — no extra turn.",
'panels.group.notesHeading': 'Group notes',
'panels.group.notes': 'Keep a record of what this group said',
'panels.group.notesHint': 'After every round the group records the closing lines of the replies as a note, kept with the group settings: the note is still there after the chat was cleared (Start new chat), and a Bot that joins later sees it too. Only the last rounds are kept (6 entries and 1200 characters at most); older ones drop off. Switching the note off stops the recording and stops handing it to the Bots, but what was recorded stays until you clear it. A note is a record, not an agreement the group reached. It is prompt text only — no extra turn.',
'panels.group.notesHint': 'After every round the group records the closing lines of the replies as a note, kept with the group settings: the note is still there after the chat was cleared (Start new chat), and a Bot that joins later sees it too. Switching the note off stops the recording and stops handing it to the Bots, but what was recorded stays until you clear it. A note is a record, not an agreement the group reached. It is prompt text only — no extra turn.',
'panels.group.notesLimit': 'Only the last rounds are kept ({entries} entries and {chars} characters at most); older ones drop off.',
'panels.group.notesList': 'Recorded so far:',
'panels.group.notesKept': 'The switch is off, so this record is kept but neither added to nor read by the Bots.',
'panels.group.notesClear': 'Clear the note',
Expand Down
42 changes: 42 additions & 0 deletions plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,17 @@ function errorText(error) {
return String(error);
}

/**
* The usage ledger speaks the Bot's vocabulary: a turn is done, error or aborted. A background
* job that failed is an error; one that was cut off (a stop, a restart) is aborted, not a fault.
* @param {string} status
*/
function helperTurnStatus(status) {
if (status === 'failed') return 'error';
if (status === 'interrupted' || status === 'stopped') return 'aborted';
return 'done';
}

export class Engine {
/**
* @param {{
Expand Down Expand Up @@ -1696,6 +1707,12 @@ export class Engine {
let result;
let failure = '';
let aborted = false;
// What this background job spent: its own turn, its tools and what it answered.
let turnStartedAt = 0;
let turnMs = 0;
let turnTools = 0;
let turnText = '';
let turnBotName = '';
try {
holdsHelper = await this.acquireHelper(controller.signal);
if (!holdsHelper || controller.signal.aborted) throw Object.assign(new Error('stopped'), { code: 'REQUEST_CANCELLED' });
Expand All @@ -1715,6 +1732,10 @@ export class Engine {
if (event.type === 'turn.started' && !helper.sessionId) {
helper.sessionId = event.sessionId;
this.broadcastHelpers();
} else if (event.type === 'tool.started') {
// A background job calls the same tools a Bot's own turn does. Count them, or the
// turn it spends looks like it cost nothing.
turnTools += 1;
} else if (event.type === 'permission.requested') {
// The user approves a helper's tool call in its Bot's chat, like the Bot's own.
const approval = {
Expand All @@ -1736,13 +1757,18 @@ export class Engine {
}
};
const text = t('helper.brief', { bot: bot?.name ?? 'Bot', role: bot?.role || '-', task: helper.task });
turnStartedAt = Date.now();
turnBotName = bot?.name ?? '';
try {
result = await /** @type {any} */ (this.agent).run({ text, ...(model ? { model } : {}) }, { signal: controller.signal, onEvent });
} catch (error) {
if (model && errorCode(error) === 'INVALID_ARGUMENTS') {
throw Object.assign(new Error(errorText(error)), { code: 'MODEL_UNAVAILABLE' });
}
throw error;
} finally {
turnMs = Date.now() - turnStartedAt;
turnText = result?.text ?? '';
}
} finally {
this.releaseSlot();
Expand Down Expand Up @@ -1781,6 +1807,22 @@ export class Engine {
helper.error = result?.error?.message ?? t('agentError.unknown');
}
if (report) helper.report = report;
if (turnStartedAt) {
// A background job is an Agent turn like any other: meter it here, or the usage panel
// counts fewer turns than the model actually ran.
await this.store.recordUsage({
ts: Date.now(),
botId: helper.botId,
botName: turnBotName,
chatId: helper.chatId,
kind: 'helper-run',
status: helperTurnStatus(helper.status),
durationMs: turnMs,
chars: turnText.length,
tools: turnTools,
});
this.broadcast({ type: 'usage.changed' });
}
if (this.disposed) return;
this.trimHelpers();
await this.store.save();
Expand Down
25 changes: 19 additions & 6 deletions plugins/minimax-ai/minimax-bot/miniapp/node/group-note.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,22 @@ function cleanEntry(entry) {
return { speaker: speaker.slice(0, NOTE_SPEAKER_MAX), text: tailOf(text, NOTE_ENTRY_MAX) };
}

/**
* A word character in the Latin sense: a letter or a digit that a cut can land inside.
*
* A CJK ideograph is its own unit, so a cut between two of them already lands on a boundary.
*/
const LATIN_WORD = /[A-Za-z0-9_]/;

/** How much of a broken word may be dropped to reach the next boundary. */
const WORD_PREFIX_MAX = 20;

/**
* The closing part of a text, cut on a word boundary.
*
* A plain cut can land inside a word — or inside the half of a tag the neutralising left
* behind — and the entry would start mid-word. When the cut lands inside a word, the partial
* word is dropped, so the entry starts where a word starts.
* behind — and the entry would start mid-word. Only a Latin word can be cut in half: Chinese
* has no spaces, so trimming up to the first whitespace there would drop the whole entry.
*
* @param {string} text
* @param {number} [max]
Expand All @@ -87,10 +97,13 @@ export function tailOf(text, max = NOTE_ENTRY_MAX) {
if (value.length <= max) return value;
const cut = value.length - max;
const tail = value.slice(cut);
// The character before the cut tells whether the cut landed inside a word.
if (!/\S/.test(value[cut - 1] ?? '')) return tail;
const trimmed = tail.replace(/^\S*\s+/, '');
return trimmed || tail;
// The characters around the cut tell whether it landed inside a Latin word.
if (!LATIN_WORD.test(value[cut - 1] ?? '') || !LATIN_WORD.test(tail[0] ?? '')) return tail;
// Drop the rest of that word and the space after it. A word longer than this is left alone:
// dropping half of a long token would cost more than the fragment is worth.
const fragment = /^[A-Za-z0-9_]+/.exec(tail)?.[0] ?? '';
if (!fragment || fragment.length > WORD_PREFIX_MAX) return tail;
return tail.slice(fragment.length).replace(/^\s+/, '') || tail;
}

/**
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.11' },
serverInfo: { name: 'minimax-bot', version: '0.7.12' },
instructions: t('mcp.instructions'),
};
break;
Expand Down
51 changes: 29 additions & 22 deletions plugins/minimax-ai/minimax-bot/miniapp/node/relay-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,18 @@ export const HANDOVER_TAIL = 400;
/**
* Protocol marks a reply must never be able to forge: a Bot that writes them into its answer
* would otherwise be able to open a block of its own in the next Bot's prompt.
*
* 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.
*/
const FORGED_MARKS = [
HANDOVER_BLOCK_MARK,
HANDOVER_BLOCK_END,
CONVERGE_MARK,
CONTEXT_BLOCK_MARK,
CONTEXT_BLOCK_END,
'<minimax-bot-group-summary>',
'</minimax-bot-group-summary>',
'<minimax-bot-group-summary-task>',
];
const MARK_PATTERN = /<\s*\/?\s*minimax-bot-[a-z0-9-]+(?:\s[^>]{0,40})?>/gi;

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

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

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

/**
* A protocol mark is a literal, so the pattern has to escape whatever it contains.
* A copy of the text with the characters that can stand in for a hyphen or an ASCII letter
* folded to their ASCII form.
*
* 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.
* @param {string} text
* @returns {string}
*/
function escapeRegExp(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function foldMarks(text) {
return text
.replace(LOOKALIKE_DASH, '-')
.replace(FULLWIDTH_ASCII, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0));
}

/**
* The marks are matched case-insensitively: a tag in another case is still a tag, and a Host
* that reads the marks case-insensitively must not be handed one.
*/
const FORGED_PATTERNS = FORGED_MARKS.map((mark) => new RegExp(escapeRegExp(mark), 'gi'));

/**
* Removes the protocol marks from a text, so a reply can never pretend to open or close a
* block of its own.
* @param {string} text
* @returns {string}
*/
export function neutralizeMarks(text) {
let out = String(text ?? '');
for (const pattern of FORGED_PATTERNS) out = out.replace(pattern, ' ');
return out;
const value = String(text ?? '');
const folded = 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;
}
return out + value.slice(last);
}

/**
Expand Down
5 changes: 4 additions & 1 deletion plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { extname, isAbsolute, join } from 'node:path';

import { createHash } from 'node:crypto';
import { Engine, EngineError, UPLOAD_NAME_RE } from './engine.mjs';
import { noteEntries } from './group-note.mjs';
import { NOTE_MAX_CHARS, NOTE_MAX_ENTRIES, noteEntries } from './group-note.mjs';
import { createMcpHandler } from './mcp.mjs';
import { LANGUAGE_PREFS, locale, matchLocale, t } from './i18n.mjs';
import { memoryBudget, MemoryError, MemoryStore } from './memory.mjs';
Expand Down Expand Up @@ -144,6 +144,9 @@ export async function start(context) {
groups: store.state.groups.map((group) => (
Array.isArray(group.noteEntries) ? { ...group, noteEntries: noteEntries(group) } : group
)),
// The limits of a group note, so the panel can state them instead of writing down numbers
// that would drift away from the ones the plugin uses.
noteLimits: { entries: NOTE_MAX_ENTRIES, chars: NOTE_MAX_CHARS },
schedules: store.state.schedules,
helpers: engine.helpersList(),
tasks: engine.tasks.uiList(),
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.11",
"version": "0.7.12",
"private": true,
"type": "module",
"mcode": {
Expand Down
Loading
Loading