Skip to content

我的世界 AI 伙伴「夕瑟」· 保姆级项目详解 ​

这是一篇"读到能自己改"的深度讲解。建议配合源码阅读,顺序:bot.mjs → perception.mjs → tools.mjs → brain.mjs → persona.mjs。 面向:会一点 JS、想学"怎么把大模型接进游戏/系统"的人。


0. 先理解一个核心思想:把"大脑"和"身体"分开 ​

绝大多数"AI 玩游戏"的项目之所以难,是因为想让一个模型同时理解世界又实时操作。这是矛盾的:

  • 大模型聪明,但慢(一次几秒到几十秒),做不了实时反应。
  • 游戏需要毫秒级的反应(被打要还手、看到岩浆要躲)。

本项目的解法是分层:

层谁负责特点
反射层(身体本能)纯代码 setInterval毫秒级、不经过模型:跟随、被打还手、躲苦力怕、自动吃
决策层(大脑)大模型秒级:理解你说的人话、决定用哪个工具
执行层(手)纯代码工具毫秒级:真正去挖、去合成、去丢东西
表达层(嘴)本地小模型 + TTS闲聊用 3B(快),说话用绯莎音

一句话:模型只负责"想",代码负责"动"。 这也是为什么 6GB 显存的笔记本也能跑出一个"像样"的伙伴。


1. 系统全景 ​

                       ┌───────────────────────────────────────────┐
                       │            你的 Windows 电脑                │
                       │                                           │
  Minecraft 客户端 ────┤─ 单机世界 · 对局域网开放 (127.0.0.1:端口)   │
                       │        ↑ mineflayer (TCP)                  │
                       │   ┌────┴─────┐                             │
                       │   │ bot.mjs  │ 身体 + 反射层 + 总控          │
                       │   └──┬───┬───┘                             │
                       │      │   │                                 │
   Ollama (11434) ◄────┤──────┘   └────────────► GPT-SoVITS (9880)  │
   闲聊 3B              │   brain.mjs 决策         绯莎语音合成        │
                       │      │                                    │
   opencode-api (8787) ┤◄─────┘                                   │
   → opencode run       │                                           │
   → 免费大模型          │  listen.ps1 (麦克风) / tts.ps1 (兜底语音)  │
                       └───────────────────────────────────────────┘

所有服务都用 127.0.0.1(本机回环) 通信,因此全部要在同一台 Windows 上跑(这也是为什么把它迁到 WSL 会出问题)。


2. 一键启动:launcher.mjs ​

用户只双击 启动夕瑟.cmd(内容就是 node launcher.mjs)。launcher 按顺序做五件事:

  1. 问端口:读 config.json 里上次的端口作为默认,写回。
  2. 拉起 Ollama:探测 http://127.0.0.1:11434/api/version;没起就 spawn ollama serve;然后预热 qwen2.5:3b(keep_alive:-1 让它常驻显存),并每 3 分钟 ping 一次保活。
  3. 拉起大脑代理 opencode-api:探测 http://127.0.0.1:8787/health;没起就 spawn node server.mjs。
  4. 拉起 GPT-SoVITS 语音服务:探测 /docs;没起就用 config.voice.gptsovits.serverDir 里的 runtime\python.exe api_v2.py … 启动,等它就绪(最多 120s),再调用 /set_gpt_weights 和 /set_sovits_weights 换上绯莎权重。
  5. 拉起麦克风:spawn powershell -File listen.ps1(stdio:"inherit",识别到的文字会显示在同一窗口)。
  6. 启动 bot:await import("./bot.mjs")——注意是同一进程内导入,所以关窗时 bot 也随之结束。

关键实现(并发与生命周期):

js
const children = [];
function launch(cmd, args, cwd, inherit = false) {
  const child = spawn(cmd, args, { cwd, detached: false, stdio: inherit ? "inherit" : "ignore",
                                   windowsHide: true, env: { ...process.env, OLLAMA_KEEP_ALIVE: "-1" } });
  children.push(child);
  return child;
}
// 关窗/退出时,把所有子进程一起杀掉(GPT-SoVITS 释放显存)
process.on("exit", killChildren);
process.on("SIGINT", () => { killChildren(); process.exit(0); });

detached:false 是刻意选的:让子进程跟启动窗口同生共死,避免"关了窗口但服务还在后台占显存"。


3. 身体与反射层:bot.mjs ​

这是最大的文件,承担四件事:连接游戏、跑反射循环、接入语音、处理聊天。

