UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

384 lines (383 loc) 19.4 kB
import { m as shortenHomePath } from "./utils-P__uGsPB.js"; import { t as formatCliCommand } from "./command-format-C7YfyMTd.js"; import { m as resolveAgentWorkspaceDir, o as listAgentIds } from "./agent-scope-config-DcbEhP0R.js"; import { w as resolveStateDir } from "./paths-D2sRr1a_.js"; import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import "./agent-scope-DbtJyKUL.js"; import { d as syncDirectoryIfSupported, o as publishFileNoClobber } from "./directory-durability-CINgXRM4.js"; import { o as DEFAULT_TOOLS_FILENAME, t as DEFAULT_AGENTS_FILENAME } from "./workspace-ConDEamr.js"; import { r as resolveBootstrapMaxChars } from "./bootstrap-C0kxr-xC.js"; import "./embedded-agent-helpers-D0eiT3IT.js"; import { t as note } from "./note-DqHk3fA1.js"; import fs from "node:fs"; import path from "node:path"; import fs$1 from "node:fs/promises"; import { createHash } from "node:crypto"; //#region src/commands/doctor-tools-md-migration-budget.ts function resolveToolsMdMigrationWorkspaceTargets(cfg) { const targets = /* @__PURE__ */ new Map(); for (const agentId of listAgentIds(cfg)) { const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const key = path.resolve(workspaceDir); const existing = targets.get(key); if (existing !== void 0) existing.agentIds.push(agentId); else targets.set(key, { primaryAgentId: agentId, agentIds: [agentId], workspaceDir }); } return [...targets.values()]; } /** One finding per agent whose own bootstrap budget cannot hold the merged file. */ function describeToolsMdMergedBootstrapLimits(params) { return params.agentIds.flatMap((agentId) => { const bootstrapMaxChars = resolveBootstrapMaxChars(params.cfg, agentId); if (params.mergedChars <= bootstrapMaxChars) return []; return [{ agentId, message: `Agent "${agentId}" TOOLS.md migration will produce a ${params.mergedChars}-character AGENTS.md, exceeding its configured bootstrapMaxChars limit of ${bootstrapMaxChars}. Raise \`agents.entries.*.bootstrapMaxChars\` for this agent, or \`agents.defaults.bootstrapMaxChars\` as fallback, to preserve all migrated instructions.` }]; }); } //#endregion //#region src/commands/doctor-tools-md-migration-content.ts const LEGACY_TOOLS_MD_TEMPLATE = "# TOOLS.md - Local Notes\n\nSkills define _how_ tools work. This file is for _your_ specifics — the stuff that's unique to your setup: camera names and locations, SSH hosts and aliases, preferred TTS voices, speaker/room names, device nicknames, anything environment-specific.\n\n## Examples\n\n```markdown\n### Cameras\n\n- living-room → Main area, 180° wide angle\n- front-door → Entrance, motion-triggered\n\n### SSH\n\n- home-server → 192.168.1.100, user: admin\n\n### TTS\n\n- Preferred voice: \"Nova\" (warm, slightly British)\n- Default speaker: Kitchen HomePod\n```\n\n## Why Separate?\n\nSkills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share skills without leaking your infrastructure.\n\n---\n\nAdd whatever helps you do your job. This is your cheat sheet.\n\n## Related\n\n- [Agent workspace](/concepts/agent-workspace)\n"; const LEGACY_TOOLS_DEV_MD_TEMPLATE = "# TOOLS.md - User Tool Notes (editable)\n\nThis file is for _your_ notes about external tools and conventions. It does not define which tools exist; OpenClaw provides built-in tools internally, and skills add the rest.\n\n## Examples\n\n### imsg\n\n- Send an iMessage/SMS: describe who/what, confirm before sending.\n- Prefer short messages; avoid sending secrets.\n\n### sag\n\n- Text-to-speech: specify voice, target speaker/room, and whether to stream.\n\nAdd whatever else you want the assistant to know about your local toolchain.\n\n## Related\n\n- [TOOLS.md template](/reference/templates/TOOLS)\n"; const LEGACY_TOOLS_DEV_FALLBACK = "# TOOLS.md - User Tool Notes (editable)\n\nAdd your local tool notes here.\n"; function shouldMergeToolsMd(content) { return content.trim().length > 0 && content !== LEGACY_TOOLS_MD_TEMPLATE && content !== LEGACY_TOOLS_DEV_MD_TEMPLATE && content !== LEGACY_TOOLS_DEV_FALLBACK; } //#endregion //#region src/commands/doctor-tools-md-migration-guidance.ts const LEGACY_AGENTS_TOOLS_GUIDANCE_REWRITES = [ ["Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.", "Skills define how tools work. Keep environment-specific local notes in this section."], ["- Keep environment-specific notes in `TOOLS.md` (notes for skills).", "- Keep environment-specific notes in this file's `## Tools` section."], ["- Keep environment-specific notes in `TOOLS.md` (Notes for Skills).", "- Keep environment-specific notes in this file's `## Tools` section."], ["- You learn a lesson -> update `AGENTS.md`, `TOOLS.md`, or the relevant skill.", "- You learn a lesson -> update `AGENTS.md` or the relevant skill."], ["- When you learn a lesson → update AGENTS.md, TOOLS.md, or the relevant skill", "- When you learn a lesson → update AGENTS.md or the relevant skill"], ["Skills 提供你的工具。当你需要某个工具时,查看它的 `SKILL.md`。在 `TOOLS.md` 中保存本地笔记(摄像头名称、SSH 详情、语音偏好等)。", "Skills 定义工具的使用方式。请将环境特定的本地笔记保存在本节中。"], ["技能提供你的工具。当你需要某个工具时,查看它的 `SKILL.md`。在 `TOOLS.md` 中保存本地笔记(摄像头名称、SSH 详情、语音偏好等)。", "技能定义工具的使用方式。请将环境特定的本地笔记保存在本节中。"], ["- 在 `TOOLS.md` 中保存环境特定的笔记(Skills 注意事项)。", "- 将环境特定的笔记保存在本文件的 `## Tools` 部分。"], ["- 将环境相关的备注保存在 `TOOLS.md`(Skills 备注)中。", "- 将环境相关的备注保存在本文件的 `## Tools` 部分。"], ["- 将环境相关的备注保存在 `TOOLS.md`(技能备注)中。", "- 将环境相关的备注保存在本文件的 `## Tools` 部分。"], ["- 当你学到教训 → 更新 AGENTS.md、TOOLS.md 或相关 Skills 文件", "- 当你学到教训 → 更新 AGENTS.md 或相关 Skills 文件"], ["- 当你学到教训 → 更新 AGENTS.md、TOOLS.md 或相关技能文件", "- 当你学到教训 → 更新 AGENTS.md 或相关技能文件"] ]; function rewriteLegacyAgentsToolsGuidance$1(content) { let rewritten = content; for (const [legacy, current] of LEGACY_AGENTS_TOOLS_GUIDANCE_REWRITES) rewritten = rewritten.replaceAll(legacy, current); return rewritten; } //#endregion //#region src/commands/doctor-tools-md-migration.ts /** Doctor-owned migration from workspace TOOLS.md into the AGENTS.md Tools section. */ const TOOLS_MD_MIGRATION_CHECK_ID = "core/doctor/tools-md-migration"; const MIGRATED_SUBSECTION_HEADING = "### Local notes (migrated from TOOLS.md)"; const NO_CLOBBER_PUBLICATION = { strategy: "link-or-copy", durability: "degrade" }; function sha256(content) { return createHash("sha256").update(content).digest("hex"); } async function readMigrationFileSnapshot(params) { let stat; try { stat = await fs$1.lstat(params.filePath); } catch (error) { if (params.allowMissing && error.code === "ENOENT") return { content: "" }; throw error; } if (!stat.isFile() || stat.nlink > 1) throw new Error(`${params.label} must be an unlinked regular file for automatic migration`); const noFollow = fs.constants.O_NOFOLLOW ?? 0; const handle = await fs$1.open(params.filePath, fs.constants.O_RDONLY | noFollow); try { const openedStat = await handle.stat(); if (!openedStat.isFile() || openedStat.nlink !== 1 || openedStat.dev !== stat.dev || openedStat.ino !== stat.ino) throw new Error(`${params.label} changed while opening it for migration`); const content = await handle.readFile("utf8"); const currentStat = await fs$1.lstat(params.filePath); if (currentStat.dev !== openedStat.dev || currentStat.ino !== openedStat.ino) throw new Error(`${params.label} changed while opening it for migration`); return { content, stat: openedStat }; } finally { await handle.close(); } } async function readToolsMd(workspaceDir) { const betaArtifacts = (await fs$1.readdir(workspaceDir).catch(() => [])).filter((entry) => entry.startsWith(`TOOLS.md.doctor-importing-`) || entry.startsWith(`AGENTS.md.doctor-backup-`)); if (betaArtifacts.length > 0) throw new Error(`Interrupted v2026.7.2-beta.5 migration artifact(s) left untouched: ${betaArtifacts.join(", ")}. Restore the desired file manually before rerunning doctor.`); const toolsPath = path.join(workspaceDir, DEFAULT_TOOLS_FILENAME); const snapshot = await readMigrationFileSnapshot({ filePath: toolsPath, label: "TOOLS.md", allowMissing: true }); if (!snapshot.stat) return; return { path: toolsPath, content: snapshot.content, sha256: sha256(snapshot.content), stat: snapshot.stat }; } function migratedBlock(content) { return `${MIGRATED_SUBSECTION_HEADING}\n\n${content}`; } function appendWithSpacing(before, addition, after = "") { return `${before}${before.length === 0 ? "" : before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n"}${addition}${after.length === 0 ? "" : addition.endsWith("\n\n") ? "" : addition.endsWith("\n") ? "\n" : "\n\n"}${after}`; } function mergeToolsMdIntoAgentsMd(agentsContent, toolsContent) { const mergedAgentsContent = rewriteLegacyAgentsToolsGuidance(agentsContent); if (mergedAgentsContent.includes(MIGRATED_SUBSECTION_HEADING)) { if (mergedAgentsContent.includes(toolsContent)) return mergedAgentsContent; const insertAt = mergedAgentsContent.indexOf(MIGRATED_SUBSECTION_HEADING) + 40; return appendWithSpacing(mergedAgentsContent.slice(0, insertAt), toolsContent, mergedAgentsContent.slice(insertAt)); } const block = migratedBlock(toolsContent); const toolsSection = findToolsSection(mergedAgentsContent); if (!toolsSection) return appendWithSpacing(mergedAgentsContent, `## Tools\n\n${block}`); const insertAt = toolsSection.insertAt; return appendWithSpacing(mergedAgentsContent.slice(0, insertAt), block, mergedAgentsContent.slice(insertAt)); } function rewriteLegacyAgentsToolsGuidance(content) { const rewritten = rewriteLegacyAgentsToolsGuidance$1(content); return rewritten === content ? content : ensureLocalNotesHeading(rewritten); } function findToolsSection(content) { let offset = 0; let insideTools = false; let headingEnd = 0; let fence; for (const lineWithEnding of content.match(/.*(?:\n|$)/gu) ?? []) { if (lineWithEnding === "") continue; const line = lineWithEnding.replace(/\n$/u, ""); const fenceRun = /^\s*(`{3,}|~{3,})/u.exec(line)?.[1]; const closingFenceRun = /^\s*(`{3,}|~{3,})\s*$/u.exec(line)?.[1]; const marker = fenceRun?.[0]; if (marker && !fence) fence = { marker, length: fenceRun.length }; else if (closingFenceRun && fence && closingFenceRun[0] === fence.marker && closingFenceRun.length >= fence.length) fence = void 0; else if (!fence) { const heading = /^(#{1,6})\s+(.+?)\s*#*\s*$/u.exec(line); if (heading) { const depth = heading[1].length; if (insideTools && depth <= 2) return { headingEnd, insertAt: offset }; if (depth === 2 && heading[2].trim().toLowerCase() === "tools") { insideTools = true; headingEnd = offset + lineWithEnding.length; } } } offset += lineWithEnding.length; } return insideTools ? { headingEnd, insertAt: content.length } : void 0; } function ensureLocalNotesHeading(content) { const section = findToolsSection(content); if (!section) return content; const body = content.slice(section.headingEnd, section.insertAt); if (/^###\s+Local notes(?:\s|$)/imu.test(body)) return content; return `${content.slice(0, section.headingEnd)}\n### Local notes\n${content.slice(section.headingEnd)}`; } async function writeAgentsAtomically(params) { const snapshot = await readMigrationFileSnapshot({ filePath: params.agentsPath, label: "AGENTS.md", allowMissing: true }); if (snapshot.content !== params.expected) throw new Error("AGENTS.md changed during TOOLS.md migration"); const stat = snapshot.stat; const mode = stat?.mode ?? 384; const tempPath = `${params.agentsPath}.doctor-writing-${process.pid}-${Date.now()}`; try { const handle = await fs$1.open(tempPath, "wx", mode); try { await handle.writeFile(params.content, "utf8"); await handle.sync(); } finally { await handle.close(); } const current = await readMigrationFileSnapshot({ filePath: params.agentsPath, label: "AGENTS.md", allowMissing: true }); if (current.content !== params.expected || current.stat?.dev !== stat?.dev || current.stat?.ino !== stat?.ino) throw new Error("AGENTS.md changed during TOOLS.md migration"); await fs$1.rename(tempPath, params.agentsPath); await syncDirectoryIfSupported(path.dirname(params.agentsPath)); } catch (error) { await fs$1.rm(tempPath, { force: true }); throw error; } } async function recoverInterruptedAgentsWrite(agentsPath) { const dir = path.dirname(agentsPath); const prefix = `${path.basename(agentsPath)}.doctor-writing-`; const entries = await fs$1.readdir(dir).catch(() => []); let removed = false; for (const entry of entries) { if (!entry.startsWith(prefix)) continue; const tempPath = path.join(dir, entry); const stat = await fs$1.lstat(tempPath); if (!stat.isFile() || stat.nlink !== 1) throw new Error(`interrupted AGENTS.md write must be an unlinked regular file: ${tempPath}`); await fs$1.rm(tempPath); removed = true; } if (removed) await syncDirectoryIfSupported(dir); } async function removeToolsSource(source, workspaceDir) { const current = await readMigrationFileSnapshot({ filePath: source.path, label: "TOOLS.md" }); if (sha256(current.content) !== source.sha256) throw new Error("TOOLS.md changed during migration"); const currentStat = fs.lstatSync(source.path); if (!current.stat || currentStat.dev !== current.stat.dev || currentStat.ino !== current.stat.ino || currentStat.dev !== source.stat.dev || currentStat.ino !== source.stat.ino) throw new Error("TOOLS.md changed during migration"); fs.unlinkSync(source.path); await syncDirectoryIfSupported(workspaceDir); } function archivePathForSource(agentId, source, env) { const safeAgentId = agentId.replace(/[^A-Za-z0-9._-]+/g, "-"); return path.join(resolveStateDir(env), "backups", "tools-md-migration", `${safeAgentId}-${source.sha256}.md`); } async function archiveSource(params) { const archivePath = archivePathForSource(params.agentId, params.source, params.env); const archiveDir = path.dirname(archivePath); await fs$1.mkdir(archiveDir, { recursive: true, mode: 448 }); const tempPath = `${archivePath}.doctor-writing-${process.pid}-${Date.now()}`; try { const handle = await fs$1.open(tempPath, "wx", 384); try { await handle.writeFile(params.source.content, "utf8"); await handle.sync(); } finally { await handle.close(); } await publishFileNoClobber(tempPath, archivePath, NO_CLOBBER_PUBLICATION); await fs$1.rm(tempPath); await syncDirectoryIfSupported(archiveDir); } catch (error) { await fs$1.rm(tempPath, { force: true }); if (error.code !== "EEXIST") throw error; if (sha256(await fs$1.readFile(archivePath, "utf8")) !== params.source.sha256) throw new Error(`TOOLS.md migration archive collision at ${archivePath}`, { cause: error }); } return archivePath; } function migrationFinding(params) { return { checkId: TOOLS_MD_MIGRATION_CHECK_ID, severity: params.severity ?? "warning", message: params.message, path: params.path, target: params.agentId, requirement: params.requirement, fixHint: `Run ${formatCliCommand("openclaw doctor --fix")} to merge TOOLS.md into AGENTS.md.` }; } async function collectToolsMdMigrationFindings(cfg) { const findings = []; for (const target of resolveToolsMdMigrationWorkspaceTargets(cfg)) try { const source = await readToolsMd(target.workspaceDir); if (source) { findings.push(migrationFinding({ agentId: target.primaryAgentId, path: source.path, message: `Agent "${target.primaryAgentId}" still stores local tool notes in TOOLS.md.`, requirement: "legacy-tools-md" })); if (shouldMergeToolsMd(source.content)) { const agentsPath = path.join(target.workspaceDir, DEFAULT_AGENTS_FILENAME); const agentsContent = (await readMigrationFileSnapshot({ filePath: agentsPath, label: "AGENTS.md", allowMissing: true })).content; const mergedChars = mergeToolsMdIntoAgentsMd(agentsContent, source.content).length; for (const budget of describeToolsMdMergedBootstrapLimits({ cfg, agentIds: target.agentIds, mergedChars })) findings.push(migrationFinding({ agentId: budget.agentId, path: agentsPath, message: budget.message, requirement: "tools-md-merged-bootstrap-limit" })); } } } catch (error) { findings.push(migrationFinding({ agentId: target.primaryAgentId, path: path.join(target.workspaceDir, DEFAULT_TOOLS_FILENAME), message: `Agent "${target.primaryAgentId}" TOOLS.md cannot be migrated: ${formatErrorMessage(error)}`, severity: "error", requirement: "tools-md-migration-blocked" })); } return findings; } async function maybeMigrateToolsMd(params) { const env = params.env ?? process.env; const changes = []; const warnings = []; for (const target of resolveToolsMdMigrationWorkspaceTargets(params.cfg)) try { const source = await readToolsMd(target.workspaceDir); if (!source) continue; if (!params.shouldRepair) { note(`${shortenHomePath(source.path)} will be archived and merged into AGENTS.md when customized.`, "TOOLS.md migration preview"); continue; } const shouldMerge = shouldMergeToolsMd(source.content); await archiveSource({ agentId: target.primaryAgentId, source, env }); const agentsPath = path.join(target.workspaceDir, DEFAULT_AGENTS_FILENAME); await recoverInterruptedAgentsWrite(agentsPath); const agentsContent = (await readMigrationFileSnapshot({ filePath: agentsPath, label: "AGENTS.md", allowMissing: true })).content; const merged = shouldMerge ? mergeToolsMdIntoAgentsMd(agentsContent, source.content) : rewriteLegacyAgentsToolsGuidance(agentsContent); if (merged !== agentsContent) { await writeAgentsAtomically({ agentsPath, expected: agentsContent, content: merged }); if ((await readMigrationFileSnapshot({ filePath: agentsPath, label: "AGENTS.md" })).content !== merged) throw new Error("AGENTS.md changed after TOOLS.md migration was written"); } await syncDirectoryIfSupported(target.workspaceDir); await removeToolsSource(source, target.workspaceDir); changes.push(shouldMerge ? `Merged ${shortenHomePath(source.path)} into AGENTS.md and archived the original.` : `Removed untouched ${shortenHomePath(source.path)} after archiving it.`); } catch (error) { warnings.push(`Agent "${target.primaryAgentId}" TOOLS.md was not migrated: ${formatErrorMessage(error)}`); } if (changes.length > 0) note(changes.join("\n"), "TOOLS.md migration"); if (warnings.length > 0) note(warnings.join("\n"), "Doctor warnings"); return { changes, warnings }; } //#endregion export { collectToolsMdMigrationFindings, maybeMigrateToolsMd };