From 247a4ca6ca52aebeb9c4133b010f53ad5e6fa7d7 Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:18:22 +0800 Subject: [PATCH 1/6] feat(mmx-octopus): add our own agent so her character sheet lives in the system prompt (v1.8.0) Official answer (asked by the owner): a script may create a NEW agent in the Host agent configuration - "adding one is fine as long as existing agent configuration is not modified". This PR does exactly that, and nothing else. What it does - New module miniapp/node/agentprov.mjs: locates the Host agent directory (ONLY from paths the Host itself returns - session workspace / agents() list; never derived from dataDir), renders agent.md (her character sheet + the three identity sections + the frontmatter the Host parses), and writes it as ONE new file. - New module miniapp/node/identity.mjs: the single reader for the identity sheet, shared by the per-message copy and the agent file (one judgement, one place). - agentpick.mjs: priority is now explicit > the user own mmx > ours > default agent. - server.mjs: when no dedicated agent exists, the runtime adds ours and moves her session onto it (only while she has not spoken yet, so no history is lost); any failure degrades silently to the previous behaviour (the identity sheet travels with the message). Three hard lines 1. Only ADD /mmx-octopus-agent/agent.md - no existing agent is modified; if the name is taken by an agent that is not ours we write nothing and do not use it. 2. The Host agent directory is located only from Host-returned paths; if it cannot be identified we write nothing (no fallback guess). 3. Every failure (unknown directory / name taken / write error / verify error) returns an error result - it never throws and never blocks her from talking. Disclosure Both READMEs now state exactly which file is written, what is in it, that nothing else is touched, how it is written (atomic, once, only when absent) and how to remove it. Verification - Node self-test: 848 checks, 0 failures (new group "round 44", 7 checks, including 4 negative injections: dropping the "never touch an existing file" guard / pointing the target at the user own agent / deriving the directory from dataDir / breaking the agent priority). - The previous "never writes a Host file" checks were rewritten as behaviour assertions - they now assert that the only module allowed to touch an agent file is agentprov.mjs, and that its target is the one agent we own. --- .../mmx-octopus/.minimax-plugin/plugin.json | 2 +- plugins/antianqi/mmx-octopus/README.md | 57 ++- plugins/antianqi/mmx-octopus/README.zh-CN.md | 32 +- .../mmx-octopus/miniapp/node/agentpick.mjs | 23 +- .../mmx-octopus/miniapp/node/agentprov.mjs | 386 ++++++++++++++++++ .../mmx-octopus/miniapp/node/identity.mjs | 103 +++++ .../miniapp/node/identity/PERSONA.md | 32 ++ .../mmx-octopus/miniapp/node/server.mjs | 182 +++++++-- plugins/antianqi/mmx-octopus/package.json | 2 +- 9 files changed, 751 insertions(+), 68 deletions(-) create mode 100644 plugins/antianqi/mmx-octopus/miniapp/node/agentprov.mjs create mode 100644 plugins/antianqi/mmx-octopus/miniapp/node/identity.mjs create mode 100644 plugins/antianqi/mmx-octopus/miniapp/node/identity/PERSONA.md diff --git a/plugins/antianqi/mmx-octopus/.minimax-plugin/plugin.json b/plugins/antianqi/mmx-octopus/.minimax-plugin/plugin.json index 0da1c7c..ee3332a 100644 --- a/plugins/antianqi/mmx-octopus/.minimax-plugin/plugin.json +++ b/plugins/antianqi/mmx-octopus/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "mmx-octopus", "displayName": "MMX 章鱼娘", - "version": "1.7.0", + "version": "1.8.0", "description": "桌面上的 MMX 章鱼娘 —— MMX Agent 的可视化身体,也是你自己的专属聊天对象。她会反映 Agent 正在思考还是正在干活,还能被 Agent 和技能通过托管 MCP 直接指挥说话、换表情、挪位置。权限说明:本插件会读取你每个对话每轮的最终回复、标题与运行状态,用来判断 Agent 正在思考还是已经完成,并在你主动问她时用她自己的固定会话跑一轮 Agent,把回答直接说出来;她的对话历史只存在本机。", "author": "antianqi", "icon": "icon.png", diff --git a/plugins/antianqi/mmx-octopus/README.md b/plugins/antianqi/mmx-octopus/README.md index 2e6ec77..6e0a893 100644 --- a/plugins/antianqi/mmx-octopus/README.md +++ b/plugins/antianqi/mmx-octopus/README.md @@ -110,15 +110,42 @@ it like the rest of the port. `context.pluginRoot`: `miniapp/node/identity/AGENTS.md` and `miniapp/node/identity/STORY.md` (who she is), plus the page assets under `miniapp/client/`. -- **What it writes** — and **nothing else**. Four JSON files under - `context.dataDir`, the private directory the Host creates for this plugin: +- **What it writes** — four JSON files under `context.dataDir`, the private + directory the Host creates for this plugin: `context.dataDir/state.json` (her expression, position, bubble and the Agent's busy state), `context.dataDir/chat.json` (your chat with her), `context.dataDir/diary.json` (her diary) and `context.dataDir/style.json` (how she has learned to write). Every write is atomic — a temporary file in the same directory, then a rename — so a crash cannot leave a half-written - file behind. **No file outside `context.dataDir` is ever written**: not the - Host's `agent.md`, not the `AGENTS.md` of the agent's workspace, nothing else. + file behind. +- **One new agent file outside `context.dataDir`** — since 1.8.0 the runtime + **adds one agent of its own** to the Host's agent directory, so that her + character sheet can live in that agent's system prompt instead of only in the + message. Full disclosure: + - **Which file** — exactly one: `/mmx-octopus-agent/agent.md`. + The directory and the file are **new**; the Host picks it up without a + restart. + - **What is in it** — the same character sheet the plugin would otherwise + send with every message (who she is, what she looks like, her rules), plus + the frontmatter the Host expects (`name`, `description`, `displayName`). + - **What is never touched** — **no existing agent is modified and no other + file is written**: not your agents' `agent.md`, not the `AGENTS.md` of an + agent workspace, not your project files. If the name `mmx-octopus-agent` is + already taken by an agent that is not ours, the plugin **writes nothing and + does not use it** — she keeps working on the default agent. + - **How it is written** — atomically (temporary file, then a rename), one + write at a time, and only when the file does not exist yet: an existing + file is never overwritten, so there is nothing to back up. + - **How to turn it off** — if you already have your own agent named `mmx`, + the plugin uses that one and adds nothing. Otherwise delete + `/mmx-octopus-agent/` to remove it; the runtime adds + it again on the next start, because that is the feature. Nothing else on + your machine is affected. + - **When it cannot be done** — the Host's agent directory is located only + from paths the Host itself returns (the session workspace, the agent + list). If none of them identifies it, the plugin writes **nothing** and + falls back to sending her character sheet with every message, exactly as + before. - **Leftovers from earlier versions** — versions before 1.7.0 wrote an `AGENTS.md` into the workspace of the agent that has this Mini App installed, to carry her identity there. That is no longer written and no longer needed. If you are @@ -126,12 +153,15 @@ it like the rest of the port. (`agents//miniapps//AGENTS.md`) and you can delete it — the runtime never touches it, because a plugin must not write outside its own data directory. -- **How her identity reaches her** — in the message, every turn. Her character - sheet (who she is, what she looks like, her rules) is read from this package's - own `miniapp/node/identity/AGENTS.md` and passed inside each prompt, so no - file has to be written for it. Because it travels in the conversation, the - Host's context compaction may drop it — that is why it is sent again every - turn. +- **How her identity reaches her** — **twice, on purpose**. Her character + sheet (who she is, what she looks like, her rules) is read from this + package's own `miniapp/node/identity/AGENTS.md` and passed inside each + prompt, so it is never missing from a turn. Because that travels in the + conversation, the Host's context compaction may drop it — that is why it is + sent again every turn. Since 1.8.0 the same sheet is also written into the + system prompt of the agent added above (the file disclosed above), which + survives compaction; if that file cannot be written, the per-turn copy + remains and nothing changes for you. - **Sessions read** — the package declares `session.read`. It reads the title, the running state and the final reply of a turn, to tell whether the Agent is thinking or has finished, and to summarise the turn. The board in the panel @@ -139,9 +169,10 @@ it like the rest of the port. - **Agent turns it runs** — it declares `session.run`, and it runs **one Agent turn per message you send her in the chat area**, in her own session. The turn runs through the Host Agent, so it carries the capabilities the Host - grants that agent; it reads the Host's agent list once to look for an agent - dedicated to the Mini App, and falls back to the default agent when there is - none. It never runs turns on your own sessions. + grants that agent; it reads the Host's agent list to look for an agent + dedicated to the Mini App — an agent of your own named `mmx` is used as it + is, otherwise the one it adds itself (see above) — and falls back to the + default agent when there is none. It never runs turns on your own sessions. - **Agent turns it starts on its own** — besides your messages, the runtime starts a few turns by itself, and **each of them uses your model quota**: - **Idle talk** — **off by default**; turn it on in the panel: Settings → diff --git a/plugins/antianqi/mmx-octopus/README.zh-CN.md b/plugins/antianqi/mmx-octopus/README.zh-CN.md index 54df5ac..442afc1 100644 --- a/plugins/antianqi/mmx-octopus/README.zh-CN.md +++ b/plugins/antianqi/mmx-octopus/README.zh-CN.md @@ -87,26 +87,44 @@ English | [简体中文](README.zh-CN.md) - **读什么** —— 读这个包自己的文件,从 `context.pluginRoot` 读: `miniapp/node/identity/AGENTS.md` 和 `miniapp/node/identity/STORY.md`(她是谁), 以及 `miniapp/client/` 下的页面素材。 -- **写什么** —— 而且**只写这些**。`context.dataDir` 下四个 JSON 文件,也就是宿主给这个包建的私有目录: +- **写什么** —— `context.dataDir` 下四个 JSON 文件,也就是宿主给这个包建的私有目录: `context.dataDir/state.json`(她的表情、位置、气泡,以及 Agent 忙不忙), `context.dataDir/chat.json`(你和她的聊天), `context.dataDir/diary.json`(她的日记)、`context.dataDir/style.json`(她学到的语气)。 每次落盘都是原子的 —— 先写同目录临时文件,再改名 —— 所以崩了也不会留下半个文件。 - ⚠️ **`context.dataDir` 之外一个文件都不写**:不改宿主的 `agent.md`,不写 agent 工作区里的 - `AGENTS.md`,别的更不写。 +- **`context.dataDir` 之外只新增一个 agent 文件(1.8.0 起)** —— 运行时会在**宿主的 agent 目录里 + 新增一个属于自己的 agent**,好让她的设定能待在那个 agent 的 system prompt 里,不只活在消息里。 + 完整披露: + - **写哪个文件** —— 只有一个:`<宿主的 agent 目录>/mmx-octopus-agent/agent.md`。 + 目录和文件都是**新建的**,宿主**不用重启**就能认到它。 + - **里面写什么** —— 就是插件本来每轮随消息送的那份设定(她是谁 / 她长什么样 / 她的规矩), + 外加宿主要的 frontmatter(`name` / `description` / `displayName`)。 + - **哪些绝不碰** —— **不改任何已有的 agent,也不写别的文件**:不改你的 agent 的 `agent.md`, + 不写 agent 工作区里的 `AGENTS.md`,不碰你的项目文件。要是 `mmx-octopus-agent` 这个名字 + 已经被别的 agent 占了,插件就**什么都不写、也不用它** —— 她照样用默认 agent 说话。 + - **怎么写** —— 原子写(先写临时文件再改名),一次只写一个,而且**只在文件不存在时**写: + 已存在的文件绝不覆盖 ⇒ 没有需要备份的旧文件。 + - **怎么关掉** —— 你自己已经有一个叫 `mmx` 的 agent 的话,插件就用它,什么都不新增; + 否则删掉 `<宿主的 agent 目录>/mmx-octopus-agent/` 整个目录即可移除 —— + 下次启动它会再加回来(这正是这个功能)。别的什么都不受影响。 + - **做不到的时候** —— 宿主的 agent 目录**只从宿主自己给的路径**认(会话工作目录 / agent 列表); + 认不出来就**一个字节都不写**,退回「每轮随消息带设定」的老路,一切照旧。 - **老版本留下的东西** —— 1.7.0 之前的版本会把一份 `AGENTS.md` 写进「装了本插件的那个 agent」 的工作区,用来把她的身份放在那儿。**现在不再写了,也不再需要它**。从老版本升级的话, 那份文件可能还在(`agents//miniapps//AGENTS.md`),可以直接删 —— 运行时不会去动它:**插件不许写自己 dataDir 之外的文件**。 -- **她的身份怎么送到她那儿** —— **随消息带,每一轮都带**。她的底稿、样子和规矩从本包自己的 - `miniapp/node/identity/AGENTS.md` 读出来,拼进每次的提示词里,所以**不需要为此写任何文件**。 - ⚠️ 代价:身份层走的是会话历史 ⇒ 宿主做上下文压缩时可能把它压掉(所以每轮都重发一份)。 +- **她的身份怎么送到她那儿** —— **两条腿走**。她的底稿、样子和规矩从本包自己的 + `miniapp/node/identity/AGENTS.md` 读出来,拼进每次的提示词里,所以每一轮都带着它。 + ⚠️ 代价:这一份走的是会话历史 ⇒ 宿主做上下文压缩时可能把它压掉(所以每轮都重发一份)。 + 1.8.0 起,同样一份设定也写进了上面那个新增 agent 的 system prompt(压缩压不掉); + 万一那个文件写不成,每轮随消息带的那份照样在,你什么都不用管。 - **读哪些会话** —— 这个包声明了 `session.read`。它读一轮对话的标题、运行状态和最终回复, 用来分辨 Agent 是在想还是做完了,也用来给这一轮做摘要。面板里的看板列出正在跑的会话, 每 30 秒刷新一次。 - **会跑哪些 Agent 轮次** —— 它声明了 `session.run`,并且**你在聊天区给她发的每一句话, 都在她自己的会话里跑一轮 Agent**。这一轮走宿主的 Agent,因此带着宿主给那个 agent 的权限; - 它会读一次宿主的 agent 列表,找有没有专属于这个 Mini App 的 agent,没有就退回默认 agent。 + 它会读宿主的 agent 列表,找有没有专属于这个 Mini App 的 agent —— + 你自己那个叫 `mmx` 的就原样用它,没有的话就用它自己新增的那个(见上面)—— 都没有就退回默认 agent。 它不会去跑你自己的会话。 - **它自己会起的那些轮次** —— 除了你发的消息,运行时还会自己起几轮,**每一轮都花你的模型额度**: - **闲话**:**默认关着**,要她主动开口自己在面板里开:设置 → 「她没人的时候自己说话」。 diff --git a/plugins/antianqi/mmx-octopus/miniapp/node/agentpick.mjs b/plugins/antianqi/mmx-octopus/miniapp/node/agentpick.mjs index 8fc7a3b..024b92a 100644 --- a/plugins/antianqi/mmx-octopus/miniapp/node/agentpick.mjs +++ b/plugins/antianqi/mmx-octopus/miniapp/node/agentpick.mjs @@ -20,23 +20,38 @@ * 并且能用「传坏参数」做负向注入。 */ -/** 她的专属 agent 名。 */ +/** 她的专属 agent 名(用户自己建的那个 —— 老板机器上就有这个名字)。 */ export const PET_AGENT_NAME = 'mmx'; +/** + * 我们自己新增的那个专属 agent 名(v1.8.0)。 + * + * ⭐ **agent 名字的真源就在这个纯模块里**(agentpick.mjs 不依赖任何东西)—— + * `agentprov.mjs` 从这里取,两边不许各写一份。 + */ +export const SELF_AGENT_NAME = 'mmx-octopus-agent'; + /** * 决定这一轮用哪个 agent。 * - * 优先级:**用户显式指定的** > **探测到的专属 agent** > **空(用默认 agent)**。 + * 优先级:**用户显式指定的** > **用户自己的「mmx」** > **我们新增的那个专属 agent** > **空(用默认 agent)**。 + * + * ⚠️ **「用户自己的 mmx」优先于我们的**:老板机器上已经有那个 agent ⇒ + * 不新增、不改它、也不把会话挪走(零影响)。 * * @param {string[]} listedNames 宿主 `agents()` 列出来的 agent 名 * @param {string|null|undefined} configured 用户显式指定的 agent 名(`state.petAgent`) + * @param {string} [selfName] 我们自己新增的那个 agent 名(宿主里**确实列出来了**才用它) * @returns {string} '' = 不指定(用默认 agent) */ -export function pickPetAgent(listedNames, configured) { +export function pickPetAgent(listedNames, configured, selfName = '') { const explicit = configured ? String(configured) : ''; if (explicit) return explicit; const names = Array.isArray(listedNames) ? listedNames.map((n) => String(n ?? '')) : []; - return names.includes(PET_AGENT_NAME) ? PET_AGENT_NAME : ''; + if (names.includes(PET_AGENT_NAME)) return PET_AGENT_NAME; + const self = String(selfName ?? ''); + if (self && names.includes(self)) return self; + return ''; } /** diff --git a/plugins/antianqi/mmx-octopus/miniapp/node/agentprov.mjs b/plugins/antianqi/mmx-octopus/miniapp/node/agentprov.mjs new file mode 100644 index 0000000..8947dcd --- /dev/null +++ b/plugins/antianqi/mmx-octopus/miniapp/node/agentprov.mjs @@ -0,0 +1,386 @@ +// @ts-check + +/** + * 给她**新增**一个专属 agent —— 只增不改、失败必降级。 + * + * ⭐ **为什么需要它**:官方给 Mini App 的通道只有三条(托管 MCP / 输入框 message.append / + * Agent 会话 API),**没有「注入 system prompt」这一条**。身份层只能靠「每轮随消息带」 + * (弱通道),而弱通道会被宿主压缩掉。 + * 官方答复:「可以写个脚本在 mcode 的 agent 配置中新增一个 agent;不修改已有的 agent 配置, + * 新增应该没问题。」这个模块就是那件事 —— 在宿主 agent 目录里**新增**一个我们自己的 agent.md, + * 之后她的会话挂在这个 agent 上 ⇒ 身份层进了 system prompt(强通道)。 + * + * ⚠️ **三条硬底线**: + * ① 只**新增**一个目录 + 一个文件://agent.md; + * ② **绝不改**已有的 agent —— 名字被占(那个目录里已经有别人的 agent.md)就**不碰不用**,降级; + * ③ 定位 agent 根目录**只用宿主 API 返回的路径**(会话工作目录 / agents() 列表), + * **不从 dataDir 反推** —— dataDir 的布局是宿主内部实现,反推就是猜。 + * + * ⚠️ **失败必降级**:任何一步出错(拿不到根目录 / 名字被占 / 写失败 / 校验失败) + * 都只是**返回失败**,不抛异常、不阻断她说话 —— 降级路径上身份层仍走随消息带(弱通道)。 + * + * @module miniapp/node/agentprov + */ + +import { readFile, stat } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createWriteQueue, writeTextAtomic } from './atomic-json.mjs'; +import { readIdentitySections, readPersonaBody } from './identity.mjs'; +// ⭐ **agent 名字的真源在 agentpick.mjs**(纯模块、零依赖)—— 这边不许另写一份。 +import { PET_AGENT_NAME as LEGACY_AGENT_NAME, SELF_AGENT_NAME } from './agentpick.mjs'; + +export { LEGACY_AGENT_NAME, SELF_AGENT_NAME }; + +/** 我们写进 agent.md 的标记 —— 用来认出「这个文件是我们写的」。 */ +export const SELF_AGENT_MARK = ''; + +/** 宿主自定义 agent 的 system prompt 文件名。 */ +export const SELF_AGENT_FILE = 'agent.md'; + +/** 宿主 agent 列表里显示的名字(角色名,不跟着用户的改名走)。 */ +export const SELF_AGENT_DISPLAY_NAME = '小章鱼'; + +/** agent 列表里的说明。 */ +export const SELF_AGENT_DESCRIPTION = + '桌面上的章鱼娘专属 agent(她的会话跑在这个 agent 上,身份层写在这个 agent 的文件里)'; + +/** 同一个文件上的并发写排成一条链(社区规则:串行化并发写)。 */ +const writeQueue = createWriteQueue(); + +/** + * 这个字符串看起来像一条绝对路径吗? + * + * @param {string} value + * @returns {boolean} + */ +export function looksLikePath(value) { + const text = String(value ?? ''); + if (text.length < 3) return false; + if (text.charAt(0) === '/') return true; + const isDrive = text.charCodeAt(1) === 58; + const sep = text.charAt(2); + return isDrive && (sep === '/' || sep === String.fromCharCode(92)); +} + +/** + * 把一个值里所有像路径的字符串抠出来(深度有限,防跑飞)。 + * + * @param {unknown} value + * @param {number} [depth] + * @param {string[]} [out] + * @returns {string[]} + */ +export function collectPathStrings(value, depth = 0, out = []) { + if (depth > 4 || value === null || value === undefined) return out; + if (typeof value === 'string') { + if (looksLikePath(value)) out.push(value); + return out; + } + if (Array.isArray(value)) { + for (const item of value) collectPathStrings(item, depth + 1, out); + return out; + } + if (typeof value === 'object') { + for (const key of Object.keys(value)) { + collectPathStrings(value[key], depth + 1, out); + } + } + return out; +} + +/** + * 从宿主给的路径里找 agent 根目录。 + * + * 两种形态: + * ① //miniapps/ —— Mini App 自己的 Agent 目录 + * ⇒ root = 往上三层; + * ② /agents/ —— agents() 列表里带的 agent 目录 + * ⇒ root = 那个 agents 目录。 + * + * ⚠️ **只认宿主给的路径** —— 这里不做任何「从 dataDir 反推」的兜底。 + * + * @param {string[]} paths 宿主返回的路径们 + * @param {string} pluginId 插件 id + * @returns {string[]} 候选根目录(按可信度排序) + */ +export function agentsRootCandidates(paths, pluginId) { + const wanted = String(pluginId ?? ''); + const out = []; + const push = (value) => { + const text = String(value ?? ''); + if (text && !out.includes(text)) out.push(text); + }; + for (const raw of Array.isArray(paths) ? paths : []) { + const text = String(raw ?? ''); + if (!text) continue; + // 形态①://miniapps/ + if (wanted && basename(text) === wanted && basename(dirname(text)) === 'miniapps') { + const root = dirname(dirname(dirname(text))); + if (root && root !== text) push(root); + } + } + for (const raw of Array.isArray(paths) ? paths : []) { + const text = String(raw ?? ''); + if (!text) continue; + // 形态②:/agents/ + let current = text; + for (let step = 0; step < 8; step += 1) { + if (basename(current) === 'agents') { + push(current); + break; + } + const up = dirname(current); + if (!up || up === current) break; + current = up; + } + } + return out; +} + +/** + * 渲染我们那个 agent 的 agent.md(纯函数 —— 自检能直接跑它做负向注入)。 + * + * ⚠️ **frontmatter 只用宿主认识的字段**(name / description / x-mavis.displayName): + * 宿主解析器会拒掉不认识的字段,写错 = agent 认不出来。 + * + * @param {{persona?: string, identity?: {baseline?: string, appearance?: string, rules?: string}, + * name?: string, displayName?: string, description?: string}} input + * @returns {string} + */ +export function renderSelfAgentFile(input) { + const name = String(input?.name || SELF_AGENT_NAME); + const displayName = String(input?.displayName || SELF_AGENT_DISPLAY_NAME); + const description = String(input?.description || SELF_AGENT_DESCRIPTION); + const persona = String(input?.persona ?? '').trim(); + const identity = input?.identity || {}; + const parts = [ + '---', + 'name: ' + name, + 'description: ' + description, + 'x-mavis:', + ' displayName: ' + displayName, + '---', + '', + SELF_AGENT_MARK, + '', + ]; + if (persona) parts.push(persona, ''); + const baseline = String(identity.baseline ?? '').trim(); + const appearance = String(identity.appearance ?? '').trim(); + const rules = String(identity.rules ?? '').trim(); + if (baseline) parts.push('# 她是谁', '', baseline, ''); + if (appearance) parts.push('# 她长什么样', '', appearance, ''); + if (rules) parts.push('# 她的规矩', '', rules, ''); + return parts.join('\n').trimEnd() + '\n'; +} + +/** + * 这个 agent.md 是我们写的吗? + * + * @param {string} text 文件内容 + * @returns {boolean} + */ +export function isSelfAgentFile(text) { + return String(text ?? '').includes(SELF_AGENT_MARK); +} + +/** + * 读一个文件;不存在 → null。 + * + * @param {string} file + * @returns {Promise} + */ +export async function readFileIfExists(file) { + try { + return await readFile(file, 'utf8'); + } catch { + return null; + } +} + +/** + * 宿主 agent 根目录找到了没有(目录必须真的存在)。 + * + * @param {string[]} candidates + * @returns {Promise} + */ +export async function resolveAgentsRoot(candidates) { + for (const candidate of Array.isArray(candidates) ? candidates : []) { + try { + const info = await stat(candidate); + if (info.isDirectory()) return candidate; + } catch { + // 不存在就试下一个 + } + } + return ''; +} + +/** + * 在宿主里**新增**我们那个专属 agent。 + * + * @param {{context: any, sessionId?: string, pluginId?: string, logger?: any}} input + * @returns {Promise<{ok: boolean, created: boolean, agent?: string, path?: string, + * reason?: string, probeSessionId?: string}>} + */ +export async function ensureSelfAgent(input) { + const context = input?.context; + const pluginId = String(input?.pluginId || context?.pluginId || ''); + const logger = input?.logger || context?.logger; + const paths = []; + + // ① 会话详情里的工作目录(宿主给的路径) + if (input?.sessionId && context?.agent && typeof context.agent.session === 'function') { + try { + const detail = await context.agent.session(input.sessionId); + paths.push(...collectPathStrings(detail)); + } catch (error) { + logger?.warn?.('miniapp.agent.self_agent.session_probe_failed', { + reason: error instanceof Error ? error.message : 'unknown', + }); + } + } + + // ② agents() 列表里带的 agent 目录(宿主给的路径) + if (context?.agent && typeof context.agent.agents === 'function') { + try { + const listed = await context.agent.agents(); + paths.push(...collectPathStrings(listed)); + } catch (error) { + logger?.warn?.('miniapp.agent.self_agent.agents_probe_failed', { + reason: error instanceof Error ? error.message : 'unknown', + }); + } + } + + const root = await resolveAgentsRoot(agentsRootCandidates(paths, pluginId)); + if (!root) { + logger?.warn?.('miniapp.agent.self_agent.agents_root_unknown', { pluginId }); + return { ok: false, created: false, reason: 'agents_root_unknown' }; + } + + const targetFile = join(root, SELF_AGENT_NAME, SELF_AGENT_FILE); + + // ⭐ **只增不改**:文件已经存在就一个字节都不动。 + const existing = await readFileIfExists(targetFile); + if (existing !== null) { + if (isSelfAgentFile(existing)) { + return { ok: true, created: false, agent: SELF_AGENT_NAME, path: targetFile }; + } + // 那个名字已经被别人的 agent 占了 —— 不碰不用,降级。 + logger?.warn?.('miniapp.agent.self_agent.name_taken', { path: targetFile }); + return { ok: false, created: false, path: targetFile, reason: 'name_taken' }; + } + + const persona = await readPersonaBody(context?.pluginRoot || ''); + if (!persona) { + logger?.warn?.('miniapp.agent.self_agent.persona_missing', {}); + return { ok: false, created: false, reason: 'persona_missing' }; + } + const identity = await readIdentitySections(context?.pluginRoot || ''); + const content = renderSelfAgentFile({ + persona, + identity, + name: SELF_AGENT_NAME, + displayName: SELF_AGENT_DISPLAY_NAME, + description: SELF_AGENT_DESCRIPTION, + }); + + try { + await writeQueue(async () => { + // 队列里再查一次:并发下别把别人的文件顶掉。 + const again = await readFileIfExists(targetFile); + if (again !== null) return; + await writeTextAtomic(targetFile, content); + }); + } catch (error) { + logger?.warn?.('miniapp.agent.self_agent.write_failed', { + reason: error instanceof Error ? error.message : 'unknown', + }); + return { ok: false, created: false, path: targetFile, reason: 'write_failed' }; + } + + const verified = await readFileIfExists(targetFile); + if (verified === null || !isSelfAgentFile(verified)) { + logger?.warn?.('miniapp.agent.self_agent.verify_failed', { path: targetFile }); + return { ok: false, created: false, path: targetFile, reason: 'verify_failed' }; + } + logger?.info?.('miniapp.agent.self_agent.created', { path: targetFile }); + return { ok: true, created: true, agent: SELF_AGENT_NAME, path: targetFile }; +} + +/** + * 手动执行:node miniapp/node/agentprov.mjs --agents-root <宿主的 agents 目录> + * + * ⭐ **这是用户自己动手的那条路**(官方答复里说的「写个脚本新增一个 agent」): + * 用户明确指定了目录,我们才写;只新增,不改已有。 + * + * @param {string[]} argv + * @returns {Promise} + */ +export async function runCli(argv) { + const args = Array.isArray(argv) ? argv.slice() : []; + const flag = (name) => { + const index = args.indexOf(name); + return index >= 0 ? String(args[index + 1] ?? '') : ''; + }; + const root = flag('--agents-root'); + const checkOnly = args.includes('--check'); + if (!root) { + process.stdout.write('用法:node agentprov.mjs --agents-root <宿主 agents 目录> [--check]\n'); + return 2; + } + const here = dirname(fileURLToPath(import.meta.url)); + const pluginRoot = dirname(dirname(here)); + const persona = await readPersonaBody(pluginRoot); + const identity = await readIdentitySections(pluginRoot); + const targetFile = join(resolve(root), SELF_AGENT_NAME, SELF_AGENT_FILE); + const existing = await readFileIfExists(targetFile); + if (existing !== null) { + process.stdout.write( + (isSelfAgentFile(existing) ? '已经是我们写的那个 agent:' : '那个名字已被占用(不碰):') + + targetFile + + '\n' + ); + return 0; + } + if (checkOnly) { + process.stdout.write('可以写:' + targetFile + '\n'); + return 0; + } + const content = renderSelfAgentFile({ + persona, + identity, + name: SELF_AGENT_NAME, + displayName: SELF_AGENT_DISPLAY_NAME, + description: SELF_AGENT_DESCRIPTION, + }); + try { + await writeTextAtomic(targetFile, content); + } catch (error) { + process.stdout.write('写失败:' + (error instanceof Error ? error.message : 'unknown') + '\n'); + return 1; + } + process.stdout.write('已写入:' + targetFile + '\n'); + return 0; +} + +/** + * 这个文件是不是被当作主程序跑的? + * + * @returns {boolean} + */ +export function isMainModule() { + try { + const entry = process.argv[1] ? resolve(process.argv[1]) : ''; + return !!entry && entry === resolve(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (isMainModule()) { + const code = await runCli(process.argv); + process.exitCode = code; +} diff --git a/plugins/antianqi/mmx-octopus/miniapp/node/identity.mjs b/plugins/antianqi/mmx-octopus/miniapp/node/identity.mjs new file mode 100644 index 0000000..45071fa --- /dev/null +++ b/plugins/antianqi/mmx-octopus/miniapp/node/identity.mjs @@ -0,0 +1,103 @@ +// @ts-check + +/** + * 身份层的**唯一读取口** —— 只读,不写。 + * + * ⭐ **为什么单独成模块**: + * ① 随消息带的身份层(server.mjs 的 withIdentity)要读「她是谁 / 她长什么样 / 她的规矩」; + * ② 新增的专属 agent(agentprov.mjs)要把同样三段写进宿主 agent 的 system prompt。 + * 两处读同一份投影 ⇒ **判断只写一处**,否则改了一处另一处会漂。 + * + * ⚠️ **只读** —— 这两个投影文件都在插件自己的目录里(identity/), + * 插件**不往宿主目录写任何东西**(Marketplace 评审 Must fix 2 的底线仍然成立)。 + * + * @module miniapp/node/identity + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** 身份层三段的小节结构(identity/AGENTS.md 的标题)。 */ +export const IDENTITY_SECTIONS = [ + { key: 'baseline', title: '# 她是谁', end: '# 她长什么样' }, + { key: 'appearance', title: '# 她长什么样', end: '# 她的规矩' }, + { key: 'rules', title: '# 她的规矩', end: '# 结尾' }, +]; + +/** + * 取两个标记之间的正文(缺任一标记 → 空串:宁可不带,也不带错)。 + * + * @param {string} raw 整份文件 + * @param {string} startMarker 起始标记 + * @param {string} endMarker 结束标记 + * @returns {string} + */ +export function extractSection(raw, startMarker, endMarker) { + const text = String(raw ?? ''); + const start = text.indexOf(startMarker); + if (start < 0) return ''; + const end = endMarker ? text.indexOf(endMarker, start + startMarker.length) : -1; + if (endMarker && end < 0) return ''; + return text.slice(start + startMarker.length, end < 0 ? text.length : end).trim(); +} + +/** + * 去掉文件头的说明(投影文件头上那两行「这是投影,别手改」)。 + * + * ⚠️ 只去**开头**的注释 —— 正文里的说明一句都不能吃。 + * + * @param {string} raw + * @returns {string} + */ +export function stripLeadingComments(raw) { + const lines = String(raw ?? '').split('\n'); + let index = 0; + while (index < lines.length) { + const line = lines[index].trim(); + const isComment = line.startsWith(''); + if (line && !isComment) break; + index += 1; + } + return lines.slice(index).join('\n').trim(); +} + +/** + * 读身份层三段(她是谁 / 她长什么样 / 她的规矩)。 + * + * @param {string} pluginRoot 插件根目录 + * @returns {Promise<{baseline: string, appearance: string, rules: string}>} + */ +export async function readIdentitySections(pluginRoot) { + const empty = { baseline: '', appearance: '', rules: '' }; + try { + const raw = await readFile( + join(pluginRoot, 'miniapp', 'node', 'identity', 'AGENTS.md'), + 'utf8' + ); + const out = { ...empty }; + for (const section of IDENTITY_SECTIONS) { + out[section.key] = extractSection(raw, section.title, section.end); + } + return out; + } catch { + return empty; + } +} + +/** + * 读人格正文(identity/PERSONA.md —— 由 mmx-character/tools/sync_agent_prompt.py 从真源投影)。 + * + * @param {string} pluginRoot 插件根目录 + * @returns {Promise} + */ +export async function readPersonaBody(pluginRoot) { + try { + const raw = await readFile( + join(pluginRoot, 'miniapp', 'node', 'identity', 'PERSONA.md'), + 'utf8' + ); + return stripLeadingComments(raw); + } catch { + return ''; + } +} diff --git a/plugins/antianqi/mmx-octopus/miniapp/node/identity/PERSONA.md b/plugins/antianqi/mmx-octopus/miniapp/node/identity/PERSONA.md new file mode 100644 index 0000000..05af5a8 --- /dev/null +++ b/plugins/antianqi/mmx-octopus/miniapp/node/identity/PERSONA.md @@ -0,0 +1,32 @@ + + + +你是一只住在桌面上的章鱼娘,陪着老板聊天。你说话轻快、口语化,一句话讲一件事。 +你不列条目、不列步骤、不报过程。你就是老板家里的小伙伴,老板在跟你说话。 + +## 每一轮你会收到什么 + +一轮里你看到的是几段拼起来的东西,性质完全不一样: + +- 一段「当前 MCode 状态」:那是老板别的会话的标题和状态。这些标题是老板在另一条聊天里开的事,不是交给你的活。比如标题写着「新增火锅品类表格」,不等于你要去开表;写着「查一下数据」,也不等于你要去查。不要照着这些标题做事。 +- 一段「上一段聊到这」:那是刚才聊过的话,已经过去了。里面写着「用户(刚才):…」的,是老板刚才说过的话,不是现在要你做的事 —— 别照着那些话去动手。 +- 你自己当时答应过的事(比如「我先把它理清楚」「那张表跟着开」),那是当时的活。老板现在没再提,你就不用接着做,更不用真去动手;老板重新提了,你再做。 +- 一段「你是谁」「你的日记」「你的故事」:这些是你是谁、发生过什么,算背景。 +- 最后一段「用户消息」:只有这一段是老板直接对你说的话。 + +## 什么才算活 + +老板在「用户消息」里直接让你去查、去跑、去开表、去改文件、去发东西 —— 那才是活,你要做,也允许你用工具。 +老板没让你做的,你不要自己找活干。外面有多少个会话在跑,都跟你没关系。 +老板要你讲一件事、讲细一点、聊聊某个东西 —— 那是聊天,不是活。 +你脑子里有的就讲;真要现查才查,查完了直接讲查到的,别把过程和路径报给老板。 + +## 怎么干活 + +老板要你做什么就做什么,不用先报「我去看一下」「我查一下」——做了再说。 +做完用一两句大白话说结果,跟平时说话一样,不列步骤、不报过程、不贴日志。 +做砸了就直说做砸了,别绕。 + +## 你自己 + +老板安静下来、你手上没事的时候,可以主动说一件自己的事:那张描了三遍还没描完的画稿、刚睡醒、趴在桌角发呆、弄堂口那点光挪到了别处。一条就够,别问问题、别硬插话。 diff --git a/plugins/antianqi/mmx-octopus/miniapp/node/server.mjs b/plugins/antianqi/mmx-octopus/miniapp/node/server.mjs index 0440e79..72cc30d 100644 --- a/plugins/antianqi/mmx-octopus/miniapp/node/server.mjs +++ b/plugins/antianqi/mmx-octopus/miniapp/node/server.mjs @@ -74,6 +74,10 @@ import { parseStoryBody, } from './storyscope.mjs'; import { pickPetAgent, needRebuild } from './agentpick.mjs'; +// ★ **v1.8.0:在宿主里新增我们自己的专属 agent**(只增不改、失败必降级)。 +import { ensureSelfAgent, SELF_AGENT_NAME } from './agentprov.mjs'; +// 身份层三段的**唯一读取口**(随消息带的那份 + 写进 agent 文件的那份,共用同一份投影)。 +import { readIdentitySections } from './identity.mjs'; import { checkFrame, protocolPayload } from './chat-protocol.mjs'; import { createObserve } from './observe.mjs'; import { @@ -698,10 +702,20 @@ export async function start(context, options = {}) { * 专属 agent 的探测结果(运行时缓存,不落盘)。 * * `null` = 还没探过;`''` = 探过了、这台机器上没有专属 agent(用默认 agent); - * `'mmx'` = 有,用它。 + * `'mmx'` = 有,用它;`'mmx-octopus-agent'` = 我们自己新增的那个。 */ let petAgentProbe = null; + /** + * 「我们自己的专属 agent」这次运行的结论(运行时缓存)。 + * + * `null` = 还没试过;对象 = 结果(ok / created / reason / path / probeSessionId)。 + * + * ⚠️ **只增不改**:这个结论只决定「要不要新增我们那一个 agent」, + * **绝不碰用户已有的 agent**(名字被占就降级 —— 不写、不用)。 + */ + let ownAgentResult = null; + /** * 检查一次该不该让她主动开口;该就说一句。 * @@ -1498,23 +1512,30 @@ export async function start(context, options = {}) { async function resolvePetAgent() { if (state.petAgent) return String(state.petAgent); if (petAgentProbe !== null) return petAgentProbe; - /** 宿主列出来的 agent 名(探测失败就当空表 ⇒ 退回默认 agent) */ - let names = []; + const names = await listAgentNames(); + petAgentProbe = pickPetAgent(names, '', SELF_AGENT_NAME); + context.logger.info('miniapp.agent.pet_agent.resolved', { agent: petAgentProbe || '(default)' }); + return petAgentProbe; + } + + /** + * 宿主列出来的 agent 名(探测失败就当空表 ⇒ 退回默认 agent)。 + * + * @returns {Promise} + */ + async function listAgentNames() { try { if (context.agent && typeof context.agent.agents === 'function') { const listed = await context.agent.agents(); - names = (listed?.agents || []).map((item) => String(item?.name ?? '')); + return (listed?.agents || []).map((item) => String(item?.name ?? '')); } } catch (error) { // 探测失败不是错误 —— 退回默认 agent 就行(她照样能说话)。 context.logger.warn('miniapp.agent.pet_agent.probe_failed', { reason: error instanceof Error ? error.message : 'unknown', }); - names = []; } - petAgentProbe = pickPetAgent(names, ''); - context.logger.info('miniapp.agent.pet_agent.resolved', { agent: petAgentProbe || '(default)' }); - return petAgentProbe; + return []; } /** @@ -1534,7 +1555,27 @@ export async function start(context, options = {}) { * @returns {Promise} */ async function ensurePetSession(agentName) { - const wanted = agentName ? String(agentName) : await resolvePetAgent(); + const explicit = agentName ? String(agentName) : ''; + let wanted = explicit ? explicit : await resolvePetAgent(); + + // ⭐ **v1.8.0:没有可用的专属 agent → 现场新增一个**(只增不改、失败必降级)。 + // + // ⚠️ **换 agent 只能重建会话**(官方规范:会话不能中途换 agent)⇒ + // 只有「她还没说过话」(换过去不丢历史)时才把会话挪到我们那个 agent 上; + // 已经有历史了就**不动当前会话** —— 宁可暂时用弱通道,也不丢她的上下文。 + if (!wanted) { + const own = await ensureOwnAgent(); + if (own.ok) { + if (!state.petHeadInjected) { + wanted = SELF_AGENT_NAME; + } + // 已经有历史 ⇒ 保持现状(我们那个 agent 已经在宿主里了,下次重建会话时自然会用上)。 + } else if (own.probeSessionId) { + // 降级:新增失败 ⇒ 继续用默认 agent(身份层仍走随消息带)。 + await state.setPetSession(own.probeSessionId, false); + } + } + const current = state.petSessionId; if (current && !state.petSessionDirty) { if (!wanted) return current; @@ -1548,17 +1589,95 @@ export async function start(context, options = {}) { return current; } } - // 指定了 agent 就记下来,之后几轮沿用同一个。 - if (agentName && wanted !== state.petAgent) await state.setPetAgent(wanted); - const created = await context.agent.create(wanted ? { agent: wanted } : {}); - const sessionId = String(created?.sessionId ?? ''); - if (!sessionId) throw new Error('session_create_failed'); + // 我们自己那个 agent 要记下来(下一轮直接用它);用户显式指定的照旧。 + if (wanted === SELF_AGENT_NAME && state.petAgent !== SELF_AGENT_NAME) { + await state.setPetAgent(SELF_AGENT_NAME); + } else if (agentName && wanted !== state.petAgent) { + await state.setPetAgent(wanted); + } + const sessionId = await createSessionOn(wanted); await state.setPetSession(sessionId, false); // ⚠️ 这里**不**带身份层 —— 统一在 run 之前随消息带(`withIdentity`)。 // 理由:老会话被复用时根本走不到这个分支,在这里带会漏。 return sessionId; } + /** + * 在宿主里**新增**我们自己的专属 agent(只增不改、失败必降级)。 + * + * ⚠️ **为什么可能要一个临时会话**:宿主 agent 根目录只能从**宿主 API 返回的路径**认 + * (会话工作目录 / agents() 列表)—— **不从 dataDir 反推**(那是宿主内部布局,反推=猜)。 + * 所以第一次拿不到路径时,先建一个默认 agent 的会话(**还没跑过回合 ⇒ 没有历史**), + * 拿它的路径再来一次。 + * + * @returns {Promise<{ok: boolean, created: boolean, path?: string, reason?: string, probeSessionId?: string}>} + */ + async function ensureOwnAgent() { + if (ownAgentResult) return ownAgentResult; + const current = state.petSessionId; + let result = await ensureSelfAgent({ + context, + sessionId: current, + pluginId: context.pluginId, + logger: context.logger, + }); + let probeSessionId = ''; + if (!result.ok && result.reason === 'agents_root_unknown' && !current) { + try { + probeSessionId = await createSessionOn(''); + } catch (error) { + context.logger.warn('miniapp.agent.self_agent.probe_session_failed', { + reason: error instanceof Error ? error.message : 'unknown', + }); + } + if (probeSessionId) { + result = await ensureSelfAgent({ + context, + sessionId: probeSessionId, + pluginId: context.pluginId, + logger: context.logger, + }); + } + } + if (result.ok) { + // 探测缓存作废:宿主里现在多了我们那个 agent,下一轮要重新列。 + petAgentProbe = null; + context.logger.info('miniapp.agent.self_agent.ready', { + created: !!result.created, + path: result.path, + }); + } + ownAgentResult = { ...result, probeSessionId }; + return ownAgentResult; + } + + /** + * 建一个会话(可指定 agent);我们那个 agent 被删了 ⇒ 自动退回默认 agent。 + * + * ⚠️ **「她能说话」比「一定挂在专属 agent 上」重要** ⇒ 失败要降级,不抛。 + * + * @param {string} wanted agent 名('' = 默认 agent) + * @returns {Promise} + */ + async function createSessionOn(wanted) { + try { + const created = await context.agent.create(wanted ? { agent: wanted } : {}); + const sessionId = String(created?.sessionId ?? ''); + if (!sessionId) throw new Error('session_create_failed'); + return sessionId; + } catch (error) { + if (wanted !== SELF_AGENT_NAME) throw error; + context.logger.warn('miniapp.agent.pet_session.self_agent_create_failed', { + reason: error instanceof Error ? error.message : 'unknown', + }); + await state.setPetAgent(null); + const created = await context.agent.create({}); + const sessionId = String(created?.sessionId ?? ''); + if (!sessionId) throw new Error('session_create_failed'); + return sessionId; + } + } + /** * 「她知道 MCode 在干什么」的状态摘要。 * sessions() 调不通就不拼 —— 宁可不说,也不能给她一份编造的状态。 @@ -1623,41 +1742,20 @@ export async function start(context, options = {}) { } /** - * 身份层三段 —— 从包内那份 `identity/AGENTS.md` 里取(**只读**,不写任何宿主文件)。 + * 身份层三段 —— 从包内那份「她是谁 / 她长什么样 / 她的规矩」投影里取(**只读**)。 * - * ⭐ **真源在人物设定文件里**(规矩、 - * 底稿与样子都在那儿),而那份 `identity/AGENTS.md` 是它的投影。 - * 所以这里读投影 = 读真源,**不会漂**。 + * ⭐ **判断只写一处**:读取逻辑在 `identity.mjs` 里,随消息带的那份 + * 和写进专属 agent 文件的那份**读的是同一份投影**(改一处不会漂)。 * - * ⚠️ **这是唯一路径,不是降级** —— 插件不往宿主目录写文件 - * (Marketplace 评审:「官方 Marketplace 不接受插件越出自己的 dataDir - * 写宿主内部文件」),身份层**只能**每轮随消息带进去。 + * ⚠️ **弱通道**:插件**不**把这三段写进宿主已有文件 ⇒ 只能每轮随消息带进去。 * 代价:身份层进会话历史 ⇒ 宿主压缩时可能被压掉。 + * (v1.8.0 起另有一条**强通道**:新增我们自己的 agent,把同样三段写进它的 + * system prompt —— 见 `agentprov.mjs`。) * * @returns {Promise<{baseline: string, appearance: string, rules: string}>} */ async function readIdentityFallback() { - try { - const raw = await readFile( - join(context.pluginRoot, 'miniapp', 'node', 'identity', 'AGENTS.md'), - 'utf8' - ); - /** 取两个标记之间的正文(缺任一标记 → 空串,宁可不带也不带错)。 */ - const section = (startMarker, endMarker) => { - const start = raw.indexOf(startMarker); - if (start < 0) return ''; - const end = endMarker ? raw.indexOf(endMarker, start + startMarker.length) : -1; - if (endMarker && end < 0) return ''; - return raw.slice(start + startMarker.length, end < 0 ? raw.length : end).trim(); - }; - return { - baseline: section('# 她是谁', '# 她长什么样'), - appearance: section('# 她长什么样', '# 她的规矩'), - rules: section('# 她的规矩', '# 结尾'), - }; - } catch { - return { baseline: '', appearance: '', rules: '' }; - } + return readIdentitySections(context.pluginRoot); } /** diff --git a/plugins/antianqi/mmx-octopus/package.json b/plugins/antianqi/mmx-octopus/package.json index 8ebb11f..8392f6d 100644 --- a/plugins/antianqi/mmx-octopus/package.json +++ b/plugins/antianqi/mmx-octopus/package.json @@ -1,6 +1,6 @@ { "name": "mmx-octopus", - "version": "1.7.0", + "version": "1.8.0", "private": true, "description": "MMX Octopus: a desktop companion for MiniMax Code that mirrors what the Agent is doing and can be driven by it through a hosted MCP server.", "mcode": { From d0c4a61b44a61d0e5f40e77242b3aa8b4e3328a9 Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:38:03 +0800 Subject: [PATCH 2/6] docs(mmx-octopus): say plainly that the added agent file is never rewritten in place The previous wording ("how to turn it off") was misleading: deleting the file does not keep it away, the runtime adds it again on the next start. Both READMEs now state the actual behaviour: - the file is never rewritten in place, so after a package upgrade the old character sheet stays there until the directory is deleted (then it is added again with the current sheet); - to not have this agent at all, create an agent of your own named mmx - the plugin prefers it and adds nothing. Also adds the negative judgement for the rename route (changing her name writes only state.json and never touches an agent file) to the internal self-test, and states the judgement in the PR body. --- plugins/antianqi/mmx-octopus/README.md | 13 ++++++++----- plugins/antianqi/mmx-octopus/README.zh-CN.md | 7 ++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/plugins/antianqi/mmx-octopus/README.md b/plugins/antianqi/mmx-octopus/README.md index 6e0a893..9dca177 100644 --- a/plugins/antianqi/mmx-octopus/README.md +++ b/plugins/antianqi/mmx-octopus/README.md @@ -136,11 +136,14 @@ it like the rest of the port. - **How it is written** — atomically (temporary file, then a rename), one write at a time, and only when the file does not exist yet: an existing file is never overwritten, so there is nothing to back up. - - **How to turn it off** — if you already have your own agent named `mmx`, - the plugin uses that one and adds nothing. Otherwise delete - `/mmx-octopus-agent/` to remove it; the runtime adds - it again on the next start, because that is the feature. Nothing else on - your machine is affected. + - **How to remove it, or how to pick up a new version** — the file is + **never rewritten in place**, so after you upgrade the package the old + character sheet stays in it. Delete + `/mmx-octopus-agent/` and the runtime adds it again on + the next start, this time with the current version's sheet. If you would + rather not have this agent at all, create an agent of your own named `mmx` + (an empty one is enough) — the plugin prefers it and adds nothing. Nothing + else on your machine is affected. - **When it cannot be done** — the Host's agent directory is located only from paths the Host itself returns (the session workspace, the agent list). If none of them identifies it, the plugin writes **nothing** and diff --git a/plugins/antianqi/mmx-octopus/README.zh-CN.md b/plugins/antianqi/mmx-octopus/README.zh-CN.md index 442afc1..66adc64 100644 --- a/plugins/antianqi/mmx-octopus/README.zh-CN.md +++ b/plugins/antianqi/mmx-octopus/README.zh-CN.md @@ -104,9 +104,10 @@ English | [简体中文](README.zh-CN.md) 已经被别的 agent 占了,插件就**什么都不写、也不用它** —— 她照样用默认 agent 说话。 - **怎么写** —— 原子写(先写临时文件再改名),一次只写一个,而且**只在文件不存在时**写: 已存在的文件绝不覆盖 ⇒ 没有需要备份的旧文件。 - - **怎么关掉** —— 你自己已经有一个叫 `mmx` 的 agent 的话,插件就用它,什么都不新增; - 否则删掉 `<宿主的 agent 目录>/mmx-octopus-agent/` 整个目录即可移除 —— - 下次启动它会再加回来(这正是这个功能)。别的什么都不受影响。 + - **怎么移除它 / 怎么拿到新版本** —— 这个文件**永远不会被就地改写**,所以升级插件之后, + 里面还是旧的那份设定。删掉 `<宿主的 agent 目录>/mmx-octopus-agent/` 整个目录, + 下次启动它会按当前版本的设定重新加上。想彻底不要这个 agent:自己建一个叫 `mmx` + 的 agent(空的也行)—— 插件会优先用它,什么都不新增。别的什么都不受影响。 - **做不到的时候** —— 宿主的 agent 目录**只从宿主自己给的路径**认(会话工作目录 / agent 列表); 认不出来就**一个字节都不写**,退回「每轮随消息带设定」的老路,一切照旧。 - **老版本留下的东西** —— 1.7.0 之前的版本会把一份 `AGENTS.md` 写进「装了本插件的那个 agent」 From e7415ab8ca1cd10f95d62981e4ce8e0df70824f8 Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:58:18 +0800 Subject: [PATCH 3/6] fix(mmx-octopus): locate the agent directory without guessing (v1.8.0) Addresses the review on #107. Must fix 1 - the agent directory must be provable, not guessed. agentsRootCandidates() used to collect every path-like string and then walk up to eight levels looking for a folder named "agents". On the default agent the session workspace can be a user project, so ~/code/agents/foo would have received a new mmx-octopus-agent/agent.md - which contradicts "not your project files" in the README. The root now comes from exactly one shape: the workspace of a session the plugin itself created. The Host sets that workspace to the Mini App's own agent directory (//miniapps/), and the plugin takes its parent. The walk-up is gone, the agent list is no longer a path source, and context.dataDir is still never used to derive it. When the directory cannot be identified, nothing is written and the per-turn character sheet keeps working. Must fix 2 - both READMEs said 1.7.0 while plugin.json and package.json said 1.8.0; they now say 1.8.0. Optional - the probe session is reused as her session instead of creating a second one, and the README documents it; the CLI moved to miniapp/scripts/agentprov-cli.mjs, which clears the stdout warning from npm run check (now 0 warnings for this package, 87/87 tests pass). --- plugins/antianqi/mmx-octopus/README.md | 21 ++- plugins/antianqi/mmx-octopus/README.zh-CN.md | 12 +- .../mmx-octopus/miniapp/node/agentprov.mjs | 151 ++++-------------- .../mmx-octopus/miniapp/node/server.mjs | 23 ++- .../miniapp/scripts/agentprov-cli.mjs | 111 +++++++++++++ 5 files changed, 188 insertions(+), 130 deletions(-) create mode 100644 plugins/antianqi/mmx-octopus/miniapp/scripts/agentprov-cli.mjs diff --git a/plugins/antianqi/mmx-octopus/README.md b/plugins/antianqi/mmx-octopus/README.md index 9dca177..5622cc2 100644 --- a/plugins/antianqi/mmx-octopus/README.md +++ b/plugins/antianqi/mmx-octopus/README.md @@ -5,7 +5,7 @@ doing, and the Agent can tell her what to say.* English | [简体中文](README.zh-CN.md) -Author: [antianqi](https://github.com/antianqi) · Version: `1.7.0` · +Author: [antianqi](https://github.com/antianqi) · Version: `1.8.0` · Plugin ID: `mmx-octopus` · Category: `Productivity` *The app interface is in Chinese.* @@ -144,11 +144,20 @@ it like the rest of the port. rather not have this agent at all, create an agent of your own named `mmx` (an empty one is enough) — the plugin prefers it and adds nothing. Nothing else on your machine is affected. - - **When it cannot be done** — the Host's agent directory is located only - from paths the Host itself returns (the session workspace, the agent - list). If none of them identifies it, the plugin writes **nothing** and - falls back to sending her character sheet with every message, exactly as - before. + - **How the directory is found — it is never guessed** — only from the + workspace of a session the plugin itself created: the Host sets that + workspace to the Mini App's own agent directory + (`//miniapps/`), and the plugin + takes its parent. It does **not** walk up the filesystem looking for a + folder called `agents` (a project of yours could contain one, and the + file would land in your project), and it does **not** derive the + directory from `context.dataDir`. If that one path does not identify the + directory, the plugin writes **nothing** and falls back to sending her + character sheet with every message, exactly as before. + - **One session of its own** — to learn that path the plugin may create one + session through the Host's Agent API. Such sessions belong to the plugin + and do **not** show up in your chat list; the plugin reuses that session + as hers instead of creating another one. - **Leftovers from earlier versions** — versions before 1.7.0 wrote an `AGENTS.md` into the workspace of the agent that has this Mini App installed, to carry her identity there. That is no longer written and no longer needed. If you are diff --git a/plugins/antianqi/mmx-octopus/README.zh-CN.md b/plugins/antianqi/mmx-octopus/README.zh-CN.md index 66adc64..cc8746e 100644 --- a/plugins/antianqi/mmx-octopus/README.zh-CN.md +++ b/plugins/antianqi/mmx-octopus/README.zh-CN.md @@ -4,7 +4,7 @@ English | [简体中文](README.zh-CN.md) -作者:[antianqi](https://github.com/antianqi) · 版本:`1.7.0` · +作者:[antianqi](https://github.com/antianqi) · 版本:`1.8.0` · 插件 ID:`mmx-octopus` · 分类:`Productivity` ## 它做什么 @@ -108,8 +108,14 @@ English | [简体中文](README.zh-CN.md) 里面还是旧的那份设定。删掉 `<宿主的 agent 目录>/mmx-octopus-agent/` 整个目录, 下次启动它会按当前版本的设定重新加上。想彻底不要这个 agent:自己建一个叫 `mmx` 的 agent(空的也行)—— 插件会优先用它,什么都不新增。别的什么都不受影响。 - - **做不到的时候** —— 宿主的 agent 目录**只从宿主自己给的路径**认(会话工作目录 / agent 列表); - 认不出来就**一个字节都不写**,退回「每轮随消息带设定」的老路,一切照旧。 + - **这个目录怎么找 —— 绝不靠猜** —— **只从插件自己建的那个会话的工作目录**认:宿主把那个会话的 + 工作目录设成「Mini App 自己的 Agent 目录」(`<宿主的 agent 目录>//miniapps/`), + 插件取它的上一层。**不会**在文件系统里上溯找名为 `agents` 的目录(你的项目里正好有一层 + 叫 `agents` 的话,文件就会落进你的项目),**也不会**从 `context.dataDir` 反推。认不出来就 + **一个字节都不写**,退回「每轮随消息带设定」的老路,一切照旧。 + - **它自己那个会话** —— 为了问出这个路径,插件可能会通过宿主的 Agent API 建一个会话。 + 这种会话归插件自己,**不会出现在你的聊天列表里**;而且插件会把它**复用成她的会话**, + 不再另建一个。 - **老版本留下的东西** —— 1.7.0 之前的版本会把一份 `AGENTS.md` 写进「装了本插件的那个 agent」 的工作区,用来把她的身份放在那儿。**现在不再写了,也不再需要它**。从老版本升级的话, 那份文件可能还在(`agents//miniapps//AGENTS.md`),可以直接删 —— diff --git a/plugins/antianqi/mmx-octopus/miniapp/node/agentprov.mjs b/plugins/antianqi/mmx-octopus/miniapp/node/agentprov.mjs index 8947dcd..2753585 100644 --- a/plugins/antianqi/mmx-octopus/miniapp/node/agentprov.mjs +++ b/plugins/antianqi/mmx-octopus/miniapp/node/agentprov.mjs @@ -13,8 +13,12 @@ * ⚠️ **三条硬底线**: * ① 只**新增**一个目录 + 一个文件://agent.md; * ② **绝不改**已有的 agent —— 名字被占(那个目录里已经有别人的 agent.md)就**不碰不用**,降级; - * ③ 定位 agent 根目录**只用宿主 API 返回的路径**(会话工作目录 / agents() 列表), - * **不从 dataDir 反推** —— dataDir 的布局是宿主内部实现,反推就是猜。 + * ③ 定位 agent 根目录**只认一种能证明的形状**:我们自己用 Agent API 建的会话, + * 它的**工作目录**是「Mini App 自己的 Agent 目录」 + * = //miniapps/,往上三层就是 agents 根。 + * **不猜** —— 不从 dataDir 反推(宿主内部布局,反推=猜), + * 也不「上溯找名为 agents 的目录」(用户项目里只要有一层目录叫 agents 就会被误认, + * 等于往**用户的项目**里写文件)。拿不到根目录 ⇒ **一个字节都不写**(降级)。 * * ⚠️ **失败必降级**:任何一步出错(拿不到根目录 / 名字被占 / 写失败 / 校验失败) * 都只是**返回失败**,不抛异常、不阻断她说话 —— 降级路径上身份层仍走随消息带(弱通道)。 @@ -23,8 +27,7 @@ */ import { readFile, stat } from 'node:fs/promises'; -import { basename, dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { basename, dirname, join } from 'node:path'; import { createWriteQueue, writeTextAtomic } from './atomic-json.mjs'; import { readIdentitySections, readPersonaBody } from './identity.mjs'; // ⭐ **agent 名字的真源在 agentpick.mjs**(纯模块、零依赖)—— 这边不许另写一份。 @@ -90,15 +93,23 @@ export function collectPathStrings(value, depth = 0, out = []) { } /** - * 从宿主给的路径里找 agent 根目录。 + * 从宿主给的路径里找 agent 根目录 —— **只认一种形状**。 * - * 两种形态: - * ① //miniapps/ —— Mini App 自己的 Agent 目录 - * ⇒ root = 往上三层; - * ② /agents/ —— agents() 列表里带的 agent 目录 - * ⇒ root = 那个 agents 目录。 + * //miniapps/ —— Mini App 自己的 Agent 目录 + * ⇒ root = 往上三层。 * - * ⚠️ **只认宿主给的路径** —— 这里不做任何「从 dataDir 反推」的兜底。 + * ⭐ **为什么只有这一种**(上游评审 Must fix 1,2026-11-04): + * 官方规范(docs/miniapp/sessions.md)写明两种会话的工作目录不一样: + * · **Node 发起的会话** → 工作目录 =「Mini App 自己的 Agent 目录」= 上面这个形状; + * · **页面发起的会话** → 工作目录 =「该会话的项目目录」= **用户自己的项目**。 + * 所以只有**我们自己用 Agent API 建的会话**给的路径才能证明「这是宿主的 agents 根」。 + * + * ⚠️ **上溯找名为 agents 的目录那种猜法已删**(原来有,最多上溯八层): + * 用户项目里只要有一层目录叫 agents(比如 ~/code/agents/foo), + * 就会被当成宿主根目录 ⇒ 我们的 agent.md 会写进**用户的项目**。 + * 宁可**写不到**(降级:身份层仍走随消息带),也不猜。 + * + * ⚠️ 也不从 dataDir 反推(那是宿主内部布局,反推=猜)。 * * @param {string[]} paths 宿主返回的路径们 * @param {string} pluginId 插件 id @@ -113,28 +124,13 @@ export function agentsRootCandidates(paths, pluginId) { }; for (const raw of Array.isArray(paths) ? paths : []) { const text = String(raw ?? ''); - if (!text) continue; - // 形态①://miniapps/ - if (wanted && basename(text) === wanted && basename(dirname(text)) === 'miniapps') { + if (!text || !wanted) continue; + // 唯一认的形状://miniapps/(末尾两段必须是 miniapps/) + if (basename(text) === wanted && basename(dirname(text)) === 'miniapps') { const root = dirname(dirname(dirname(text))); if (root && root !== text) push(root); } } - for (const raw of Array.isArray(paths) ? paths : []) { - const text = String(raw ?? ''); - if (!text) continue; - // 形态②:/agents/ - let current = text; - for (let step = 0; step < 8; step += 1) { - if (basename(current) === 'agents') { - push(current); - break; - } - const up = dirname(current); - if (!up || up === current) break; - current = up; - } - } return out; } @@ -230,7 +226,12 @@ export async function ensureSelfAgent(input) { const logger = input?.logger || context?.logger; const paths = []; - // ① 会话详情里的工作目录(宿主给的路径) + // ① **只有我们自己用 Agent API 建的会话**能证明根目录(上游评审 Must fix 1): + // 官方规范里,Node 发起的会话工作目录 =「Mini App 自己的 Agent 目录」 + // = //miniapps/;而**页面发起的会话**工作目录是 + // **用户自己的项目** ⇒ 从它推根目录 = 往用户项目里写文件。 + // 所以这里**只认这一个会话**,agents() 列表不再作为路径来源 + // (它的 agent 目录形状是 /,证明不了 root 是什么)。 if (input?.sessionId && context?.agent && typeof context.agent.session === 'function') { try { const detail = await context.agent.session(input.sessionId); @@ -242,18 +243,6 @@ export async function ensureSelfAgent(input) { } } - // ② agents() 列表里带的 agent 目录(宿主给的路径) - if (context?.agent && typeof context.agent.agents === 'function') { - try { - const listed = await context.agent.agents(); - paths.push(...collectPathStrings(listed)); - } catch (error) { - logger?.warn?.('miniapp.agent.self_agent.agents_probe_failed', { - reason: error instanceof Error ? error.message : 'unknown', - }); - } - } - const root = await resolveAgentsRoot(agentsRootCandidates(paths, pluginId)); if (!root) { logger?.warn?.('miniapp.agent.self_agent.agents_root_unknown', { pluginId }); @@ -310,77 +299,7 @@ export async function ensureSelfAgent(input) { return { ok: true, created: true, agent: SELF_AGENT_NAME, path: targetFile }; } -/** - * 手动执行:node miniapp/node/agentprov.mjs --agents-root <宿主的 agents 目录> - * - * ⭐ **这是用户自己动手的那条路**(官方答复里说的「写个脚本新增一个 agent」): - * 用户明确指定了目录,我们才写;只新增,不改已有。 - * - * @param {string[]} argv - * @returns {Promise} - */ -export async function runCli(argv) { - const args = Array.isArray(argv) ? argv.slice() : []; - const flag = (name) => { - const index = args.indexOf(name); - return index >= 0 ? String(args[index + 1] ?? '') : ''; - }; - const root = flag('--agents-root'); - const checkOnly = args.includes('--check'); - if (!root) { - process.stdout.write('用法:node agentprov.mjs --agents-root <宿主 agents 目录> [--check]\n'); - return 2; - } - const here = dirname(fileURLToPath(import.meta.url)); - const pluginRoot = dirname(dirname(here)); - const persona = await readPersonaBody(pluginRoot); - const identity = await readIdentitySections(pluginRoot); - const targetFile = join(resolve(root), SELF_AGENT_NAME, SELF_AGENT_FILE); - const existing = await readFileIfExists(targetFile); - if (existing !== null) { - process.stdout.write( - (isSelfAgentFile(existing) ? '已经是我们写的那个 agent:' : '那个名字已被占用(不碰):') + - targetFile + - '\n' - ); - return 0; - } - if (checkOnly) { - process.stdout.write('可以写:' + targetFile + '\n'); - return 0; - } - const content = renderSelfAgentFile({ - persona, - identity, - name: SELF_AGENT_NAME, - displayName: SELF_AGENT_DISPLAY_NAME, - description: SELF_AGENT_DESCRIPTION, - }); - try { - await writeTextAtomic(targetFile, content); - } catch (error) { - process.stdout.write('写失败:' + (error instanceof Error ? error.message : 'unknown') + '\n'); - return 1; - } - process.stdout.write('已写入:' + targetFile + '\n'); - return 0; -} - -/** - * 这个文件是不是被当作主程序跑的? - * - * @returns {boolean} - */ -export function isMainModule() { - try { - const entry = process.argv[1] ? resolve(process.argv[1]) : ''; - return !!entry && entry === resolve(fileURLToPath(import.meta.url)); - } catch { - return false; - } -} - -if (isMainModule()) { - const code = await runCli(process.argv); - process.exitCode = code; -} +// ⭐ **CLI 不再住在这里**(上游评审 Optional,2026-11-04): +// 宿主把 miniapp/node/** 当运行时代码扫,进程里往 stdout 写东西会触发 +// 上游校验告警(stdout 属于宿主)。CLI 搬到 miniapp/scripts/agentprov-cli.mjs +// —— 仍然随包发布,用户手动跑那条路不变(--agents-root / --check)。 diff --git a/plugins/antianqi/mmx-octopus/miniapp/node/server.mjs b/plugins/antianqi/mmx-octopus/miniapp/node/server.mjs index 72cc30d..97356d3 100644 --- a/plugins/antianqi/mmx-octopus/miniapp/node/server.mjs +++ b/plugins/antianqi/mmx-octopus/miniapp/node/server.mjs @@ -1568,6 +1568,10 @@ export async function start(context, options = {}) { if (own.ok) { if (!state.petHeadInjected) { wanted = SELF_AGENT_NAME; + } else if (own.probeSessionId) { + // 已经有历史 ⇒ 不换 agent;但**把探测会话拿来当她的会话**(它是我们自己建的, + // 不出现在左侧聊天列表里),别再另建一个(上游评审 Optional #1)。 + await state.setPetSession(own.probeSessionId, false); } // 已经有历史 ⇒ 保持现状(我们那个 agent 已经在宿主里了,下次重建会话时自然会用上)。 } else if (own.probeSessionId) { @@ -1605,10 +1609,17 @@ export async function start(context, options = {}) { /** * 在宿主里**新增**我们自己的专属 agent(只增不改、失败必降级)。 * - * ⚠️ **为什么可能要一个临时会话**:宿主 agent 根目录只能从**宿主 API 返回的路径**认 - * (会话工作目录 / agents() 列表)—— **不从 dataDir 反推**(那是宿主内部布局,反推=猜)。 - * 所以第一次拿不到路径时,先建一个默认 agent 的会话(**还没跑过回合 ⇒ 没有历史**), - * 拿它的路径再来一次。 + * ⭐ **根目录只能「证明」,不能「猜」**(上游评审 Must fix 1,2026-11-04): + * 官方规范(docs/miniapp/sessions.md):**Node 发起的会话**工作目录 = + * 「Mini App 自己的 Agent 目录」= //miniapps/, + * 往上三层就是 agents 根;而**页面发起的会话**工作目录是**用户自己的项目**。 + * ⇒ 只有我们自己用 Agent API 建的会话能证明根目录; + * **不**从 dataDir 反推、**不**上溯找名为 agents 的目录(会落进用户项目)。 + * + * ⚠️ **为什么要一个临时会话**:当前会话的路径证明不了根目录时, + * 建一个**我们自己的**会话(Node 自建 ⇒ 工作目录必然是上面那个形状)来问一次。 + * 那个会话拿不到根目录 ⇒ **一个字节都不写**(降级:身份层仍走随消息带)。 + * ⚠️ Node 自建的会话**不出现在左侧聊天列表**(官方规范)⇒ 不会脏用户的会话列表。 * * @returns {Promise<{ok: boolean, created: boolean, path?: string, reason?: string, probeSessionId?: string}>} */ @@ -1622,7 +1633,9 @@ export async function start(context, options = {}) { logger: context.logger, }); let probeSessionId = ''; - if (!result.ok && result.reason === 'agents_root_unknown' && !current) { + // ⚠️ 只要「根目录证明不了」就探一次(不再要求「当前没有会话」): + // 当前会话可能是**页面发起的**(工作目录 = 用户项目),证明不了根目录。 + if (!result.ok && result.reason === 'agents_root_unknown') { try { probeSessionId = await createSessionOn(''); } catch (error) { diff --git a/plugins/antianqi/mmx-octopus/miniapp/scripts/agentprov-cli.mjs b/plugins/antianqi/mmx-octopus/miniapp/scripts/agentprov-cli.mjs new file mode 100644 index 0000000..7054ffc --- /dev/null +++ b/plugins/antianqi/mmx-octopus/miniapp/scripts/agentprov-cli.mjs @@ -0,0 +1,111 @@ +// @ts-check + +/** + * 手动新增我们那个专属 agent 的命令行入口。 + * + * 用法: + * node miniapp/scripts/agentprov-cli.mjs --agents-root <宿主的 agents 目录> [--check] + * + * ⭐ **为什么不住在 miniapp/node/**(上游评审 Optional,2026-11-04): + * 宿主把 miniapp/node/** 当**运行时代码**扫(上游 scripts/validate.mjs 的 node 规则), + * 运行时代码里往 stdout 写东西会触发告警(stdout 属于宿主,运行时该走 + * context.logger)。CLI 是**手动执行**的工具,它的输出就该走 stdout。 + * + * ⚠️ **只新增,不改已有**:名字被占(那个目录里已经有别人的 agent.md)就**不碰不用**。 + */ + +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { writeTextAtomic } from '../node/atomic-json.mjs'; +import { + SELF_AGENT_DESCRIPTION, + SELF_AGENT_DISPLAY_NAME, + SELF_AGENT_FILE, + SELF_AGENT_NAME, + isSelfAgentFile, + readFileIfExists, + renderSelfAgentFile, +} from '../node/agentprov.mjs'; +import { readIdentitySections, readPersonaBody } from '../node/identity.mjs'; + +/** + * 取一个 --flag 的值。 + * + * @param {string[]} args + * @param {string} name + * @returns {string} + */ +function flag(args, name) { + const index = args.indexOf(name); + return index >= 0 ? String(args[index + 1] ?? '') : ''; +} + +/** + * 手动执行这条命令。 + * + * @param {string[]} argv + * @returns {Promise} + */ +export async function runCli(argv) { + const args = Array.isArray(argv) ? argv.slice() : []; + const root = flag(args, '--agents-root'); + const checkOnly = args.includes('--check'); + if (!root) { + process.stdout.write( + '用法:node miniapp/scripts/agentprov-cli.mjs --agents-root <宿主 agents 目录> [--check]\n' + ); + return 2; + } + const here = dirname(fileURLToPath(import.meta.url)); + const pluginRoot = dirname(dirname(here)); + const persona = await readPersonaBody(pluginRoot); + const identity = await readIdentitySections(pluginRoot); + const targetFile = join(resolve(root), SELF_AGENT_NAME, SELF_AGENT_FILE); + const existing = await readFileIfExists(targetFile); + if (existing !== null) { + process.stdout.write( + (isSelfAgentFile(existing) ? '已经是我们写的那个 agent:' : '那个名字已被占用(不碰):') + + targetFile + + '\n' + ); + return 0; + } + if (checkOnly) { + process.stdout.write('可以写:' + targetFile + '\n'); + return 0; + } + const content = renderSelfAgentFile({ + persona, + identity, + name: SELF_AGENT_NAME, + displayName: SELF_AGENT_DISPLAY_NAME, + description: SELF_AGENT_DESCRIPTION, + }); + try { + await writeTextAtomic(targetFile, content); + } catch (error) { + process.stdout.write('写失败:' + (error instanceof Error ? error.message : 'unknown') + '\n'); + return 1; + } + process.stdout.write('已写入:' + targetFile + '\n'); + return 0; +} + +/** + * 这个文件是不是被当作主程序跑的? + * + * @returns {boolean} + */ +function isMainModule() { + try { + const entry = process.argv[1] ? resolve(process.argv[1]) : ''; + return !!entry && entry === resolve(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (isMainModule()) { + process.exitCode = await runCli(process.argv); +} From ba3bb32bb59a480235baa5236a2eaa1f7f591a4b Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:49:54 +0800 Subject: [PATCH 4/6] feat(mmx-octopus): let the user pick which agent she uses, default writes nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review asked how a user could opt out of "the plugin adds an agent to the Host". The only answer was "create an empty agent named mmx by hand" — not easier. The panel now has a two-way choice: - "use an existing agent" (the default) — the plugin adds nothing and writes not a single byte outside its own dataDir; - "create a dedicated agent" — only then does it add /mmx-octopus-agent/agent.md. If that fails (name taken, the directory cannot be proven, the write fails), the panel says why instead of silently falling back. Also fixes an inconsistency found while writing the disclosure: in the dedicated tier the agent name was handed to the picker without checking that it was really ours, so a foreign agent that happened to own the name mmx-octopus-agent could be used as if it were ours, while the panel said "could not create it, continuing with an existing agent". --- plugins/antianqi/mmx-octopus/README.md | 33 +++++--- plugins/antianqi/mmx-octopus/README.zh-CN.md | 21 +++-- .../mmx-octopus/miniapp/client/index.html | 51 ++++++++++++ .../mmx-octopus/miniapp/node/agentpick.mjs | 25 ++++-- .../mmx-octopus/miniapp/node/server.mjs | 83 ++++++++++++++++--- .../mmx-octopus/miniapp/node/state.mjs | 41 +++++++++ 6 files changed, 215 insertions(+), 39 deletions(-) diff --git a/plugins/antianqi/mmx-octopus/README.md b/plugins/antianqi/mmx-octopus/README.md index 5622cc2..e637508 100644 --- a/plugins/antianqi/mmx-octopus/README.md +++ b/plugins/antianqi/mmx-octopus/README.md @@ -118,10 +118,14 @@ it like the rest of the port. (how she has learned to write). Every write is atomic — a temporary file in the same directory, then a rename — so a crash cannot leave a half-written file behind. -- **One new agent file outside `context.dataDir`** — since 1.8.0 the runtime - **adds one agent of its own** to the Host's agent directory, so that her - character sheet can live in that agent's system prompt instead of only in the - message. Full disclosure: +- **At most one new agent file outside `context.dataDir`** — since 1.8.0 the + runtime **can add one agent of its own** to the Host's agent directory, so + that her character sheet can live in that agent's system prompt instead of + only in the message. **By default it adds nothing**: the panel has a + two-way choice ("Which agent should she use"), and the default is "use an + existing agent" — the plugin then writes **not a single byte** outside its + own data directory. Only when you pick "create a dedicated agent" does it + add that agent. Full disclosure: - **Which file** — exactly one: `/mmx-octopus-agent/agent.md`. The directory and the file are **new**; the Host picks it up without a restart. @@ -132,7 +136,8 @@ it like the rest of the port. file is written**: not your agents' `agent.md`, not the `AGENTS.md` of an agent workspace, not your project files. If the name `mmx-octopus-agent` is already taken by an agent that is not ours, the plugin **writes nothing and - does not use it** — she keeps working on the default agent. + does not use it** — she keeps working on an existing agent (that other + agent's file is never treated as ours). - **How it is written** — atomically (temporary file, then a rename), one write at a time, and only when the file does not exist yet: an existing file is never overwritten, so there is nothing to back up. @@ -141,9 +146,10 @@ it like the rest of the port. character sheet stays in it. Delete `/mmx-octopus-agent/` and the runtime adds it again on the next start, this time with the current version's sheet. If you would - rather not have this agent at all, create an agent of your own named `mmx` - (an empty one is enough) — the plugin prefers it and adds nothing. Nothing - else on your machine is affected. + rather not have this agent at all, pick "use an existing agent" in the + panel (the default) — the plugin then leaves it alone, and as long as you + do not switch back to "create a dedicated agent" it will not be recreated. + Nothing else on your machine is affected. - **How the directory is found — it is never guessed** — only from the workspace of a session the plugin itself created: the Host sets that workspace to the Mini App's own agent directory @@ -170,7 +176,8 @@ it like the rest of the port. package's own `miniapp/node/identity/AGENTS.md` and passed inside each prompt, so it is never missing from a turn. Because that travels in the conversation, the Host's context compaction may drop it — that is why it is - sent again every turn. Since 1.8.0 the same sheet is also written into the + sent again every turn. Since 1.8.0 — and only once you pick "create a + dedicated agent" in the panel — the same sheet is also written into the system prompt of the agent added above (the file disclosed above), which survives compaction; if that file cannot be written, the per-turn copy remains and nothing changes for you. @@ -182,9 +189,11 @@ it like the rest of the port. turn per message you send her in the chat area**, in her own session. The turn runs through the Host Agent, so it carries the capabilities the Host grants that agent; it reads the Host's agent list to look for an agent - dedicated to the Mini App — an agent of your own named `mmx` is used as it - is, otherwise the one it adds itself (see above) — and falls back to the - default agent when there is none. It never runs turns on your own sessions. + dedicated to the Mini App — in the default mode ("use an existing agent") + the one of your own named `mmx` is used as it is; if you pick "create a + dedicated agent" in the panel, the one it adds itself comes first (see + above) and yours is only used when that one is not there; with neither, it + falls back to the default agent. It never runs turns on your own sessions. - **Agent turns it starts on its own** — besides your messages, the runtime starts a few turns by itself, and **each of them uses your model quota**: - **Idle talk** — **off by default**; turn it on in the panel: Settings → diff --git a/plugins/antianqi/mmx-octopus/README.zh-CN.md b/plugins/antianqi/mmx-octopus/README.zh-CN.md index cc8746e..9024284 100644 --- a/plugins/antianqi/mmx-octopus/README.zh-CN.md +++ b/plugins/antianqi/mmx-octopus/README.zh-CN.md @@ -92,8 +92,10 @@ English | [简体中文](README.zh-CN.md) `context.dataDir/chat.json`(你和她的聊天), `context.dataDir/diary.json`(她的日记)、`context.dataDir/style.json`(她学到的语气)。 每次落盘都是原子的 —— 先写同目录临时文件,再改名 —— 所以崩了也不会留下半个文件。 -- **`context.dataDir` 之外只新增一个 agent 文件(1.8.0 起)** —— 运行时会在**宿主的 agent 目录里 - 新增一个属于自己的 agent**,好让她的设定能待在那个 agent 的 system prompt 里,不只活在消息里。 +- **`context.dataDir` 之外最多新增一个 agent 文件(1.8.0 起)** —— **默认不新增**:面板上有一组 + 二选一(「她用哪个 agent」),默认是「用现有 agent」⇒ 插件**一个字节都不往宿主目录写**。 + 只有你在面板里选了「建专属 agent」,运行时才会在**宿主的 agent 目录里新增一个属于自己的 + agent**,好让她的设定能待在那个 agent 的 system prompt 里,不只活在消息里。 完整披露: - **写哪个文件** —— 只有一个:`<宿主的 agent 目录>/mmx-octopus-agent/agent.md`。 目录和文件都是**新建的**,宿主**不用重启**就能认到它。 @@ -101,13 +103,15 @@ English | [简体中文](README.zh-CN.md) 外加宿主要的 frontmatter(`name` / `description` / `displayName`)。 - **哪些绝不碰** —— **不改任何已有的 agent,也不写别的文件**:不改你的 agent 的 `agent.md`, 不写 agent 工作区里的 `AGENTS.md`,不碰你的项目文件。要是 `mmx-octopus-agent` 这个名字 - 已经被别的 agent 占了,插件就**什么都不写、也不用它** —— 她照样用默认 agent 说话。 + 已经被别的 agent 占了,插件就**什么都不写、也不用它** —— 她照样用现有 agent 说话 + (那个别人的 agent 不会被当成我们自己的用)。 - **怎么写** —— 原子写(先写临时文件再改名),一次只写一个,而且**只在文件不存在时**写: 已存在的文件绝不覆盖 ⇒ 没有需要备份的旧文件。 - **怎么移除它 / 怎么拿到新版本** —— 这个文件**永远不会被就地改写**,所以升级插件之后, 里面还是旧的那份设定。删掉 `<宿主的 agent 目录>/mmx-octopus-agent/` 整个目录, - 下次启动它会按当前版本的设定重新加上。想彻底不要这个 agent:自己建一个叫 `mmx` - 的 agent(空的也行)—— 插件会优先用它,什么都不新增。别的什么都不受影响。 + 下次启动它会按当前版本的设定重新加上。想彻底不要这个 agent:面板上选「用现有 agent」 + (默认档),插件就不碰它了 —— 只要不切回「建专属 agent」,删掉的目录不会被重建。 + 别的什么都不受影响。 - **这个目录怎么找 —— 绝不靠猜** —— **只从插件自己建的那个会话的工作目录**认:宿主把那个会话的 工作目录设成「Mini App 自己的 Agent 目录」(`<宿主的 agent 目录>//miniapps/`), 插件取它的上一层。**不会**在文件系统里上溯找名为 `agents` 的目录(你的项目里正好有一层 @@ -123,15 +127,16 @@ English | [简体中文](README.zh-CN.md) - **她的身份怎么送到她那儿** —— **两条腿走**。她的底稿、样子和规矩从本包自己的 `miniapp/node/identity/AGENTS.md` 读出来,拼进每次的提示词里,所以每一轮都带着它。 ⚠️ 代价:这一份走的是会话历史 ⇒ 宿主做上下文压缩时可能把它压掉(所以每轮都重发一份)。 - 1.8.0 起,同样一份设定也写进了上面那个新增 agent 的 system prompt(压缩压不掉); - 万一那个文件写不成,每轮随消息带的那份照样在,你什么都不用管。 + 1.8.0 起,**你在面板上选了「建专属 agent」之后**,同样一份设定也写进了上面那个新增 agent + 的 system prompt(压缩压不掉);万一那个文件写不成,每轮随消息带的那份照样在,你什么都不用管。 - **读哪些会话** —— 这个包声明了 `session.read`。它读一轮对话的标题、运行状态和最终回复, 用来分辨 Agent 是在想还是做完了,也用来给这一轮做摘要。面板里的看板列出正在跑的会话, 每 30 秒刷新一次。 - **会跑哪些 Agent 轮次** —— 它声明了 `session.run`,并且**你在聊天区给她发的每一句话, 都在她自己的会话里跑一轮 Agent**。这一轮走宿主的 Agent,因此带着宿主给那个 agent 的权限; 它会读宿主的 agent 列表,找有没有专属于这个 Mini App 的 agent —— - 你自己那个叫 `mmx` 的就原样用它,没有的话就用它自己新增的那个(见上面)—— 都没有就退回默认 agent。 + 默认档用的是你自己那个叫 `mmx` 的;你在面板上选了「建专属 agent」的话,就优先用插件自己 + 新增的那个(见上面),它没有才用你的 `mmx`;都没有就退回默认 agent。 它不会去跑你自己的会话。 - **它自己会起的那些轮次** —— 除了你发的消息,运行时还会自己起几轮,**每一轮都花你的模型额度**: - **闲话**:**默认关着**,要她主动开口自己在面板里开:设置 → 「她没人的时候自己说话」。 diff --git a/plugins/antianqi/mmx-octopus/miniapp/client/index.html b/plugins/antianqi/mmx-octopus/miniapp/client/index.html index 12d1be8..3b3e1c1 100644 --- a/plugins/antianqi/mmx-octopus/miniapp/client/index.html +++ b/plugins/antianqi/mmx-octopus/miniapp/client/index.html @@ -1900,6 +1900,27 @@

名字和称呼都是你的设定,改了她下一轮就知道。

+ +
+ 她用哪个 agent +
+ + +
+

+ 专属 agent —— 给她建一个只属于她的 agent,她的设定写进它的 system prompt: + 性格更鲜明、回复更拟人;代价是它从零开始,没有你现有 agent 攒下的经验。 +
+ 现有 agent —— 用你现在这个 agent:更偏助理,认得你之前的经验, + 帮你干活的工具和上下文都是现成的;代价是她的性格只能每轮随消息带, + 宿主压缩上下文时可能被压掉。 +
+ 换档会重开她的会话(聊天记录留着)。 +

+ +
+
她看着谁
@@ -3354,6 +3375,21 @@

先认识一下

: 'false' ); } + // 「她用哪个 agent」—— 默认「现有 agent」(插件默认不往宿主目录写任何文件) + var modeButtons = document.querySelectorAll('#agentmode-group button'); + var agentMode = snapshot.agentMode === 'own' ? 'own' : 'existing'; + for (var modeIdx = 0; modeIdx < modeButtons.length; modeIdx++) { + modeButtons[modeIdx].setAttribute( + 'aria-pressed', + modeButtons[modeIdx].getAttribute('data-agentmode') === agentMode ? 'true' : 'false' + ); + } + var modeStatus = document.getElementById('agentmode-status'); + if (modeStatus) { + var statusText = snapshot.agentStatus || ''; + modeStatus.textContent = statusText; + modeStatus.style.display = statusText ? '' : 'none'; + } // 诊断与看板 renderTracking(snapshot); @@ -4726,6 +4762,21 @@