3.1 连接与事件 ​

js
bot = mineflayer.createBot({ host, port, username, auth: "offline", hideErrors: true });
bot.loadPlugin(pathfinder);
bot.loadPlugin(pvpPlugin);
bot.once("spawn", () => { /* 初始化 Movements、tools、默认跟随 */ });

建筑保护在初始化寻路器时设置:

js
const movements = new Movements(bot);
movements.canDig = false;       // 赶路不挖方块 → 不会拆你的房子
movements.canOpenDoors = true;  // 会开门
movements.allow1by1towers = false;
bot.pathfinder.setMovements(movements);

注意:这只约束寻路。主动挖矿走的是 bot.dig(),不受影响——这正是我们想要的分工。

事件监听里比较重要的是死亡/复活:

js
bot.on("death", () => { pushEvent("我死了"); mood = Math.max(5, mood - 10); });
bot.on("spawn", () => {
  spawnCount++;
  if (spawnCount <= 1) return;              // 首次由 once("spawn") 处理
  pushEvent("我复活了,回主人身边");
  if (cfg.followByDefault !== false && owner) followTarget = owner;
  sayFlavor("你刚复活,正在回到主人身边", "主人,我又回来了,马上到你那儿。");
});

以及"主人死亡/回归"——它监听的是系统消息 messagestr(不是 chat),因为死亡提示是系统消息:

js
bot.on("messagestr", (raw) => {
  const t = String(raw || "");
  if (!owner || !t.includes(owner)) return;
  if (canEvent("ownerDeath", 15000) && /(被杀|杀死了|死亡|died|slain|虚空|溺死|烧死|饿死|炸死|摔死)/.test(t)) {
    const ent = playerEntity(bot, owner);
    if (ent) ownerDeathPos = { x: 取整, y, z };       // 记住你死亡的位置
    pushEvent("主人死了");
    pendingConfirm = { kind: "collect", pos: ownerDeathPos, at: Date.now() };
    say("主人你挂了……要我去你倒下的地方把东西捡回来吗?");
  }
  // 主人(重新)加入
  if (canEvent("ownerJoin", 15000) && /(加入了游戏|joined the game)/.test(t)) { … }
});

3.2 反射循环:四个定时器 ​

js
function startLoops() {
  setInterval(followTick, 1000);   // 跟随(顺手捡掉落物)
  setInterval(guardTick, 1000);    // 威胁评估 / 自保 / 护主 / 撤退
  setInterval(eatTick, 3000);      // 低血自动吃
  setInterval(brainTick, 6000);    // 夜间回家 / 主动搭话 / 恢复被打断的任务
}

这就是"身体本能":不经过任何模型,每秒都在跑。

followTick:跟随 + 顺路捡物 ​

js
function followTick() {
  if (!followTarget || !bot.entity) return;
  if (homeTrip) return;                                   // 回家优先级高于跟随
  if ((suspect && Date.now() < suspect.until) || nearestHostile(bot, 10)) return;  // 反击高于跟随
  const drop = nearestItem(bot, 3);                       // 顺路捡起附近掉落物
  if (drop) { bot.pathfinder.setGoal(new GoalNear(drop.position…, 0)); return; }
  const t = playerEntity(bot, followTarget);
  if (t) bot.pathfinder.setGoal(new GoalFollow(t, cfg.followDistance), true);
}

优先级就藏在这几行里:回家 > 反击 > 跟随。

guardTick:威胁评估 → 撤退 / 护主 / 攻击 ​

它先做"打不过判断":

js
function shouldFlee() {
  const h = bot.health ?? 20;
  const { score, count, creeper } = assessThreat();
  if (creeper) return "creeper";                 // 苦力怕贴身 → 一律避开
  if (有监守者/凋灵/末影龙) return "boss";        // 直接跑
  if (h < 4) return "dying";                     // 濒死
  if (count >= 3 && score > 7 * mult * s) return "outnumbered";  // 寡不敌众
  if (score > 14 * mult * s) return "toostrong"; // 威胁远超自身战力
  return null;                                    // 否则能打就打
}

assessThreat() 把附近 16 格内每个怪物按"种类权重 × 距离"累加成 score,myStrength() 综合血量/武器/护甲算战力,mult 来自 persona.courage(胆量)。低血本身不跑,只有真正打不过才跑(用户的明确要求)。

撤退时用"锁定期"避免刷屏:

