Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/7-e1even_learn-agent.json
T

1 line
24 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "7-e1even/learn-agent", "files": {"notes/s10_prompt_assembly/agent.mjs": {"02dc380a82da9c3900d58631b4295738ebb8ce04": {"t": 1785746410.9929755, "n": 9163, "h": "b5d44e4335a6da21", "c": "#!/usr/bin/env node\n// s10 —— prompt 是拼出来的:s03 的 agent + system prompt 分段拼装 + 技能按需加载。\n//\n// 相对 s03 基底的全部变化:\n// · SYSTEM 字符串常量没了 —— 每轮由 buildSystemPrompt(sections) 确定性拼装\n// · 新增 skills/ 目录 + load_skill 工具:目录进 prompt,正文按需取\n// · 每轮开工前重扫技能目录:运行中新装的技能,下一轮自动可见\n// · 易变信息(当前时间)附在最新一条用户消息尾部,绝不进 system\n//\n// 运行方式与 s03 相同:AGENT_API_KEY=sk-xxx node agent.mjs\n\nimport readline from \"node:readline/promises\";\nimport { execSync } from \"node:child_process\";\nimport { readFileSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { LoopBudget, isRecoverable, repairPrompt } from \"./loop-budget.mjs\";\nimport { buildSystemPrompt, loadSkills, formatSkillsSection } from \"./prompt.mjs\";\n\nconst BASE_URL = process.env.AGENT_BASE_URL ?? \"https://api.deepseek.com/v1\";\nconst API_KEY = process.env.AGENT_API_KEY;\nconst MODEL = process.env.AGENT_MODEL ?? \"deepseek-chat\";\n\nif (!API_KEY) {\n console.error(\"缺少 AGENT_API_KEY。任何 OpenAI 兼容的 key 都行(DeepSeek / Kimi / GLM / OpenRouter / 本地 Ollama)。\");\n process.exit(1);\n}\n\n// 技能目录跟着脚本走(也可用 AGENT_SKILLS_DIR 指到别处,比如 ~/.config/agent/skills)\nconst SKILLS_DIR =\n process.env.AGENT_SKILLS_DIR ?? path.join(path.dirname(fileURLToPath(import.meta.url)), \"skills\");\n\n// ─── system prompt:每轮拼装,但拼装本身是确定性的 ──────────────────────\n//\n// 段的顺序 = 稳定性排序:越不可能变的越靠前。身份永远不变;环境在会话内\n// 不变;技能目录只在用户增删技能时变。任何一段不变,它贡献的字节就不变 ——\n// 目录没动时,两轮的 system prompt 逐字节一致,前缀缓存照样命中。\n\nfunction assembleSystemPrompt(skills) {\n return buildSystemPrompt([\n // ① 身份 —— 最稳定的段放最前\n \"你是一个运行在用户终端里的编程助手。\",\n // ② 环境 —— cwd / 平台在会话内不变。注意:这里没有时间戳。\n `## 环境\\n当前目录:${process.cwd()}\\n操作系统:${process.platform}`,\n // ③ 工具指引\n [\n \"## 工具使用\",\n \"优先用专用工具(read_file / write_file / edit_file)操作文件;run_shell 用于其余一切。\",\n \"先观察真实世界再行动,不要凭空猜测文件内容。\",\n ].join(\"\\n\"),\n // ④ 技能目录 —— 只有 name + description,正文靠 load_skill 按需取\n formatSkillsSection(skills),\n ]);\n}\n\n/** 易变信息走用户消息,不走 system。已写进历史的旧提醒字节不再变化,\n * 所以前缀缓存不受影响;塞进 system 则每轮整个前缀全部 miss。 */\nfunction withVolatileReminder(text) {\n return `${text}\\n\\n<环境提醒>当前时间:${new Date().toISOString()}</环境提醒>`;\n}\n\n// ─── 工具注册表(s03 基底 + load_skill)─────────────────────────────────\n\nconst REGISTRY = {\n run_shell: {\n description: \"在用户的终端里执行一条 shell 命令,返回 stdout 和 stderr。\",\n parameters: {\n type: \"object\",\n properties: { command: { type: \"string\", description: \"要执行的命令\" } },\n required: [\"command\"],\n },\n handler: ({ command }) => {\n console.log(`\\x1b[33m $ ${command}\\x1b[0m`);\n try {\n const out = execSync(command, {\n encoding: \"utf8\",\n timeout: 30_000,\n maxBuffer: 1024 * 1024,\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n return out.trim() || \"(命令执行成功,无输出)\";\n } catch (err) {\n return `命令失败(exit ${err.status ?? \"?\"}):\\n${err.stdout ?? \"\"}${err.stderr ?? err.message}`;\n }\n },\n },\n\n read_file: {\n description: \"读取一个文本文件,返回带行号的内容。\",\n parameters: {\n type: \"object\",\n properties: { path: { type: \"string\", description: \"文件路径(相对或绝对)\" } },\n required: [\"path\"],\n },\n handler: ({ path: p }) => {\n console.log(`\\x1b[33m read ${p}\\x1b[0m`);\n const text = readFileSync(p, \"utf8\");\n const CAP = 50_000;\n const body = text.length > CAP ? text.slice(0, CAP) + `\\n…(截断,共 ${text.length} 字符)` : text;\n return body\n .split(\"\\n\")\n .map((line, i) => `${String(i + 1).padStart(4)}\\t${line}`)\n .join(\"\\n\");\n },\n },\n\n write_file: {\n description: \"写入一个文件(整体覆盖)。父目录不存在时自动创建。\",\n parameters: {\n type: \"object\",\n properties: {\n path: { type: \"string\", description: \"文件路径\" },\n content: { type: \"string\", description: \"完整的文件内容\" },\n },\n required: [\"path\", \"content\"],\n },\n handler: ({ path: p, content }) => {\n console.log(`\\x1b[33m write ${p} (${content.length} 字符)\\x1b[0m`);\n mkdirSync(path.dirname(path.resolve(p)), { recursive: true });\n writeFileSync(p, content);\n return `已写入 ${p}`;\n },\n },\n\n edit_file: {\n description:\n \"对文件做一次精确替换。old_string 必须在文件中出现且仅出现一次(带上足够的上下文来保证唯一),否则会失败。\",\n parameters: {\n type: \"object\",\n properties: {\n path: { type: \"string\", description: \"文件路径\" },\n old_string: { type: \"string\", description: \"要被替换的原文(必须唯一匹配)\" },\n new_string: { type: \"string\", description: \"替换成的新文本\" },\n },\n required: [\"path\", \"old_string\", \"new_string\"],\n },\n handler: ({ path: p, old_string, new_string }) => {\n console.log(`\\x1b[33m edit ${p}\\x1b[0m`);\n const text = readFileSync(p, \"utf8\");\n const first = text.indexOf(old_string);\n if (first === -1) return `编辑失败:old_string 在 ${p} 中找不到。请先 read_file 确认原文。`;\n if (text.indexOf(old_string, first + 1) !== -1)\n return `编辑失败:old_string 在 ${p} 中出现多次。请带上更多上下文让它唯一。`;\n // 不能用 text.replace(old, new):new_string 里的 $$ / $& 会被 JS 当替换模式展开,静默写坏文件。\n writeFileSync(p, text.slice(0, first) + new_string + text.slice(first + old_string.length));\n return `已编辑 ${p}`;\n },\n },\n\n // 本章新增:技能正文的按需加载。目录里那一行描述是\"广告\",这里才是\"全文\"。\n load_skill: {\n description:\n \"读取一个技能的完整指引正文。system prompt 里的技能目录只有一句话描述;当任务和某个技能的描述匹配时,先调用本工具拿到全文再动手。\",\n parameters: {\n type: \"object\",\n properties: { name: { type: \"string\", description: \"技能名(目录里列出的 name)\" } },\n required: [\"name\"],\n },\n handler: ({ name }) => {\n console.log(`\\x1b[33m load_skill ${name}\\x1b[0m`);\n // 每次都重扫磁盘而不是用缓存 —— 技能可能刚被用户编辑过,\n // 正文取最新的;扫描很便宜(几个小文件),不值得为它做失效逻辑。\n const skills = loadSkills(SKILLS_DIR);\n const skill = skills.find((s) => s.name === name);\n if (!skill) {\n const available = skills.map((s) => s.name).join(\"、\") || \"(无)\";\n return `未找到技能\"${name}\"。可用技能:${available}。请使用目录中列出的确切名字。`;\n }\n return `# 技能:${skill.name}\\n\\n${skill.body}`;\n },\n },\n};\n\nconst TOOLS = Object.entries(REGISTRY).map(([name, t]) => ({\n type: \"function\",\n function: { name, description: t.description, parameters: t.parameters },\n}));\n\nconst FAILURE_RE = /^(命令失败|编辑失败|工具执行出错|未知工具|工具参数|未找到技能)/;\n\nfunction dispatch(call) {\n const tool = REGISTRY[call.function.name];\n if (!tool) return `未知工具:${call.function.name}`;\n let args;\n try {\n args = JSON.parse(call.function.arguments || \"{}\");\n } catch (err) {\n return `工具参数不是合法 JSON:${err.message}`;\n }\n try {\n return tool.handler(args);\n } catch (err) {\n return `工具执行出错:${err.message}`;\n }\n}\n\n// ─── 主循环:system 不再是常量,而是本轮的拼装结果 ──────────────────────\n\nasync function chat(system, messages) {\n const res = await fetch(`${BASE_URL}/chat/completions`, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${API_KEY}`,\n },\n body: JSON.stringify({\n model: MODEL,\n messages: [{ role: \"system\", content: system }, ...messages],\n tools: TOOLS,\n }),\n });\n if (!res.ok) throw new Error(`API ${res.status}:${await res.text()}`);\n const data = await res.json();\n return data.choices[0].message;\n}\n\nasync function runTurn(messages) {\n // 每轮开工前重扫技能目录(Reina 的 refreshContext 同款时机)。\n // agent 运行中装的新技能,这里自动进目录 —— 引擎零改动。\n // 拼装在一轮内只做一次:同一轮的多次模型调用里 system 字节完全一致。\n const system = assembleSystemPrompt(loadSkills(SKILLS_DIR));\n\n const budget = new LoopBudget({ baseSteps: 12 });\n let repaired = false;\n\n while (true) {\n if (!budget.canContinue()) {\n const stop = budget.exhaustedStop();\n console.log(`\\n\\x1b[31m⛔ ${stop.message}(第 ${stop.turnCount} 轮)\\x1b[0m`);\n return;\n }\n\n const msg = await chat(system, messages);\n messages.push(msg);\n\n if (msg.content) console.log(`\\n${msg.content}`);\n if (!msg.tool_calls?.length) return;\n\n const records = [];\n for (const call of msg.tool_calls) {\n const output = dispatch(call);\n messages.push({ role: \"tool\", tool_call_id: call.id, content: output });\n let input = {};\n try {\n input = JSON.parse(call.function.arguments || \"{}\");\n } catch { /* 坏参数已作为失败回填 */ }\n records.push({\n name: call.function.name,\n input,\n status: FAILURE_RE.test(output) ? \"failed\" : \"completed\",\n output,\n });\n }\n\n const stop = budget.recordTurn(records);\n if (!stop) continue;\n\n if (isRecoverable(stop) && !repaired) {\n repaired = true;\n console.log(`\\n\\x1b[35m🟡 看门狗触发(${stop.reason}),注入纠偏 prompt…\\x1b[0m`);\n messages.push({ role: \"user\", content: repairPrompt(stop) });\n continue;\n }\n console.log(`\\n\\x1b[31m⛔ ${stop.message}(reason=${stop.reason},第 ${stop.turnCount} 轮)\\x1b[0m`);\n return;\n }\n}\n\nconst rl = readline.createInterface({ input: process.stdin, output: process.stdout });\nconst messages = [];\n\nconst bootSkills = loadSkills(SKILLS_DIR);\nconsole.log(\n `s10 agent 已上线(${MODEL})。工具:${Object.keys(REGISTRY).join(\"、\")}。` +\n `技能目录:${SKILLS_DIR}(${bootSkills.length} 个:${bootSkills.map((s) => s.name).join(\"、\") || \"无\"})。Ctrl+C 退出。`,\n);\n\nwhile (true) {\n const line = (await rl.question(\"\\n你> \")).trim();\n if (!line) continue;\n messages.push({ role: \"user\", content: withVolatileReminder(line) });\n await runTurn(messages);\n}\n"}, "0790470cc3024a1c831fff217476a5f61d7b5a07": {"t": 1785763655.8950965, "n": 9163, "h": "b5d44e4335a6da21", "c": "#!/usr/bin/env node\n// s10 —— prompt 是拼出来的:s03 的 agent + system prompt 分段拼装 + 技能按需加载。\n//\n// 相对 s03 基底的全部变化:\n// · SYSTEM 字符串常量没了 —— 每轮由 buildSystemPrompt(sections) 确定性拼装\n// · 新增 skills/ 目录 + load_skill 工具:目录进 prompt,正文按需取\n// · 每轮开工前重扫技能目录:运行中新装的技能,下一轮自动可见\n// · 易变信息(当前时间)附在最新一条用户消息尾部,绝不进 system\n//\n// 运行方式与 s03 相同:AGENT_API_KEY=sk-xxx node agent.mjs\n\nimport readline from \"node:readline/promises\";\nimport { execSync } from \"node:child_process\";\nimport { readFileSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { LoopBudget, isRecoverable, repairPrompt } from \"./loop-budget.mjs\";\nimport { buildSystemPrompt, loadSkills, formatSkillsSection } from \"./prompt.mjs\";\n\nconst BASE_URL = process.env.AGENT_BASE_URL ?? \"https://api.deepseek.com/v1\";\nconst API_KEY = process.env.AGENT_API_KEY;\nconst MODEL = process.env.AGENT_MODEL ?? \"deepseek-chat\";\n\nif (!API_KEY) {\n console.error(\"缺少 AGENT_API_KEY。任何 OpenAI 兼容的 key 都行(DeepSeek / Kimi / GLM / OpenRouter / 本地 Ollama)。\");\n process.exit(1);\n}\n\n// 技能目录跟着脚本走(也可用 AGENT_SKILLS_DIR 指到别处,比如 ~/.config/agent/skills)\nconst SKILLS_DIR =\n process.env.AGENT_SKILLS_DIR ?? path.join(path.dirname(fileURLToPath(import.meta.url)), \"skills\");\n\n// ─── system prompt:每轮拼装,但拼装本身是确定性的 ──────────────────────\n//\n// 段的顺序 = 稳定性排序:越不可能变的越靠前。身份永远不变;环境在会话内\n// 不变;技能目录只在用户增删技能时变。任何一段不变,它贡献的字节就不变 ——\n// 目录没动时,两轮的 system prompt 逐字节一致,前缀缓存照样命中。\n\nfunction assembleSystemPrompt(skills) {\n return buildSystemPrompt([\n // ① 身份 —— 最稳定的段放最前\n \"你是一个运行在用户终端里的编程助手。\",\n // ② 环境 —— cwd / 平台在会话内不变。注意:这里没有时间戳。\n `## 环境\\n当前目录:${process.cwd()}\\n操作系统:${process.platform}`,\n // ③ 工具指引\n [\n \"## 工具使用\",\n \"优先用专用工具(read_file / write_file / edit_file)操作文件;run_shell 用于其余一切。\",\n \"先观察真实世界再行动,不要凭空猜测文件内容。\",\n ].join(\"\\n\"),\n // ④ 技能目录 —— 只有 name + description,正文靠 load_skill 按需取\n formatSkillsSection(skills),\n ]);\n}\n\n/** 易变信息走用户消息,不走 system。已写进历史的旧提醒字节不再变化,\n * 所以前缀缓存不受影响;塞进 system 则每轮整个前缀全部 miss。 */\nfunction withVolatileReminder(text) {\n return `${text}\\n\\n<环境提醒>当前时间:${new Date().toISOString()}</环境提醒>`;\n}\n\n// ─── 工具注册表(s03 基底 + load_skill)─────────────────────────────────\n\nconst REGISTRY = {\n run_shell: {\n description: \"在用户的终端里执行一条 shell 命令,返回 stdout 和 stderr。\",\n parameters: {\n type: \"object\",\n properties: { command: { type: \"string\", description: \"要执行的命令\" } },\n required: [\"command\"],\n },\n handler: ({ command }) => {\n console.log(`\\x1b[33m $ ${command}\\x1b[0m`);\n try {\n const out = execSync(command, {\n encoding: \"utf8\",\n timeout: 30_000,\n maxBuffer: 1024 * 1024,\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n return out.trim() || \"(命令执行成功,无输出)\";\n } catch (err) {\n return `命令失败(exit ${err.status ?? \"?\"}):\\n${err.stdout ?? \"\"}${err.stderr ?? err.message}`;\n }\n },\n },\n\n read_file: {\n description: \"读取一个文本文件,返回带行号的内容。\",\n parameters: {\n type: \"object\",\n properties: { path: { type: \"string\", description: \"文件路径(相对或绝对)\" } },\n required: [\"path\"],\n },\n handler: ({ path: p }) => {\n console.log(`\\x1b[33m read ${p}\\x1b[0m`);\n const text = readFileSync(p, \"utf8\");\n const CAP = 50_000;\n const body = text.length > CAP ? text.slice(0, CAP) + `\\n…(截断,共 ${text.length} 字符)` : text;\n return body\n .split(\"\\n\")\n .map((line, i) => `${String(i + 1).padStart(4)}\\t${line}`)\n .join(\"\\n\");\n },\n },\n\n write_file: {\n description: \"写入一个文件(整体覆盖)。父目录不存在时自动创建。\",\n parameters: {\n type: \"object\",\n properties: {\n path: { type: \"string\", description: \"文件路径\" },\n content: { type: \"string\", description: \"完整的文件内容\" },\n },\n required: [\"path\", \"content\"],\n },\n handler: ({ path: p, content }) => {\n console.log(`\\x1b[33m write ${p} (${content.length} 字符)\\x1b[0m`);\n mkdirSync(path.dirname(path.resolve(p)), { recursive: true });\n writeFileSync(p, content);\n return `已写入 ${p}`;\n },\n },\n\n edit_file: {\n description:\n \"对文件做一次精确替换。old_string 必须在文件中出现且仅出现一次(带上足够的上下文来保证唯一),否则会失败。\",\n parameters: {\n type: \"object\",\n properties: {\n path: { type: \"string\", description: \"文件路径\" },\n old_string: { type: \"string\", description: \"要被替换的原文(必须唯一匹配)\" },\n new_string: { type: \"string\", description: \"替换成的新文本\" },\n },\n required: [\"path\", \"old_string\", \"new_string\"],\n },\n handler: ({ path: p, old_string, new_string }) => {\n console.log(`\\x1b[33m edit ${p}\\x1b[0m`);\n const text = readFileSync(p, \"utf8\");\n const first = text.indexOf(old_string);\n if (first === -1) return `编辑失败:old_string 在 ${p} 中找不到。请先 read_file 确认原文。`;\n if (text.indexOf(old_string, first + 1) !== -1)\n return `编辑失败:old_string 在 ${p} 中出现多次。请带上更多上下文让它唯一。`;\n // 不能用 text.replace(old, new):new_string 里的 $$ / $& 会被 JS 当替换模式展开,静默写坏文件。\n writeFileSync(p, text.slice(0, first) + new_string + text.slice(first + old_string.length));\n return `已编辑 ${p}`;\n },\n },\n\n // 本章新增:技能正文的按需加载。目录里那一行描述是\"广告\",这里才是\"全文\"。\n load_skill: {\n description:\n \"读取一个技能的完整指引正文。system prompt 里的技能目录只有一句话描述;当任务和某个技能的描述匹配时,先调用本工具拿到全文再动手。\",\n parameters: {\n type: \"object\",\n properties: { name: { type: \"string\", description: \"技能名(目录里列出的 name)\" } },\n required: [\"name\"],\n },\n handler: ({ name }) => {\n console.log(`\\x1b[33m load_skill ${name}\\x1b[0m`);\n // 每次都重扫磁盘而不是用缓存 —— 技能可能刚被用户编辑过,\n // 正文取最新的;扫描很便宜(几个小文件),不值得为它做失效逻辑。\n const skills = loadSkills(SKILLS_DIR);\n const skill = skills.find((s) => s.name === name);\n if (!skill) {\n const available = skills.map((s) => s.name).join(\"、\") || \"(无)\";\n return `未找到技能\"${name}\"。可用技能:${available}。请使用目录中列出的确切名字。`;\n }\n return `# 技能:${skill.name}\\n\\n${skill.body}`;\n },\n },\n};\n\nconst TOOLS = Object.entries(REGISTRY).map(([name, t]) => ({\n type: \"function\",\n function: { name, description: t.description, parameters: t.parameters },\n}));\n\nconst FAILURE_RE = /^(命令失败|编辑失败|工具执行出错|未知工具|工具参数|未找到技能)/;\n\nfunction dispatch(call) {\n const tool = REGISTRY[call.function.name];\n if (!tool) return `未知工具:${call.function.name}`;\n let args;\n try {\n args = JSON.parse(call.function.arguments || \"{}\");\n } catch (err) {\n return `工具参数不是合法 JSON:${err.message}`;\n }\n try {\n return tool.handler(args);\n } catch (err) {\n return `工具执行出错:${err.message}`;\n }\n}\n\n// ─── 主循环:system 不再是常量,而是本轮的拼装结果 ──────────────────────\n\nasync function chat(system, messages) {\n const res = await fetch(`${BASE_URL}/chat/completions`, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${API_KEY}`,\n },\n body: JSON.stringify({\n model: MODEL,\n messages: [{ role: \"system\", content: system }, ...messages],\n tools: TOOLS,\n }),\n });\n if (!res.ok) throw new Error(`API ${res.status}:${await res.text()}`);\n const data = await res.json();\n return data.choices[0].message;\n}\n\nasync function runTurn(messages) {\n // 每轮开工前重扫技能目录(Reina 的 refreshContext 同款时机)。\n // agent 运行中装的新技能,这里自动进目录 —— 引擎零改动。\n // 拼装在一轮内只做一次:同一轮的多次模型调用里 system 字节完全一致。\n const system = assembleSystemPrompt(loadSkills(SKILLS_DIR));\n\n const budget = new LoopBudget({ baseSteps: 12 });\n let repaired = false;\n\n while (true) {\n if (!budget.canContinue()) {\n const stop = budget.exhaustedStop();\n console.log(`\\n\\x1b[31m⛔ ${stop.message}(第 ${stop.turnCount} 轮)\\x1b[0m`);\n return;\n }\n\n const msg = await chat(system, messages);\n messages.push(msg);\n\n if (msg.content) console.log(`\\n${msg.content}`);\n if (!msg.tool_calls?.length) return;\n\n const records = [];\n for (const call of msg.tool_calls) {\n const output = dispatch(call);\n messages.push({ role: \"tool\", tool_call_id: call.id, content: output });\n let input = {};\n try {\n input = JSON.parse(call.function.arguments || \"{}\");\n } catch { /* 坏参数已作为失败回填 */ }\n records.push({\n name: call.function.name,\n input,\n status: FAILURE_RE.test(output) ? \"failed\" : \"completed\",\n output,\n });\n }\n\n const stop = budget.recordTurn(records);\n if (!stop) continue;\n\n if (isRecoverable(stop) && !repaired) {\n repaired = true;\n console.log(`\\n\\x1b[35m🟡 看门狗触发(${stop.reason}),注入纠偏 prompt…\\x1b[0m`);\n messages.push({ role: \"user\", content: repairPrompt(stop) });\n continue;\n }\n console.log(`\\n\\x1b[31m⛔ ${stop.message}(reason=${stop.reason},第 ${stop.turnCount} 轮)\\x1b[0m`);\n return;\n }\n}\n\nconst rl = readline.createInterface({ input: process.stdin, output: process.stdout });\nconst messages = [];\n\nconst bootSkills = loadSkills(SKILLS_DIR);\nconsole.log(\n `s10 agent 已上线(${MODEL})。工具:${Object.keys(REGISTRY).join(\"、\")}。` +\n `技能目录:${SKILLS_DIR}(${bootSkills.length} 个:${bootSkills.map((s) => s.name).join(\"、\") || \"无\"})。Ctrl+C 退出。`,\n);\n\nwhile (true) {\n const line = (await rl.question(\"\\n你> \")).trim();\n if (!line) continue;\n messages.push({ role: \"user\", content: withVolatileReminder(line) });\n await runTurn(messages);\n}\n"}}}}