openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
384 lines (383 loc) • 19.4 kB
JavaScript
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 };