js
if (fleeReason) {
  if (Date.now() >= fleeUntil) {           // 进入一次撤退
    fleeUntil = Date.now() + 9000;         // 锁定 9 秒
    fleeAnnounced = false;
    bot.pvp?.stop();
    pushEvent(`我撤退了(${fleeReason})`);
  }
  if (!fleeAnnounced) { fleeAnnounced = true; sayFlavor("情况不妙,正在撤退", "打不过,先撤!"); }
  // 持续朝主人/家跑
  return;
}

护主 / 陪猎在最前面(不需要"保护"开关):

js
if (ownerAttacker && Date.now() < ownerAttacker.until) { bot.pvp.attack(bot.entities[ownerAttacker.id]); return; }
if (huntAssist && Date.now() < huntAssist.until)       { bot.pvp.attack(bot.entities[huntAssist.id]);   return; }

ownerAttacker / huntAssist 是在 entityHurt 事件里设置的:主人受伤 → 找最近的非被动怪锁定;被动动物在主人附近受伤 → 陪打。

一个易错点:PvP 插件靠寻路接近目标,如果同时 followTick 每秒覆盖寻路目标,就打不到怪。所以战斗中 followTick 会让位。

eatTick:低血/饥饿自动进食 ​

js
if (bot.food >= 18 && (bot.health ?? 20) >= 16) return;
const food = items.find(i => /cooked_/.test(i.name)) || items.find(i => /apple|bread|beef|porkchop…/);
if (food) { await bot.equip(food, "hand"); await bot.consume(); }

brainTick:夜间回家 / 主动搭话 / 续任务 ​

  • 夜间回家且可放弃手头工作:天黑且设过家 → homeTrip = true,并清掉 pendingTask/pendingConfirm、置 abortRequested(正在挖矿的循环会看到并中止)。
  • 到家整理:到家后 homeTidy() 打开附近箱子:把"不用的"存进去(保留武器/工具/护甲/食物/火把),并从箱子里"补缺的"(剑/镐/斧/锹/火把/食物)。这就是"每次回家自动补"。
  • 天亮了恢复跟随:waitingHome && !isNight → 重新 followTarget = owner。
  • 主动搭话:调用 proactive(),结合"最近发生的事"说一句。

3.3 聊天处理:onChat(二次确认的核心) ​

处理一条消息的顺序:

  1. 首次认主:owner = username,并写入 owner.txt,memory.txt 记一条。
  2. 记忆命令:记住 X → 写 rules.txt;清空对话 / 忘记一切。
  3. 记忆查询:你记得什么 → 回显事实 + 规矩 + 好感度。
  4. 自动记个人信息:含"我叫/我住/我喜欢/我讨厌…"的短句自动进 memory.txt。
  5. 命令二次确认:见下。
  6. 编号选项:模型拿不准时给 1)… 2)… 3) 取消。
  7. 夜间先问:晚上让它外出的采集类命令 → 先问"还要我去吗"。
  8. 存箱子模式:字面出现"放箱子/放家里/存起来" → storeMode = true。
  9. 安全指令即时:停下 / 别跟 / 别打 → 立即执行,不确认。
  10. 快速通道:制作/给/采集/配方 → 直接由正则识别,但先 proposeConfirm。
  11. 交给大脑:think() 决定这是"闲聊"还是"命令"。

二次确认的实现(这是用户最新要求的行为):

js
function proposeConfirm(summary, thunk) {
  lookAtOwner();
  say(`你是要我做「${summary}」吗?回「是」我就去做。`);
  pendingExec = { summary, thunk, at: Date.now() };   // 把"要做的事"存起来,等确认
}
  • 是命令(模型给出 actions)→ proposeConfirm(describeActions(actions), () => runPlan(plan, username, message))。
  • 是闲聊 → 直接 say(plan.say)。
  • 下一条消息若匹配 /^(是|对|好|嗯|可以|确认|执行|去吧|行|开始|ok)/ → 执行 pendingExec.thunk();匹配"不/算了/取消" → 放弃;否则当新消息。

主动行为(护主/反击/夜里回家/复活回身边/就近捡物)不经过 onChat,所以不受"二次确认"影响——这正是用户要的"被动的都要确认,主动的不用"。

3.4 语音接入(详见第 6 节) ​

say(text, voice=true) 同时做三件事:打日志(写 bot.log)、如果要发声就 ttsSpeak()、以及 bot.chat() 发到游戏聊天栏。


4. 眼睛:perception.mjs ​