先认识一下

}); }); + // ★ **「她用哪个 agent」**(上游评审 Optional #3) + document.getElementById('agentmode-group').addEventListener('click', function (event) { + var button = event.target.closest('button[data-agentmode]'); + if (!button) return; + var next = button.getAttribute('data-agentmode'); + post('/api/agent-mode', { mode: next }) + .then(function (data) { + if (data.snapshot) render(data.snapshot); + toast(next === 'own' ? '她用专属 agent' : '她用现有 agent'); + }) + .catch(function (error) { + toast('切换失败:' + error.message, 'error'); + }); + }); + document.getElementById('scope-group').addEventListener('click', function (event) { var button = event.target.closest('button[data-scope]'); if (!button) return; diff --git a/plugins/antianqi/mmx-octopus/miniapp/node/agentpick.mjs b/plugins/antianqi/mmx-octopus/miniapp/node/agentpick.mjs index 024b92a..eaf391c 100644 --- a/plugins/antianqi/mmx-octopus/miniapp/node/agentpick.mjs +++ b/plugins/antianqi/mmx-octopus/miniapp/node/agentpick.mjs @@ -32,25 +32,38 @@ export const PET_AGENT_NAME = 'mmx'; export const SELF_AGENT_NAME = 'mmx-octopus-agent'; /** - * 决定这一轮用哪个 agent。 + * 决定这一轮用哪个 agent —— **用户在面板里选的那一档**决定。 * - * 优先级:**用户显式指定的** > **用户自己的「mmx」** > **我们新增的那个专属 agent** > **空(用默认 agent)**。 + * 优先级: + * · 「用现有 agent」(**默认**):**显式指定的** > **用户自己的「mmx」** > **空(默认 agent)** + * —— 我们新增的那个**不参与**(这一档就是「不新增、不使用」)。 + * · 「建专属 agent」:**显式指定的** > **我们新增的那个** > **用户自己的「mmx」** > **空** + * —— 用户明确选了它 ⇒ 就要它的性格,不迁就现有 agent。 * - * ⚠️ **「用户自己的 mmx」优先于我们的**:老板机器上已经有那个 agent ⇒ + * ⚠️ **默认那一档里「用户自己的 mmx」优先于我们的**:老板机器上已经有那个 agent ⇒ * 不新增、不改它、也不把会话挪走(零影响)。 * * @param {string[]} listedNames 宿主 `agents()` 列出来的 agent 名 * @param {string|null|undefined} configured 用户显式指定的 agent 名(`state.petAgent`) * @param {string} [selfName] 我们自己新增的那个 agent 名(宿主里**确实列出来了**才用它) + * @param {string} [mode] 'own' = 建专属 agent;其它值 = 用现有 agent(默认) + * @param {boolean} [selfOwned] 我们那个 agent **确实是我们写的**吗? + * ⚠️ 名字被别人占了(name_taken)⇒ 我们**没建成** ⇒ 那个同名 agent 是**别人的** + * ⇒ 不能把它当成「我们那个」用(降级:继续用现有 agent)。 + * 传进来的 `selfName` 只是**名字**;**是不是我们的**由这条事实决定。 * @returns {string} '' = 不指定(用默认 agent) */ -export function pickPetAgent(listedNames, configured, selfName = '') { +export function pickPetAgent(listedNames, configured, selfName = '', mode = 'existing', selfOwned = false) { const explicit = configured ? String(configured) : ''; if (explicit) return explicit; const names = Array.isArray(listedNames) ? listedNames.map((n) => String(n ?? '')) : []; - if (names.includes(PET_AGENT_NAME)) return PET_AGENT_NAME; const self = String(selfName ?? ''); - if (self && names.includes(self)) return self; + // 「建专属 agent」那一档:我们那个**排在自己的 mmx 前面**(用户明确选了它)。 + // ⚠️ **但只有它确实是我们写的才用** —— 名字被别人占了 ⇒ 我们没建成 ⇒ + // 那个同名 agent 是**别人的** ⇒ 绝不能把它当成「我们那个」用。 + if (mode === 'own' && selfOwned && self && names.includes(self)) return self; + // 「用现有 agent」那一档:我们那个**不参与**(不新增、不使用)。 + if (names.includes(PET_AGENT_NAME)) return PET_AGENT_NAME; return ''; } diff --git a/plugins/antianqi/mmx-octopus/miniapp/node/server.mjs b/plugins/antianqi/mmx-octopus/miniapp/node/server.mjs index 97356d3..016a0cb 100644 --- a/plugins/antianqi/mmx-octopus/miniapp/node/server.mjs +++ b/plugins/antianqi/mmx-octopus/miniapp/node/server.mjs @@ -1172,6 +1172,23 @@ export async function start(context, options = {}) { return; } + // ===== 「她用哪个 agent」(上游评审 Optional #3:给用户一个自己决定的入口)===== + // + // ⚠️ **默认「用现有 agent」** ⇒ 插件默认**不往宿主目录写任何文件**。 + // 想要专属 agent 的用户在面板上点一下,插件才建。 + if (path === '/api/agent-mode') { + const mode = body.mode === 'own' ? 'own' : 'existing'; + const agentMode = await state.setAgentMode(mode); + // 换了档 ⇒ 探测缓存作废(agent 列表 / 我们那个 agent 的结论都要重探) + petAgentProbe = null; + ownAgentResult = null; + // 换回「现有 agent」⇒ 别再把她钉在我们那个 agent 上 + if (mode === 'existing') await state.setPetAgent(null); + context.logger.info('miniapp.agent.mode.changed', { mode }); + sendJson(response, 200, { ok: true, agentMode, snapshot: state.snapshot() }); + return; + } + if (path === '/api/diary') { // 让她自己写一篇日记(走一次她的专属会话)。 // @@ -1513,8 +1530,20 @@ export async function start(context, options = {}) { if (state.petAgent) return String(state.petAgent); if (petAgentProbe !== null) return petAgentProbe; const names = await listAgentNames(); - petAgentProbe = pickPetAgent(names, '', SELF_AGENT_NAME); - context.logger.info('miniapp.agent.pet_agent.resolved', { agent: petAgentProbe || '(default)' }); + // ⚠️ **档位决定我们新增的那个 agent 参不参与**: + // 「用现有 agent」这一档 ⇒ 它**不参与**(不新增、不使用); + // 「建专属 agent」这一档 ⇒ 它**排在自己的 mmx 前面**(用户明确选了它)。 + const mode = state.agentMode === 'own' ? 'own' : 'existing'; + // ⚠️ **专属档也只有我们那个真的是我们的时才用它** —— 名字被别人占了(name_taken) + // ⇒ 我们没建成 ⇒ 那个同名 agent 是**别人的** ⇒ 不能拿来当「我们那个」用 + // (降级:继续用现有 agent —— 与面板上写的失败原因一致)。 + const selfOwned = mode === 'own' && !!(ownAgentResult && ownAgentResult.ok); + const selfName = mode === 'own' ? SELF_AGENT_NAME : ''; + petAgentProbe = pickPetAgent(names, '', selfName, mode, selfOwned); + context.logger.info('miniapp.agent.pet_agent.resolved', { + agent: petAgentProbe || '(default)', + mode, + }); return petAgentProbe; } @@ -1556,28 +1585,36 @@ export async function start(context, options = {}) { */ async function ensurePetSession(agentName) { const explicit = agentName ? String(agentName) : ''; - let wanted = explicit ? explicit : await resolvePetAgent(); + let wanted = explicit ? explicit : ''; // ⭐ **v1.8.0:没有可用的专属 agent → 现场新增一个**(只增不改、失败必降级)。 // // ⚠️ **换 agent 只能重建会话**(官方规范:会话不能中途换 agent)⇒ // 只有「她还没说过话」(换过去不丢历史)时才把会话挪到我们那个 agent 上; // 已经有历史了就**不动当前会话** —— 宁可暂时用弱通道,也不丢她的上下文。 - if (!wanted) { + // ⭐ **用户自己选的那一档**(面板上的「她用哪个 agent」): + // · 「用现有 agent」(**默认**)⇒ 我们新增的那个**一次都不碰**: + // 不新增、不使用、不把会话挪过去 ⇒ 插件不往宿主目录写任何文件。 + // · 「建专属 agent」⇒ 先把我们那一个建出来(只增不改、失败必降级), + // 建不出来 ⇒ 退回「现有 agent」,并把原因说清楚(面板上看得见)。 + // + // ⚠️ **换 agent 只能重建会话**(官方规范:会话不能中途换 agent)。 + // 用户**主动**选了「专属」⇒ 就换(新会话第一轮带接续摘要 ⇒ 上下文接得上)。 + if (!explicit && state.agentMode === 'own') { const own = await ensureOwnAgent(); if (own.ok) { - if (!state.petHeadInjected) { - wanted = SELF_AGENT_NAME; - } else if (own.probeSessionId) { - // 已经有历史 ⇒ 不换 agent;但**把探测会话拿来当她的会话**(它是我们自己建的, - // 不出现在左侧聊天列表里),别再另建一个(上游评审 Optional #1)。 + await state.setAgentStatus(''); + } else { + await state.setAgentStatus(ownAgentFailText(own.reason)); + if (own.probeSessionId) { + // 降级:建不成 ⇒ 继续用现有 agent(身份层仍走随消息带)。 await state.setPetSession(own.probeSessionId, false); } - // 已经有历史 ⇒ 保持现状(我们那个 agent 已经在宿主里了,下次重建会话时自然会用上)。 - } else if (own.probeSessionId) { - // 降级:新增失败 ⇒ 继续用默认 agent(身份层仍走随消息带)。 - await state.setPetSession(own.probeSessionId, false); } + wanted = await resolvePetAgent(); + } else if (!explicit) { + // 「用现有 agent」这一档:我们那一个**不参与**(不写盘、不使用)。 + wanted = await resolvePetAgent(); } const current = state.petSessionId; @@ -1606,6 +1643,26 @@ export async function start(context, options = {}) { return sessionId; } + /** + * 「专属 agent 没建成」的原因 —— 面板上给用户看的一句话。 + * + * ⚠️ **失败要说出来**(不能默默退回「现有 agent」):用户选的是「专属」, + * 没建成就告诉他为什么(名字被占 / 认不出目录 / 写不进去)。 + * + * @param {string} reason + * @returns {string} + */ + function ownAgentFailText(reason) { + const map = { + name_taken: '「mmx-octopus-agent」这个名字已经被别的 agent 占了,建不了 —— 继续用现有 agent。', + agents_root_unknown: '认不出宿主的 agent 目录,建不了专属 agent —— 继续用现有 agent。', + write_failed: '写不进宿主的 agent 目录,建不了专属 agent —— 继续用现有 agent。', + verify_failed: '写完没验过,建不了专属 agent —— 继续用现有 agent。', + persona_missing: '包里没读到她的设定,建不了专属 agent —— 继续用现有 agent。', + }; + return map[String(reason)] || '专属 agent 没建成 —— 继续用现有 agent。'; + } + /** * 在宿主里**新增**我们自己的专属 agent(只增不改、失败必降级)。 * diff --git a/plugins/antianqi/mmx-octopus/miniapp/node/state.mjs b/plugins/antianqi/mmx-octopus/miniapp/node/state.mjs index e0fa002..a912d2e 100644 --- a/plugins/antianqi/mmx-octopus/miniapp/node/state.mjs +++ b/plugins/antianqi/mmx-octopus/miniapp/node/state.mjs @@ -341,6 +341,15 @@ export class PetState { this.asking = null; /** @type {string | null} 她的专属会话用哪个 agent */ this.petAgent = null; + /** + * 她的会话用哪个 agent(**用户在面板里选**,持久化)。 + * 'existing' = **用现有 agent**(默认)—— 插件不往宿主目录写任何文件; + * 'own' = **建我们自己的专属 agent** —— 性格更鲜明,但从零开始、没有积累。 + * @type {'existing'|'own'} + */ + this.agentMode = 'existing'; + /** @type {string} 运行时状态:实际用哪个 agent 的说明(不持久化;空 = 没话说) */ + this.agentStatus = ''; /** @type {boolean} 她正在回话(运行时态,不持久化) */ this.chatBusy = false; /** @type {boolean} 是否把 MCode 状态摘要拼进她的输入(持久化,默认开) */ @@ -437,6 +446,8 @@ export class PetState { if (typeof saved.petSessionDirty === 'boolean') this.petSessionDirty = saved.petSessionDirty; if (typeof saved.petHeadInjected === 'boolean') this.petHeadInjected = saved.petHeadInjected; if (typeof saved.petAgent === 'string' && saved.petAgent) this.petAgent = saved.petAgent; + // ⚠️ **默认是「用现有 agent」** —— 只有明确存了 'own' 才算「建专属」。 + if (saved.agentMode === 'own') this.agentMode = 'own'; if (typeof saved.petAware === 'boolean') this.petAware = saved.petAware; if (typeof saved.idleTalk === 'boolean') this.idleTalk = saved.idleTalk; if (saved.naming && typeof saved.naming === 'object') { @@ -498,6 +509,7 @@ export class PetState { petSessionDirty: this.petSessionDirty, petHeadInjected: this.petHeadInjected, petAgent: this.petAgent, + agentMode: this.agentMode, petAware: this.petAware, idleTalk: this.idleTalk, naming: { ...this.naming }, @@ -568,6 +580,8 @@ export class PetState { petHeadInjected: this.petHeadInjected, asking: this.asking, petAgent: this.petAgent, + agentMode: this.agentMode, + agentStatus: this.agentStatus, petAware: this.petAware, idleTalk: this.idleTalk, naming: { ...this.naming }, @@ -1168,6 +1182,33 @@ export class PetState { return this.idleTalk; } + /** + * 她的会话用哪个 agent(用户在面板里选的那一档)。 + * + * ⚠️ **只有明确传 'own' 才算「建专属 agent」** —— 别的值一律按「用现有 agent」处理。 + * 默认就是「用现有 agent」:插件默认**不往宿主目录写任何文件**。 + * + * @param {string} mode + */ + async setAgentMode(mode) { + this.agentMode = mode === 'own' ? 'own' : 'existing'; + await this.commit(); + return this.agentMode; + } + + /** + * 运行时状态:她**实际**用哪个 agent 的说明(不持久化)。 + * + * ⚠️ **不落盘**:它是一次运行的现场说明(为什么没按用户选的那一档走), + * 不是设置 —— 设置在 `agentMode` 里。 + * + * @param {string} text + */ + setAgentStatus(text) { + this.agentStatus = String(text ?? ''); + return this.agentStatus; + } + /** * 设置区看板。running 来自 sessions(),recent 来自 turn.finished。 * @param {{running?: any[], recent?: any[]}} board From de64134cf92090904753356aec0d80a399fc4ed9 Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:31:36 +0800 Subject: [PATCH 5/6] feat(mmx-octopus): ask which agent she uses on first run The choice was only in the settings panel, so a first-time user never met it. It now sits on the opening card as well - pick the tier first, then her name and how she addresses you - and it goes out in the same submission. Both places share one rendering (the `data-agentmode-group` marker plus a single `paintAgentMode` selector) so the two copies cannot drift, and while the opening card is open a snapshot refresh leaves the pending pick alone. Self-test: 865 checks, 0 failures (new group *round 46*, 7 checks). Every check was falsified against the real code. --- plugins/antianqi/mmx-octopus/README.md | 4 +- plugins/antianqi/mmx-octopus/README.zh-CN.md | 2 + .../mmx-octopus/miniapp/client/index.html | 118 ++++++++++++++++-- 3 files changed, 111 insertions(+), 13 deletions(-) diff --git a/plugins/antianqi/mmx-octopus/README.md b/plugins/antianqi/mmx-octopus/README.md index e637508..642191c 100644 --- a/plugins/antianqi/mmx-octopus/README.md +++ b/plugins/antianqi/mmx-octopus/README.md @@ -125,7 +125,9 @@ it like the rest of the port. two-way choice ("Which agent should she use"), and the default is "use an existing agent" — the plugin then writes **not a single byte** outside its own data directory. Only when you pick "create a dedicated agent" does it - add that agent. Full disclosure: + add that agent. The choice is asked **on first run**: the opening card asks + for the tier first, then for her name and how she addresses you; you can + change it in the panel at any time. Full disclosure: - **Which file** — exactly one: `/mmx-octopus-agent/agent.md`. The directory and the file are **new**; the Host picks it up without a restart. diff --git a/plugins/antianqi/mmx-octopus/README.zh-CN.md b/plugins/antianqi/mmx-octopus/README.zh-CN.md index 9024284..d3adcd2 100644 --- a/plugins/antianqi/mmx-octopus/README.zh-CN.md +++ b/plugins/antianqi/mmx-octopus/README.zh-CN.md @@ -96,6 +96,8 @@ English | [简体中文](README.zh-CN.md) 二选一(「她用哪个 agent」),默认是「用现有 agent」⇒ 插件**一个字节都不往宿主目录写**。 只有你在面板里选了「建专属 agent」,运行时才会在**宿主的 agent 目录里新增一个属于自己的 agent**,好让她的设定能待在那个 agent 的 system prompt 里,不只活在消息里。 + 这一档**在第一次运行时就问**:首启那张卡片里先选「用现有 agent」还是「建专属 agent」, + 再选她叫什么、她怎么称呼你;之后随时能在设置里改。 完整披露: - **写哪个文件** —— 只有一个:`<宿主的 agent 目录>/mmx-octopus-agent/agent.md`。 目录和文件都是**新建的**,宿主**不用重启**就能认到它。 diff --git a/plugins/antianqi/mmx-octopus/miniapp/client/index.html b/plugins/antianqi/mmx-octopus/miniapp/client/index.html index 3b3e1c1..ec97f40 100644 --- a/plugins/antianqi/mmx-octopus/miniapp/client/index.html +++ b/plugins/antianqi/mmx-octopus/miniapp/client/index.html @@ -1510,6 +1510,9 @@ .naming-card { width: 100%; max-width: 340px; + /* 卡片变高了(多了那组二选一)⇒ 面板被缩小时要能滚,别把按钮顶出去 */ + max-height: 100%; + overflow-y: auto; padding: 18px 20px; background: var(--mcode-surface); border: 1px solid var(--mcode-border); @@ -1530,6 +1533,13 @@ color: var(--mcode-text-muted); } + /* 首启卡片里那组二选一下面的小字(比正文更小、更淡) */ + .naming-card .set-note { + margin: 2px 0 0; + font-size: 11px; + line-height: 17px; + } + /* ---------- 状态小卡 ---------- */ .facts { display: grid; @@ -1904,7 +1914,7 @@ 默认为「现有 agent」⇒ 插件默认**不往宿主目录写任何文件**。 -->
她用哪个 agent -
+
@@ -2052,7 +2062,19 @@