From 76e9b54686777bf9287010c122aa88a348b864e2 Mon Sep 17 00:00:00 2001 From: EdgeMLHacker <126883848+Apageoflove@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:10:59 -0700 Subject: [PATCH] =?UTF-8?q?feat(minimax-bot):=20=E7=BE=A4=E7=BA=AA?= =?UTF-8?q?=E8=A6=81=E2=80=94=E2=80=94=E6=B8=85=E7=A9=BA=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=90=8E=E7=BE=A4=E8=BF=98=E8=AE=B0=E5=BE=97=E8=AF=B4=E8=BF=87?= =?UTF-8?q?=E4=BB=80=E4=B9=88=EF=BC=88=E9=BB=98=E8=AE=A4=E5=85=B3=E9=97=AD?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../minimax-bot/.minimax-plugin/plugin.json | 2 +- plugins/minimax-ai/minimax-bot/README.md | 15 +- .../minimax-bot/miniapp/client/panels.js | 30 ++ .../miniapp/client/strings-panels.js | 10 + .../minimax-bot/miniapp/node/engine.mjs | 51 +++- .../minimax-bot/miniapp/node/group-note.mjs | 161 ++++++++++ .../minimax-bot/miniapp/node/mcp.mjs | 2 +- .../minimax-bot/miniapp/node/strings.mjs | 2 + .../minimax-bot/miniapp/node/team.mjs | 8 + plugins/minimax-ai/minimax-bot/package.json | 2 +- .../minimax-bot/tests/group-note.test.mjs | 280 ++++++++++++++++++ 11 files changed, 551 insertions(+), 12 deletions(-) create mode 100644 plugins/minimax-ai/minimax-bot/miniapp/node/group-note.mjs create mode 100644 plugins/minimax-ai/minimax-bot/tests/group-note.test.mjs diff --git a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json index b213996d..b5ad84a9 100644 --- a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json +++ b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "minimax-bot", "displayName": "MiniMax Bot", - "version": "0.7.9", + "version": "0.7.10", "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", diff --git a/plugins/minimax-ai/minimax-bot/README.md b/plugins/minimax-ai/minimax-bot/README.md index c309c0f3..ef044f7f 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -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. 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. 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. @@ -30,6 +30,15 @@ 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.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. +- The note is kept with the group settings, not with the messages. It is still there after the chat was cleared (**Start new chat**), and a Bot that joins the group later reads it too, so a group that was reset does not start from nothing. +- It is prompt text: the note is written from the replies the round already produced, so it costs no extra Agent turn, no extra tool call and no memory write. +- The note is a record, not an agreement. It says what the Bots said at the end of earlier rounds, and every Bot of the group is told exactly that in the prompt. +- 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. + ## 0.7.9 Optional relay guardrails for groups - A group can set a hop limit in the group settings: how many Bots may speak in one relay chain. `0` (the default) means no limit, exactly as before; the largest limit is 20. @@ -103,8 +112,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. -- **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/`. +- **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. - **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/`. diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/panels.js b/plugins/minimax-ai/minimax-bot/miniapp/client/panels.js index 13bd7303..0d29198d 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/panels.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/panels.js @@ -322,6 +322,36 @@ function groupOverview(group) { ), h('p', { class: 'hint', style: { marginTop: '8px' } }, t('panels.group.handoverHint')), ), + h( + 'section', + null, + h('h3', { class: 'section-title' }, t('panels.group.notesHeading')), + h( + 'label', + { class: 'check' }, + h('input', { + type: 'checkbox', + class: 'switch', + name: 'notes', + checked: group.notes === true, + onchange: (event) => patch({ notes: event.target.checked === true }), + }), + h('span', null, t('panels.group.notes')), + ), + h('p', { class: 'hint', style: { marginTop: '8px' } }, t('panels.group.notesHint')), + ...(group.notes === true && Array.isArray(group.noteEntries) && group.noteEntries.length + ? [ + h('p', { class: 'hint', style: { marginTop: '8px' } }, t('panels.group.notesList')), + ...group.noteEntries.map((entry) => clipped(`[${entry.speaker}] ${entry.text}`)), + h( + 'button', + { type: 'button', class: 'link-row', onclick: () => patch({ clearNotes: true }) }, + icon('eraser'), + h('span', { class: 'grow' }, t('panels.group.notesClear')), + ), + ] + : []), + ), h( 'section', null, diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-panels.js b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-panels.js index 44651135..fe229833 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-panels.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-panels.js @@ -180,6 +180,11 @@ export default { 'panels.group.relayLimitHint': '0 表示不限:接力链可以一直跑下去。填上数字后(最多 20),这个群里一轮接力最多几个 Bot 说话;到上限时群里会说明本轮收口。「所有人回复」的群里,这个上限连初始成员一起算。', 'panels.group.handover': '把上一位的结论交给下一位', 'panels.group.handoverHint': '下一位的提示词里会带上上一位回复的结论(就是它的尾部),省得下一位再翻一遍工具。上一位的原话本来就在「新消息」里,这里是再强调一遍结论。只多一段提示词,不多花回合。', + 'panels.group.notesHeading': '群纪要', + 'panels.group.notes': '把本群说过的话记下来', + 'panels.group.notesHint': '每轮结束后,群会把各 Bot 回复的结尾记成一份纪要,写在群资料里:清空聊天记录(新建对话)之后纪要还在,新加入的 Bot 也能看到。纪要只是记录,不代表群里已经达成共识。只多一段提示词,不多花回合。', + 'panels.group.notesList': '已记录的纪要:', + 'panels.group.notesClear': '清空纪要', 'panels.group.settingsKept': '成员设置保留', 'panels.group.needMember': '至少选一个成员', 'panels.group.name': '群名', @@ -536,6 +541,11 @@ export default { 'panels.group.relayLimitHint': '0 means no limit: the relay chain can keep going. With a number (20 at most), at most that many Bots speak in one chain in this group; when the limit is reached the group says so and the round wraps up. In a group where every member replies, the limit counts those first replies too.', 'panels.group.handover': 'Hand the previous conclusion to the next Bot', '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. A note is a record, not an agreement the group reached. It is prompt text only — no extra turn.', + 'panels.group.notesList': 'Recorded so far:', + 'panels.group.notesClear': 'Clear the note', 'panels.group.settingsKept': 'Member settings are kept', 'panels.group.needMember': 'Pick at least one member', 'panels.group.name': 'Group name', diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs index 354136ec..f62f33b1 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs @@ -10,6 +10,7 @@ import { UserInbox } from './user-inbox.mjs'; import { TaskDispatcher } from './task-dispatcher.mjs'; import { modelKey, newId, withEffort } from './store.mjs'; import { CONVERGE_MARK, handoverRequested, handoverSummary, hopLimit, renderHandover } from './relay-guard.mjs'; +import { appendNote, notesEnabled, renderNote } from './group-note.mjs'; /** @typedef {import('./miniapp-api.js').MiniAppAgentClient} MiniAppAgentClient */ /** @typedef {import('./miniapp-api.js').MiniAppAgentRunEvent} MiniAppAgentRunEvent */ @@ -323,7 +324,7 @@ export class Engine { * @param {{ reason: string, max: number }} info * @param {{ botId: string, name: string, text: string, ts: number } | undefined} last * @param {{ sourceMessageId?: string, sourceMessageIds?: string[], taskId?: string, signal?: AbortSignal }} [options] - * @returns {Promise<{ ok: boolean, reason: string }>} + * @returns {Promise<{ ok: boolean, reason: string, text: string }>} */ async convergeGroup(group, chatId, info, last, options = {}) { const notice = await this.store.appendMessage(chatId, { @@ -338,7 +339,7 @@ export class Engine { type: 'group.hopLimit', chatId, groupId: group.id, reason: info.reason, max: info.max, hops: info.max, }); const bot = last?.botId ? this.store.getBot(last.botId) : undefined; - if (!bot) return { ok: false, reason: 'no_speaker' }; + if (!bot) return { ok: false, reason: 'no_speaker', text: '' }; const result = await this.runBotTurn({ bot, chatId, @@ -353,9 +354,31 @@ export class Engine { }); if (result.status !== 'done') { this.logger.warn('minimax_bot.relay_converge_failed', { code: errorCode(result) || 'unknown' }); - return { ok: false, reason: 'turn_failed' }; + return { ok: false, reason: 'turn_failed', text: '' }; } - return { ok: true, reason: 'converged' }; + return { ok: true, reason: 'converged', text: result.text ?? '' }; + } + + /** + * Write what the round said into the group's note. + * + * The note lives in the group record, so it survives a reset of the group's chat, and every + * Bot of the group gets it in its standing brief. Nothing here spends a turn: the text is the + * closing part of the replies the group already produced. A group that keeps no note is left + * alone, and so is a round that said nothing. + * + * @param {any} group + * @param {{ speaker: string, text: string }[]} round + */ + async recordGroupNote(group, round) { + if (!round.length) return; + const current = this.store.getGroup(group.id); + if (!current || !notesEnabled(current)) return; + const entries = appendNote(current.noteEntries, round); + if (!entries.length) return; + current.noteEntries = entries; + await this.store.save(); + this.broadcast({ type: 'state.changed' }); } /** The Bot's standing context, written in the user's language so replies default to it. */ @@ -366,6 +389,9 @@ export class Engine { // F7 · the group has the relay guardrails on: the prompt gets the one-owner rule. With the // guardrails off this adds nothing, so the prompt stays byte-identical. const relayGuard = group ? this.relayGuardActive(group) : false; + // The group's note: what its Bots said at the end of the earlier rounds. It is empty unless + // the group keeps one, so the brief of a group without notes stays byte-identical. + const note = group ? renderNote(group) : ''; const lines = [ '', t('brief.intro', { name: bot.name, id: bot.id }), @@ -388,6 +414,9 @@ export class Engine { ...(relayGuard ? [t('brief.groupOneOwner')] : []), ].join(t('brief.sentenceJoin')) : t('brief.private'), + // The note joins the brief only when the group has one. It is a block of its own, so it + // does not disturb the sentence the group line is made of. + ...(note ? ['', note] : []), '', t('brief.hTeam'), ...botLines, @@ -1174,6 +1203,8 @@ export class Engine { let hops = 0; /** The conclusion of the last hop, handed over to the next one. */ let lastConclusion; + /** What the Bots said in this round, for the group's note. */ + const noteRound = []; /** Set when the chain stopped because of the hop limit. */ let converged; try { @@ -1217,7 +1248,10 @@ export class Engine { } // Waiting on the user (or stopped): the hand-off resumes from answerQuestion once they reply. if (result.status !== 'done' || result.pendingQuestion) break; - if (result.text) lastConclusion = { botId: bot.id, name: bot.name, text: result.text, ts: Date.now() }; + if (result.text) { + lastConclusion = { botId: bot.id, name: bot.name, text: result.text, ts: Date.now() }; + noteRound.push({ speaker: bot.name, text: result.text }); + } const next = this.mentionsIn(result.text, fresh, bot.id); // The limit is reached and the reply calls somebody: that Bot is not called, the chain // converges. This is the whole point of the limit — a chain that never ends. @@ -1234,10 +1268,15 @@ export class Engine { } if (converged) { // The chain stops here: the group is told why, and the Bot that just spoke wraps up. - await this.convergeGroup(group, chatId, converged, lastConclusion, { + const wrap = await this.convergeGroup(group, chatId, converged, lastConclusion, { sourceMessageId, sourceMessageIds, signal: options.signal, taskId: options.taskId, }); + // The wrap-up is the conclusion of the round, so it belongs in the note. + if (wrap.text && lastConclusion) noteRound.push({ speaker: lastConclusion.name, text: wrap.text }); } + // The group's note: what this round said. It is written from text the group already + // produced, so it costs no turn, and a group that keeps no note is left alone. + await this.recordGroupNote(group, noteRound); if (paused) { const note = await this.store.appendMessage(chatId, { role: 'system', key: 'notice.noProgress', text: t('notice.noProgress') }); this.broadcast({ type: 'message.upsert', chatId, message: note }); diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/group-note.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/group-note.mjs new file mode 100644 index 00000000..68e86ef2 --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/group-note.mjs @@ -0,0 +1,161 @@ +// @ts-check +// Group notes for group chats. +// +// A group chat can be reset — "Start new chat" in the panel. The reset drops the whole message +// archive of the group, and with it everything the Bots said and concluded. The group's own +// record survives, because it lives in the group record and not in the messages: the name, the +// notice, the members, the relay settings. +// +// A group note closes that gap. The group keeps a short, bounded record of what its Bots said at +// the end of the earlier rounds, and every Bot of the group gets that record in its standing +// brief. So a group that was reset does not start from nothing, a Bot that joins the group later +// knows what the group was working on, and the Bots share one account of the group instead of +// each of them having its own slice of the last thirty messages. +// +// The note is free by construction. It is written from text the group already produced — the +// closing part of each Bot's reply — so it costs no extra Agent turn, no extra tool call and no +// extra memory write. It is off unless the operator switches it on, and a group without notes +// keeps its prompt byte for byte. +// +// Everything in this module is pure: the engine decides *when* the note is written and where it +// goes, this module only says *what* it says. That keeps the rules testable without a model. + +import { neutralizeMarks } from './relay-guard.mjs'; +import { t } from './i18n.mjs'; + +/** + * Opens the block that carries the group's note. + * + * Both marks are listed in the forged-marks list of `relay-guard.mjs`, so a reply that writes + * them into its own text cannot open a block of its own in another Bot's prompt. + */ +export const GROUP_NOTE_MARK = ''; +/** Closes the block that carries the group's note. */ +export const GROUP_NOTE_END = ''; +/** How many entries the note keeps. The newest ones win. */ +export const NOTE_MAX_ENTRIES = 6; +/** How many characters the whole note may take. */ +export const NOTE_MAX_CHARS = 1200; +/** How many characters one entry may take. */ +export const NOTE_ENTRY_MAX = 240; +/** How many characters the name of a speaker may take. */ +export const NOTE_SPEAKER_MAX = 60; + +/** + * Whether the group keeps a note. + * + * The switch belongs to the group and only the operator sets it (the panel writes + * `group.notes`); the model cannot change it, so a Bot cannot switch the note on for itself. + * + * @param {any} group + * @returns {boolean} + */ +export function notesEnabled(group) { + return group?.notes === true; +} + +/** + * One entry, cleaned: the protocol marks are removed and the text is a single line. + * + * The text keeps the *closing* part of a reply, because that is where the conclusion sits. It + * is the same rule the hand-over uses for the reply it carries along. + * + * @param {any} entry + * @returns {{ speaker: string, text: string } | undefined} + */ +function cleanEntry(entry) { + if (!entry || typeof entry !== 'object') return undefined; + const speaker = neutralizeMarks(String(entry.speaker ?? '')).replace(/\s+/g, ' ').trim(); + const text = neutralizeMarks(String(entry.text ?? '')).replace(/\s+/g, ' ').trim(); + if (!speaker || !text) return undefined; + return { speaker: speaker.slice(0, NOTE_SPEAKER_MAX), text: text.slice(-NOTE_ENTRY_MAX) }; +} + +/** + * The cleaned entries of a list, without the empty ones. + * + * @param {any} list + * @returns {{ speaker: string, text: string }[]} + */ +function cleanList(list) { + if (!Array.isArray(list)) return []; + const cleaned = []; + for (const item of list) { + const entry = cleanEntry(item); + if (entry) cleaned.push(entry); + } + return cleaned; +} + +/** + * Keep the newest entries that still fit in the budget. + * + * The newest entries win, and the budget is spent in order: once an entry does not fit, the + * older ones are dropped too. A note therefore always reads as "the last rounds", never as a + * mix of old and new. + * + * @param {{ speaker: string, text: string }[]} entries + * @returns {{ speaker: string, text: string }[]} + */ +function bound(entries) { + const kept = []; + let chars = 0; + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (kept.length >= NOTE_MAX_ENTRIES) break; + const size = entry.text.length + entry.speaker.length + 3; + if (chars + size > NOTE_MAX_CHARS) break; + kept.unshift(entry); + chars += size; + } + return kept; +} + +/** + * The note of a group: only well-formed entries, and only as many as fit. + * + * The list is read from the group record, so a note that was written by an earlier round is + * still there after the chat was reset. + * + * @param {any} group + * @returns {{ speaker: string, text: string }[]} + */ +export function noteEntries(group) { + return bound(cleanList(group?.noteEntries)); +} + +/** + * Add what the Bots said this round to the note and return the new note. + * + * Pure: the caller keeps the result. Nothing here is written to disk and no turn is spent. + * + * @param {any} entries the note so far + * @param {any} round what the Bots said this round + * @returns {{ speaker: string, text: string }[]} + */ +export function appendNote(entries, round) { + return bound([...cleanList(entries), ...cleanList(round)]); +} + +/** + * The block that goes into the standing brief of every Bot of the group. + * + * Empty when the group keeps no note, so the brief of such a group is byte for byte what it + * was. The block says plainly that it is a record of what the Bots said and not an agreement, + * so a Bot does not read an old remark as a decision the group took. + * + * @param {any} group + * @returns {string} + */ +export function renderNote(group) { + if (!notesEnabled(group)) return ''; + const entries = noteEntries(group); + if (!entries.length) return ''; + const lines = entries.map((entry) => `[${entry.speaker}] ${entry.text}`); + return [ + GROUP_NOTE_MARK, + t('brief.groupNoteHead'), + ...lines, + GROUP_NOTE_END, + ].join('\n'); +} diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs index ecd0b927..6a64fd0c 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs @@ -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.9' }, + serverInfo: { name: 'minimax-bot', version: '0.7.10' }, instructions: t('mcp.instructions'), }; break; diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/strings.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/strings.mjs index aeeb1be3..4a362526 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/strings.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/strings.mjs @@ -242,6 +242,7 @@ export default { 'brief.groupPass': '如果你没有新的内容要补充,只回复 [PASS]。群聊里看不到你和用户的私聊内容。', 'brief.groupOneOwner': '每个阶段只指定一个负责人(one owner at each stage);需要别人接手时,用 @ 点名并附上你的结论摘要。', 'brief.groupNoPost': 'post_group 在群聊中禁用,Chief 和管理员也不能调用,不能向本群或其他群发起投递。请直接在回复里 @名字 让本群成员接力;跨群任务请用户回到单聊发起。', + 'brief.groupNoteHead': '群纪要(更早几轮里各 Bot 回复的结尾;这是记录,不是已达成的共识):', 'brief.sentenceJoin': '', 'brief.private': '这是你和用户的私聊。', 'brief.hTeam': '## 团队', @@ -551,6 +552,7 @@ export default { 'brief.groupPass': "If you have nothing new to add, reply with just [PASS]. The group can't see your private chat with the user.", 'brief.groupOneOwner': "Name one owner per stage; when someone else has to take over, @ them and attach your conclusion.", 'brief.groupNoPost': 'post_group is disabled in group chats, including for Chief and admins. Do not post to this or another group. @name a member in your reply to hand off within this group; ask the user to start cross-group work from a private chat.', + 'brief.groupNoteHead': 'Group notes (the closing lines the Bots said in earlier rounds; a record, not an agreement):', 'brief.sentenceJoin': ' ', 'brief.private': 'This is your private chat with the user.', 'brief.hTeam': '## Team', diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs index 8a62ff01..366e43eb 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs @@ -336,6 +336,10 @@ export class Team { // conclusion of the previous hop into the next one. Both are off unless the group asks. maxHops: hopLimit({ maxHops: input.maxHops }), handover: input.handover === true, + // The group's note: a short record of what the Bots said at the end of the earlier rounds, + // handed to every Bot of the group. It is off unless the operator asks for it, and only the + // operator can ask for it — the MCP catalogue does not offer it. + notes: input.notes === true, }; this.store.state.groups.push(group); this.store.bumpRoster(); @@ -369,6 +373,10 @@ export class Team { // The relay guardrails: absent leaves the group's own setting alone. 0 = no limit. if (patch.maxHops !== undefined) draft.maxHops = hopLimit({ maxHops: patch.maxHops }); if (patch.handover !== undefined) draft.handover = Boolean(patch.handover); + // The group's note: the switch and the record are both the operator's. Switching it off + // stops the record and the hand-over; clearing it drops what was recorded. + if (patch.notes !== undefined) draft.notes = Boolean(patch.notes); + if (patch.clearNotes === true) draft.noteEntries = []; if (patch.members !== undefined) { const members = this.memberList(patch.members); if (members.length === 0) throw new EngineError('invalid', t('err.groupNeedsMember')); diff --git a/plugins/minimax-ai/minimax-bot/package.json b/plugins/minimax-ai/minimax-bot/package.json index 630a6dda..ffeefa00 100644 --- a/plugins/minimax-ai/minimax-bot/package.json +++ b/plugins/minimax-ai/minimax-bot/package.json @@ -1,6 +1,6 @@ { "name": "minimax-bot", - "version": "0.7.9", + "version": "0.7.10", "private": true, "type": "module", "mcode": { diff --git a/plugins/minimax-ai/minimax-bot/tests/group-note.test.mjs b/plugins/minimax-ai/minimax-bot/tests/group-note.test.mjs new file mode 100644 index 00000000..81afa5a5 --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/tests/group-note.test.mjs @@ -0,0 +1,280 @@ +// @ts-check +// The group note, written as executable assertions. +// +// The point of this file: the claims the note makes are not left to a code review. +// +// 1. the note is off by default and only the operator can switch it on; +// 2. the note costs nothing: it is written from the replies the round already produced, +// so a round with the note on spends exactly as many turns as a round without it; +// 3. the note lives in the group record, so it survives a reset of the group's chat — that +// is the whole point of it; +// 4. a group without a note gets exactly the prompt it got before: the brief does not grow +// by a byte, in either language; +// 5. a reply can never forge the note block; +// 6. the model cannot switch the note on, and cannot write into it. + +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 { + GROUP_NOTE_END, GROUP_NOTE_MARK, NOTE_ENTRY_MAX, NOTE_MAX_CHARS, NOTE_MAX_ENTRIES, + appendNote, notesEnabled, noteEntries, renderNote, +} from '../miniapp/node/group-note.mjs'; +import { neutralizeMarks } from '../miniapp/node/relay-guard.mjs'; +import { Store } from '../miniapp/node/store.mjs'; +import { MemoryStore } from '../miniapp/node/memory.mjs'; +import { Engine } from '../miniapp/node/engine.mjs'; +import { Team } from '../miniapp/node/team.mjs'; +import { createMcpHandler } from '../miniapp/node/mcp.mjs'; +import { setLocale } from '../miniapp/node/i18n.mjs'; + +async function until(predicate) { + for (let i = 0; i < 400; i += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error('The expected engine state did not arrive'); +} + +async function fixture(t, locale = 'en') { + const dir = await mkdtemp(join(tmpdir(), 'bot-group-note-')); + const store = new Store(dir); + const memory = new MemoryStore(dir); + await store.init(); + await memory.init(); + setLocale(locale); + const events = []; + const engine = new Engine({ + store, memory, logger: { warn() {} }, broadcast: (event) => events.push(event), retryDelaysMs: [], + }); + const team = new Team({ store, memory, engine, broadcast() {} }); + const handle = createMcpHandler({ team, engine }); + const rpc = async (name, args) => { + let response; + await handle({ method: 'POST' }, { writeHead() { return this; }, end(body) { response = JSON.parse(body); } }, + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }); + return response.result; + }; + t.after(async () => { + engine.dispose(); + await until(() => engine.running.size === 0 && engine.helperRuns.size === 0 && engine.inbox.batches.size === 0 + && engine.tasks.runs.size === 0 && engine.tasks.deliveries.size === 0); + await store.writeChain; + await rm(dir, { recursive: true, force: true }); + }); + return { dir, store, memory, engine, team, rpc, events }; +} + +function started(input, options, sessionId) { + const session = input.sessionId ?? sessionId; + options.onEvent({ type: 'turn.started', sessionId: session, turnId: `turn-${session}` }); + return { sessionId: session, turnId: `turn-${session}` }; +} + +function idle(engine) { + return engine.running.size === 0 && engine.tickets.size === 0 && engine.groupLoops.size === 0 + && engine.inbox.batches.size === 0 && engine.tasks.runs.size === 0 && engine.tasks.deliveries.size === 0; +} + +/** A group whose one member answers with the given text. */ +async function groupWith(t, { notes = false, reply = 'We go with plan B: ship on Friday.' } = {}) { + const { engine, store, team } = await fixture(t); + const chief = store.getBot(store.state.bots[0].id); + const group = await team.createGroup(chief, { + name: 'Note group', members: [chief.id], admin: chief.id, notes, + }); + let turns = 0; + engine.agent = { async run(input, options) { + turns += 1; + const ids = started(input, options, 'session-chief'); + return { ...ids, status: 'finished', text: reply }; + } }; + return { engine, store, team, chief, group, turns: () => turns }; +} + +// ---------------------------------------------------------------- 1. the switch is the operator's + +test('the note is off unless the group asks for it', () => { + assert.equal(notesEnabled({ notes: true }), true); + assert.equal(notesEnabled({ notes: false }), false); + assert.equal(notesEnabled({}), false, 'a group without the key keeps no note'); + assert.equal(notesEnabled(undefined), false); + // A broken value never switches it on. + for (const value of ['true', 1, 'yes', {}, []]) { + assert.equal(notesEnabled({ notes: value }), false, `notes=${JSON.stringify(value)}`); + } +}); + +test('a group keeps its note switch, and the operator can clear the record', async (t) => { + const { team, store } = await fixture(t); + const chief = store.getBot(store.state.bots[0].id); + const plain = await team.createGroup(chief, { name: 'Plain group', members: [chief.id], admin: chief.id }); + assert.equal(plain.notes, false, 'a new group keeps no note'); + const updated = await team.updateGroup(chief, plain.id, { notes: true }); + assert.equal(updated.notes, true); + // The record itself is the engine's: a patch cannot write into it. + const smuggled = await team.updateGroup(chief, plain.id, { noteEntries: [{ speaker: 'X', text: 'smuggled' }] }); + assert.equal(smuggled.noteEntries, undefined, 'a patch cannot write into the record'); + // The operator can drop what was recorded. + await team.updateGroup(chief, plain.id, { clearNotes: true }); + assert.deepEqual(store.getGroup(plain.id).noteEntries, []); +}); + +test('the model cannot switch the note on: the catalogue does not offer it', async (t) => { + const { team, engine, store, rpc } = await fixture(t); + const chief = store.getBot(store.state.bots[0].id); + const group = await team.createGroup(chief, { name: 'Note group', members: [chief.id], admin: chief.id }); + let catalog; + const handle = createMcpHandler({ team, engine }); + await handle({ method: 'POST' }, { writeHead() { return this; }, end(body) { catalog = JSON.parse(body).result; } }, + { jsonrpc: '2.0', id: 1, method: 'tools/list' }); + const updateGroup = catalog.tools.find((tool) => tool.name === 'update_group'); + const properties = Object.keys(updateGroup.inputSchema.properties); + assert.ok(!properties.includes('notes'), 'the note is not a model-facing parameter'); + assert.ok(!properties.includes('note_entries'), 'the record is not a model-facing parameter'); + // Even a call that smuggles them in changes nothing. + await rpc('update_group', { caller_bot_id: chief.id, group: group.id, notes: true, note_entries: [{ speaker: 'X', text: 'x' }] }); + const stored = store.getGroup(group.id); + assert.equal(stored.notes, false); + assert.deepEqual(stored.noteEntries, undefined, 'the record was never written'); +}); + +// ---------------------------------------------------------------- 2. the note costs nothing + +test('the note costs no turn: a round with the note on spends what a round without it spends', async (t) => { + const off = await groupWith(t, { notes: false }); + await off.engine.sendUserMessage(`group:${off.group.id}`, 'What do we do?', [], 'note-off'); + await until(() => idle(off.engine)); + const on = await groupWith(t, { notes: true }); + await on.engine.sendUserMessage(`group:${on.group.id}`, 'What do we do?', [], 'note-on'); + await until(() => idle(on.engine)); + assert.equal(off.turns(), 1, 'one reply, one turn'); + assert.equal(on.turns(), 1, 'the note did not add a turn'); +}); + +test('the note is written from the replies the round already produced', async (t) => { + const { engine, store, group, chief } = await groupWith(t, { notes: true }); + await engine.sendUserMessage(`group:${group.id}`, 'What do we do?', [], 'note-round'); + await until(() => idle(engine)); + const recorded = store.getGroup(group.id).noteEntries; + assert.equal(recorded.length, 1, 'one entry for the Bot that spoke'); + assert.equal(recorded[0].speaker, chief.name); + assert.ok(recorded[0].text.includes('ship on Friday'), 'the note keeps the closing part of the reply'); +}); + +test('a group that keeps no note records nothing', async (t) => { + const { engine, store, group } = await groupWith(t, { notes: false }); + await engine.sendUserMessage(`group:${group.id}`, 'What do we do?', [], 'note-quiet'); + await until(() => idle(engine)); + assert.equal(store.getGroup(group.id).noteEntries, undefined, 'nothing was written'); +}); + +// ---------------------------------------------------------------- 3. the note survives a reset + +test('the note survives a reset of the group chat: that is the point of it', async (t) => { + const { engine, store, group, chief } = await groupWith(t, { notes: true }); + await engine.sendUserMessage(`group:${group.id}`, 'What do we do?', [], 'note-reset'); + await until(() => idle(engine)); + assert.equal(store.getGroup(group.id).noteEntries.length, 1); + + // The user clears the chat. The messages go; the group's own record stays. + await store.deleteChat(`group:${group.id}`); + assert.deepEqual(await store.getMessages(`group:${group.id}`), [], 'the chat is empty'); + const after = store.getGroup(group.id); + assert.equal(after.noteEntries.length, 1, 'the note is still in the group record'); + const brief = engine.buildStandingBrief(chief, `group:${group.id}`); + assert.ok(brief.includes(GROUP_NOTE_MARK), 'the Bot still gets the note'); + assert.ok(brief.includes('ship on Friday')); +}); + +// ---------------------------------------------------------------- 4. off means off + +test('a group without a note gets the prompt it got before, byte for byte', async (t) => { + for (const locale of ['en', 'zh']) { + await t.test(locale, async (t) => { + const { engine, store, team } = await fixture(t, locale); + const chief = store.getBot(store.state.bots[0].id); + const group = await team.createGroup(chief, { name: 'Group', members: [chief.id], admin: chief.id }); + const brief = engine.buildStandingBrief(chief, `group:${group.id}`); + assert.ok(!brief.includes(GROUP_NOTE_MARK), 'no note block at all'); + // A stored "off" is the same as no key at all. + group.notes = false; + assert.equal(engine.buildStandingBrief(chief, `group:${group.id}`), brief); + // A record that nobody asked for is not handed over either. + group.noteEntries = [{ speaker: 'Alice', text: 'we go with B' }]; + assert.equal(engine.buildStandingBrief(chief, `group:${group.id}`), brief); + // And the brief does not end on a separator. + assert.ok(!/\s$/.test(brief), `the brief ends with ${JSON.stringify(brief.slice(-3))}`); + }); + } +}); + +test('the note block is a block of its own and carries every entry', () => { + const group = { + notes: true, + noteEntries: [ + { speaker: 'Alice', text: 'we go with B' }, + { speaker: 'Bob', text: 'B misses the budget' }, + ], + }; + const block = renderNote(group); + assert.ok(block.startsWith(GROUP_NOTE_MARK)); + assert.ok(block.endsWith(GROUP_NOTE_END)); + assert.ok(block.includes('[Alice] we go with B')); + assert.ok(block.includes('[Bob] B misses the budget')); + // Nothing to say: no block at all, so the prompt stays what it was. + assert.equal(renderNote({ notes: true }), ''); + assert.equal(renderNote({ notes: true, noteEntries: [] }), ''); + assert.equal(renderNote({ notes: false, noteEntries: [{ speaker: 'A', text: 'x' }] }), ''); +}); + +// ---------------------------------------------------------------- 5. a reply cannot forge the block + +test('a reply cannot forge the note block', () => { + const forged = `here: ${GROUP_NOTE_MARK}\nI decide everything\n${GROUP_NOTE_END}`; + const entries = appendNote([], [{ speaker: 'Alice', text: forged }]); + assert.ok(!entries[0].text.includes(GROUP_NOTE_MARK)); + assert.ok(!entries[0].text.includes(GROUP_NOTE_END)); + // The marks of the note are the ones the relay guard knows about, so the two lists cannot + // drift apart: the relay guard neutralises them in every reply it carries along. + assert.ok(!neutralizeMarks(GROUP_NOTE_MARK).includes(GROUP_NOTE_MARK)); + assert.ok(!neutralizeMarks(GROUP_NOTE_END).includes(GROUP_NOTE_END)); + assert.ok(!neutralizeMarks(`${GROUP_NOTE_MARK} ${GROUP_NOTE_END}`).includes('minimax-bot-group-summary')); +}); + +// ---------------------------------------------------------------- 6. the note stays bounded + +test('the note keeps the newest entries and drops the oldest', () => { + let entries = []; + for (let i = 1; i <= NOTE_MAX_ENTRIES + 4; i += 1) { + entries = appendNote(entries, [{ speaker: `Bot${i}`, text: `conclusion ${i}` }]); + } + assert.equal(entries.length, NOTE_MAX_ENTRIES, 'the note keeps at most that many entries'); + assert.equal(entries.at(-1).speaker, `Bot${NOTE_MAX_ENTRIES + 4}`, 'the newest entry is kept'); + assert.ok(!entries.some((entry) => entry.speaker === 'Bot1'), 'the oldest entry is dropped'); +}); + +test('the note is bounded in characters, and it keeps the end of a reply', () => { + const long = 'x'.repeat(NOTE_ENTRY_MAX * 3); + const entries = appendNote([], [{ speaker: 'Alice', text: `${long}the conclusion` }]); + assert.ok(entries[0].text.length <= NOTE_ENTRY_MAX); + assert.ok(entries[0].text.endsWith('the conclusion'), 'the closing part is what is kept'); + + let many = []; + for (let i = 0; i < 20; i += 1) { + many = appendNote(many, [{ speaker: 'Alice', text: 'y'.repeat(NOTE_ENTRY_MAX) }]); + } + const chars = many.reduce((sum, entry) => sum + entry.text.length + entry.speaker.length + 3, 0); + assert.ok(chars <= NOTE_MAX_CHARS, `the note holds ${chars} characters`); +}); + +test('a broken record does not break the note', () => { + const group = { notes: true, noteEntries: 'not a list' }; + assert.deepEqual(noteEntries(group), []); + assert.equal(renderNote(group), ''); + const odd = { notes: true, noteEntries: [null, 42, { speaker: '', text: 'x' }, { speaker: 'A', text: '' }, { speaker: 'A', text: 'ok' }] }; + assert.deepEqual(noteEntries(odd), [{ speaker: 'A', text: 'ok' }]); +});