sense(bot, owner) 把游戏状态整理成一个对象,describe(s) 再把它变成一段中文文本喂给模型。

js
export function sense(bot, owner) {
  return {
    health, food, position, dimension, timeOfDay, isNight, held,
    inventory,        // "oak_log×6、dirt×12、…"
    hostiles,         // 附近 20 格的敌对怪(名字+距离)
    animals,          // 附近 24 格的动物
    items,            // 附近 16 格的掉落物
    players,          // 附近 32 格的玩家
    nearbyBlocks,     // 附近 12 格的工作台/熔炉/箱子/水/岩浆/床/火把
    owner,            // 主人的距离/血量/位置
  };
}

describe() 输出形如:

血量 20/20,饥饿 18/20,白天,维度 minecraft:overworld
坐标 123,64,-45,手持 wooden_sword
背包:oak_log×6、dirt×12、bread×3
附近敌对生物:zombie(7m)
附近动物:cow(9m)
附近设施:crafting_table(4m)、chest(6m)
主人 mhd 距离5格 血量20

为什么要给模型"眼睛"? 因为大模型看不到游戏画面。把这些结构化数据塞进 prompt,它才能"看着世界"做决定("附近没树""背包没石头"这类判断就靠它)。

isHostileName / isPassiveName / MEAT_ANIMALS 等集合定义了"什么算敌人/动物/可打的肉源"。


5. 手:tools.mjs(全部动作) ​

5.1 工具是怎么"暴露"给模型的 ​

createTools(bot, ctx) 返回一个 { 工具名: async (args) => 结果 } 的字典;TOOL_SPEC 是一份"工具名 + 参数示意 + 中文说明"的清单,会被 brain.mjs 拼进 prompt。模型只输出 {tool, args},程序查字典调用。

加动作的成本:写一个函数 + 在 TOOL_SPEC 加一行。不用改大脑。

5.2 采集安全:digNames ​

所有采集都走它,它在"动手前"做安全检查:

js
for (let i = 0; i < count; i++) {
  if (ctx?.abort?.() || ctx?.underAttack?.() || nearestHostile(bot, 8)) {   // 被中止/被袭击
    ctx.interrupted = true;
    break;                                                                  // 停手,交给反射层应战
  }
  const block = findBlockByName(bot, names, 64);
  if (!block) break;
  await approach(bot, block, 1, 12000);   // 走到旁边(GoalGetToBlock:站到方块旁,不会站它顶上)
  await bot.dig(block);                   // 从侧面挖 → 天然"不竖直向下挖"
}

"永不竖直向下挖"不是一句提示,而是靠 GoalGetToBlock(站到方块旁边再挖)+ 只挖目标方块实现的。

5.3 合成:ensureItem 递归补材料 ​

这是最"聪明"的一段。目标:说"做个木剑",自动把原木 → 木板 → 木棍 → 剑全补齐。

js
async function ensureItem(bot, name, qty, depth = 4) {
  if (已有数量 >= qty) return true;
  const recipes = bot.recipesFor(itemId, …, table);
  for (const recipe of recipes) {
    for (const d of recipe.delta) {                 // delta:负数是消耗,正数是产出
      if (d.count < 0) {
        // 递归:先把每个原料补齐
        if (!(await ensureItem(bot, ingredientName, -d.count * crafts, depth - 1))) { ok = false; break; }
      }
    }
    if (ok) { await bot.craft(recipe, crafts, table); return 已够; }
  }
}

如果某个配方需要工作台而附近没有,ensureCraftingTable() 会先造一个工作台、放到地上、再用它。

craft_kit 更进一步:一次做一整套(剑/镐/斧/锹/锄),然后走到主人身边丢给他。

5.4 放置与"堵你正看的缺口":placeBlockAt + ownerViewTarget ​

js
async function placeBlockAt(bot, pos, materialName) {
  // 1) 目标格必须是空的 2) 找一个相邻的实心方块当"参照" 3) 太远就先走近 4) 放
}

function ownerViewTarget(bot, owner, maxDist = 6) {
  const o = bot.players[owner]?.entity;
  const eyes = o.position.offset(0, (o.height||1.8)*0.9, 0);
  const dir = { x: -sin(yaw)*cos(pitch), y: -sin(pitch), z: cos(yaw)*cos(pitch) };  // 主人视线方向
  // 沿视线步进,返回"命中实心方块之前的那个空气格" = 缺口
}

于是"用土块把这里堵上"会:读你的朝向 → 射线找到你看的缺口 → 走过去 → 放下土块。

5.5 采集通用化:GATHER_MAP ​

把中文资源名映射到"挖哪些方块/打哪些动物":

js
const GATHER_MAP = [
  { match: /木头|原木|圆木|树|木/, blocks: LOG_NAMES, tool: "axe" },
  { match: /石头|石/, blocks: ["stone","cobblestone"], tool: "pickaxe" },
  { match: /泥土|土/, blocks: ["dirt","grass_block"], tool: "shovel" },
  { match: /煤/, blocks: ["coal_ore","deepslate_coal_ore"], tool: "pickaxe" },
  …
  { match: /羊毛|毛/, hunt: ["sheep"], drop: /wool|mutton/ },
  { match: /肉/, hunt: ["cow","pig","chicken","sheep","rabbit"], drop: /beef|…/ },
];

采集数量按物品个数算:砍12个圆木 → 挖 12 个原木。

5.6 其他值得一提的工具 ​

  • equipWeapon / equipTool:按动作自动换装备(砍树换斧、挖矿换镐、打怪换剑/斧)。没工具就空手(fetchToolFromChest 已被移除,符合"生存前期没有工具就用手"的要求)。
  • store / withdraw:箱子存取。
  • recipe:用 minecraft-data 的配方数据回答"X 要什么材料",不靠模型猜。
  • survey_shelter:扫描家附近,报告"照明/门/床/工作台/熔炉/箱子/屋顶"缺什么(观察类工具,触发"观察→再决定")。
  • feed / craft_kit / gather:高层组合动作。

6. 大脑:brain.mjs ​

6.1 只输出 JSON ​

系统提示词要求模型输出:

json
{"say":"要说的话","actions":[{"tool":"chop","args":{"count":4}}]}

parsePlan(text) 负责把它解析出来,而且很结实:

  • 用"括号配对"从任意文本里抠出第一个 JSON;
  • 解析失败时,用正则从 "say":"…" 里抠出话,避免把原始 JSON 念出来;
  • 只保留 TOOL_NAMES 里存在的工具(模型编的假工具会被丢弃)。

6.2 路由:闲聊 vs 做事 ​

needsAction(message) 用一组强指令词判断"像不像命令"。

  • 不像命令(闲聊) → 走本地 3B,用短提示词(不含工具清单),快。
  • 像命令 → 走"3B 先试,升级大模型":先让 3B 用完整工具提示词试一次,出不了合法动作再依次试 brain.model 和 fallbacks。全失败才回"这个我还不会"。

为什么动作不直接用大模型?因为大模型每次要 spawn opencode run、约 10–30s;3B 常在 1–2s 内就选对常见动作。二者结合=又快又稳。

6.3 流式闲聊:callOpenAIStream ​

闲聊用 Ollama 的 stream:true,边收边按句子(遇 。!?;… 切分)回调 onSentence。这样她能"边想边说",首句约 2 秒就开始出声。

6.4 事件即兴台词:flavor ​

挨打、天黑、你受伤等场景,不再用写死台词,而是让 3B 按情境即兴说一句(并明确要求"不要用口头禅、不要重复上一句");失败才用兜底句。

6.5 长期记忆:summarize ​

每隔约 20 轮,让大模型把旧对话浓缩成 1–3 条长期事实,追加到 memory.txt——所以聊得再多,早期的重要信息也不会丢。


7. 人格与记忆:persona.mjs ​

createMemory(here) 管四样东西:

文件内容
memory.txt长期事实("主人叫 mhd""主人喜欢…")
rules.txt规矩("以后有怪先保护我"),每次对话强制注入
affinity.json好感度,影响称呼与语气
history.json短期对话历史(存 40 条,发给模型只取最近 12 条)

personaPrompt(cfg, mem, mood) 生成人格描述:"你是夕瑟,女仆,和主人玩 Java 版…性格…你们关系…你现在心情…"。relationWord / moodWord 把数值转成自然语言。


8. 语音系统(TTS / STT / 缓存) ​

8.1 她说(TTS) ​

say() → ttsSpeak(text):

  1. 若 voice_cache/index.json 里正好有这句话 → 直接 playWav() 播放缓存的 wav(瞬时、省 GPU);
  2. 否则把这句话塞进 ttsQueue,由 ttsWorker 依次调 GPT-SoVITS 的 /tts 接口合成(gptsovitsSpeak):GET 附带 ref_audio_path(绯莎参考音频)+ prompt_text + 待合成文本,拿回 wav,写到临时文件后 SoundPlayer 播放;
  3. 若 GPT-SoVITS 不可用 → 退回系统语音 tts.ps1(Huihui)。

voice_cache.mjs 预先批量合成一批常用固定台词(问候、确认、报错…)存进 voice_cache/,命中即播。生成语音缓存.cmd 就是跑它。

8.2 你说话(STT) ​

listen.ps1 用 Windows 自带 System.Speech 连续识别(RecognizeAsync),识别到一句就 POST 到 http://127.0.0.1:8790/say;bot.mjs 里的 ensureVoiceInput() 起了个小 HTTP 服务接收,转成 onChat(owner, text)。

  • 唤醒词机制:配置 wakeWord(默认"喂喂喂")。喊一次后进入 60 秒"会话窗口"(每说一句续期),窗口内免唤醒;listen.ps1 会把唤醒词从文本里去掉再发。
  • 回音风险:免唤醒词 + 外放 → 她的声音会被麦克风再识别一遍 → 回环;所以建议戴耳机。

8.3 为什么第一次说话很慢 ​

GPT-SoVITS 首次要加载模型(30–60s),之后每句合成仅 1–2s。启动器负责等待并设置权重,所以"启动时慢一下,玩起来就快"。


9. 配置:config.json(逐项) ​

字段说明
host / port连的服务器(单机局域网:127.0.0.1 + 端口)
username机器人名(也是皮肤站绑定名)
brain.*决策大模型(apiBase 指向 opencode-api;model + fallbacks 组成回退链)
chat.*闲聊模型(本地 Ollama 3B)
followByDefault进服/复活是否自动跟随
historyMax / historySend历史"存多少 / 发给模型多少"
voice.*语音总开关、引擎(gptsovits/sapi)、唤醒词、会话窗口、麦克风端口、GPT-SoVITS 各路径
persona.*名字、性格、口头禅、胆量、主动搭话间隔

改动后要重启才生效(bot.mjs 启动时读一次)。


10. 如何自己扩展 ​

  1. 加一个动作:在 tools.mjs 的返回对象里加 async xxx(args){…},并在 TOOL_SPEC 加一行说明。模型即可调用。
  2. 加一条快速通道:在 bot.mjs#onChat 的"快速通道"区加一段正则,命中就 proposeConfirm(…, () => runFast([…]))。
  3. 换大脑模型:改 config.json 的 brain.model(用 opencode models 里存在的名字)。
  4. 加语音台词到缓存:把句子加进 voice-cache.mjs 的 PHRASES,重新双击 生成语音缓存.cmd。
  5. 改人格:改 config.json#persona 或 persona.mjs#personaPrompt。
  6. 加"自主"行为:在 bot.mjs#brainTick 里加逻辑,但记得遵守"只做简单/安全/就近的事"这一原则。

11. 设计取舍与踩坑 ​

  • 为什么不用大模型做实时战斗:模型延迟秒级,实时反应必须用代码反射层。
  • 为什么动作先给 3B:省时省钱;大模型只在必要时兜底。
  • 为什么工具要"安全优先":canDig=false 保护建筑;GoalGetToBlock 防竖挖;采集在受袭时立即中断交给反射层。
  • 二次确认为什么必要:大模型会误解指令;确认一步能挡住大部分"帮倒忙"。
  • 为什么别迁 WSL:所有服务走 127.0.0.1,而 WSL2 与 Windows 网络隔离,机器人连不上 Windows 上开的游戏;GPT-SoVITS 也是 Windows 形态。
  • owner.txt / home.json:把"主人是谁""家在哪"持久化,重启不忘。

12. 结语:这个项目最值得学的三件事 ​

  1. 分层架构:反射(代码)/决策(模型)/执行(工具)/表达(模型+TTS)各司其职,才可能既实时又聪明。
  2. 给模型"眼睛和手":结构化感知(sense)+ 可调用工具(TOOL_SPEC)+ 严格 JSON 协议,是把 LLM 变成"能干活"的关键。
  3. 渐进式信任:从"全自动"退回到"二次确认",让 AI 的能力和你的控制权取得平衡。

建议阅读顺序:bot.mjs 的 tick 循环 → perception.mjs 的 sense → tools.mjs 的 ensureItem/digNames → brain.mjs 的 think。这四处吃透,你就能随意改造她。