mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
feat(tool-display): add intent-first details and exec summaries
- add human-readable read/write/edit/attach details with path alias support\n- add explicit web_search/web_fetch phrasing (quoted query, mode/limit)\n- make detail text title-first by returning detail-only in formatters\n- add deterministic exec summarizer (wrappers, pipelines, heredoc, git/node/python heuristics, preamble stripping)\n- extend e2e coverage for file/web/exec cases
This commit is contained in:
committed by
Peter Steinberger
parent
b9c45d003d
commit
24f213e7ed
@@ -18,6 +18,12 @@ export type CoerceDisplayValueOptions = {
|
||||
maxArrayEntries?: number;
|
||||
};
|
||||
|
||||
type ArgsRecord = Record<string, unknown>;
|
||||
|
||||
function asRecord(args: unknown): ArgsRecord | undefined {
|
||||
return args && typeof args === "object" ? (args as ArgsRecord) : undefined;
|
||||
}
|
||||
|
||||
export function normalizeToolName(name?: string): string {
|
||||
return (name ?? "tool").trim();
|
||||
}
|
||||
@@ -127,30 +133,732 @@ export function formatDetailKey(raw: string, overrides: Record<string, string> =
|
||||
return spaced.trim().toLowerCase() || last.toLowerCase();
|
||||
}
|
||||
|
||||
export function resolveReadDetail(args: unknown): string | undefined {
|
||||
if (!args || typeof args !== "object") {
|
||||
export function resolvePathArg(args: unknown): string | undefined {
|
||||
const record = asRecord(args);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const record = args as Record<string, unknown>;
|
||||
const path = typeof record.path === "string" ? record.path : undefined;
|
||||
for (const candidate of [record.path, record.file_path, record.filePath]) {
|
||||
if (typeof candidate !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveReadDetail(args: unknown): string | undefined {
|
||||
const record = asRecord(args);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const path = resolvePathArg(record);
|
||||
if (!path) {
|
||||
return undefined;
|
||||
}
|
||||
const offset = typeof record.offset === "number" ? record.offset : undefined;
|
||||
const limit = typeof record.limit === "number" ? record.limit : undefined;
|
||||
|
||||
const offsetRaw =
|
||||
typeof record.offset === "number" && Number.isFinite(record.offset)
|
||||
? Math.floor(record.offset)
|
||||
: undefined;
|
||||
const limitRaw =
|
||||
typeof record.limit === "number" && Number.isFinite(record.limit)
|
||||
? Math.floor(record.limit)
|
||||
: undefined;
|
||||
|
||||
const offset = offsetRaw !== undefined ? Math.max(1, offsetRaw) : undefined;
|
||||
const limit = limitRaw !== undefined ? Math.max(1, limitRaw) : undefined;
|
||||
|
||||
if (offset !== undefined && limit !== undefined) {
|
||||
return `${path}:${offset}-${offset + limit}`;
|
||||
const unit = limit === 1 ? "line" : "lines";
|
||||
return `${unit} ${offset}-${offset + limit - 1} from ${path}`;
|
||||
}
|
||||
return path;
|
||||
if (offset !== undefined) {
|
||||
return `from line ${offset} in ${path}`;
|
||||
}
|
||||
if (limit !== undefined) {
|
||||
const unit = limit === 1 ? "line" : "lines";
|
||||
return `first ${limit} ${unit} of ${path}`;
|
||||
}
|
||||
return `from ${path}`;
|
||||
}
|
||||
|
||||
export function resolveWriteDetail(args: unknown): string | undefined {
|
||||
if (!args || typeof args !== "object") {
|
||||
export function resolveWriteDetail(toolKey: string, args: unknown): string | undefined {
|
||||
const record = asRecord(args);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const record = args as Record<string, unknown>;
|
||||
const path = typeof record.path === "string" ? record.path : undefined;
|
||||
return path;
|
||||
|
||||
const path = resolvePathArg(record) ?? (typeof record.url === "string" ? record.url.trim() : undefined);
|
||||
if (!path) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (toolKey === "attach") {
|
||||
return `from ${path}`;
|
||||
}
|
||||
|
||||
const destinationPrefix = toolKey === "edit" ? "in" : "to";
|
||||
const content =
|
||||
typeof record.content === "string"
|
||||
? record.content
|
||||
: typeof record.newText === "string"
|
||||
? record.newText
|
||||
: typeof record.new_string === "string"
|
||||
? record.new_string
|
||||
: undefined;
|
||||
|
||||
if (content && content.length > 0) {
|
||||
return `${destinationPrefix} ${path} (${content.length} chars)`;
|
||||
}
|
||||
|
||||
return `${destinationPrefix} ${path}`;
|
||||
}
|
||||
|
||||
function stripOuterQuotes(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (
|
||||
trimmed.length >= 2 &&
|
||||
((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'")))
|
||||
) {
|
||||
return trimmed.slice(1, -1).trim();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function splitShellWords(input: string | undefined, maxWords = 48): string[] {
|
||||
if (!input) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const words: string[] = [];
|
||||
let current = "";
|
||||
let quote: '"' | "'" | undefined;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const char = input[i];
|
||||
|
||||
if (escaped) {
|
||||
current += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (char === quote) {
|
||||
quote = undefined;
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(char)) {
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
words.push(current);
|
||||
if (words.length >= maxWords) {
|
||||
return words;
|
||||
}
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
words.push(current);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
function binaryName(token: string | undefined): string | undefined {
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
const cleaned = stripOuterQuotes(token) ?? token;
|
||||
const segment = cleaned.split(/[\/]/).at(-1) ?? cleaned;
|
||||
return segment.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function optionValue(words: string[], names: string[]): string | undefined {
|
||||
const lookup = new Set(names);
|
||||
|
||||
for (let i = 0; i < words.length; i += 1) {
|
||||
const token = words[i];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lookup.has(token)) {
|
||||
const value = words[i + 1];
|
||||
if (value && !value.startsWith("-")) {
|
||||
return value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const name of names) {
|
||||
if (name.startsWith("--") && token.startsWith(`${name}=`)) {
|
||||
return token.slice(name.length + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function positionalArgs(words: string[], from = 1, optionsWithValue: string[] = []): string[] {
|
||||
const args: string[] = [];
|
||||
const takesValue = new Set(optionsWithValue);
|
||||
|
||||
for (let i = from; i < words.length; i += 1) {
|
||||
const token = words[i];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === "--") {
|
||||
for (let j = i + 1; j < words.length; j += 1) {
|
||||
const candidate = words[j];
|
||||
if (candidate) {
|
||||
args.push(candidate);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (token.startsWith("--")) {
|
||||
if (token.includes("=")) {
|
||||
continue;
|
||||
}
|
||||
if (takesValue.has(token)) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("-")) {
|
||||
if (takesValue.has(token)) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
args.push(token);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function firstPositional(words: string[], from = 1, optionsWithValue: string[] = []): string | undefined {
|
||||
return positionalArgs(words, from, optionsWithValue)[0];
|
||||
}
|
||||
|
||||
function trimLeadingEnv(words: string[]): string[] {
|
||||
if (words.length === 0) {
|
||||
return words;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
if (binaryName(words[0]) === "env") {
|
||||
index = 1;
|
||||
while (index < words.length) {
|
||||
const token = words[index];
|
||||
if (!token) {
|
||||
break;
|
||||
}
|
||||
if (token.startsWith("-")) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return words.slice(index);
|
||||
}
|
||||
|
||||
while (index < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index])) {
|
||||
index += 1;
|
||||
}
|
||||
return words.slice(index);
|
||||
}
|
||||
|
||||
function unwrapShellWrapper(command: string): string {
|
||||
const words = splitShellWords(command, 10);
|
||||
if (words.length < 3) {
|
||||
return command;
|
||||
}
|
||||
|
||||
const bin = binaryName(words[0]);
|
||||
if (!(bin === "bash" || bin === "sh" || bin === "zsh" || bin === "fish")) {
|
||||
return command;
|
||||
}
|
||||
|
||||
const flagIndex = words.findIndex(
|
||||
(token, index) => index > 0 && (token === "-c" || token === "-lc" || token === "-ic"),
|
||||
);
|
||||
if (flagIndex === -1) {
|
||||
return command;
|
||||
}
|
||||
|
||||
const inner = words.slice(flagIndex + 1).join(" ").trim();
|
||||
return inner ? (stripOuterQuotes(inner) ?? command) : command;
|
||||
}
|
||||
|
||||
function firstTopLevelStage(command: string): string {
|
||||
let quote: '"' | "'" | undefined;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < command.length; i += 1) {
|
||||
const char = command[i];
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (char === quote) {
|
||||
quote = undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ";") {
|
||||
return command.slice(0, i);
|
||||
}
|
||||
if ((char === "&" || char === "|") && command[i + 1] === char) {
|
||||
return command.slice(0, i);
|
||||
}
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
function splitTopLevelPipes(command: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let quote: '"' | "'" | undefined;
|
||||
let escaped = false;
|
||||
let start = 0;
|
||||
|
||||
for (let i = 0; i < command.length; i += 1) {
|
||||
const char = command[i];
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (char === quote) {
|
||||
quote = undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "|" && command[i - 1] !== "|" && command[i + 1] !== "|") {
|
||||
parts.push(command.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(command.slice(start));
|
||||
return parts.map((part) => part.trim()).filter((part) => part.length > 0);
|
||||
}
|
||||
|
||||
function stripShellPreamble(command: string): string {
|
||||
let rest = command.trim();
|
||||
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const andIndex = rest.indexOf("&&");
|
||||
const semicolonIndex = rest.indexOf(";");
|
||||
const newlineIndex = rest.indexOf("\n");
|
||||
|
||||
const candidates = [
|
||||
{ index: andIndex, length: 2 },
|
||||
{ index: semicolonIndex, length: 1 },
|
||||
{ index: newlineIndex, length: 1 },
|
||||
]
|
||||
.filter((candidate) => candidate.index >= 0)
|
||||
.sort((a, b) => a.index - b.index);
|
||||
|
||||
const first = candidates[0];
|
||||
const head = (first ? rest.slice(0, first.index) : rest).trim();
|
||||
const isPreamble =
|
||||
head.startsWith("set ") || head.startsWith("export ") || head.startsWith("unset ");
|
||||
|
||||
if (!isPreamble) {
|
||||
break;
|
||||
}
|
||||
|
||||
rest = first ? rest.slice(first.index + first.length).trimStart() : "";
|
||||
if (!rest) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return rest.trim();
|
||||
}
|
||||
|
||||
function summarizeKnownExec(words: string[]): string {
|
||||
if (words.length === 0) {
|
||||
return "run command";
|
||||
}
|
||||
|
||||
const bin = binaryName(words[0]) ?? "command";
|
||||
|
||||
if (bin === "git") {
|
||||
const subcommands = new Set([
|
||||
"status",
|
||||
"diff",
|
||||
"log",
|
||||
"show",
|
||||
"branch",
|
||||
"checkout",
|
||||
"switch",
|
||||
"commit",
|
||||
"pull",
|
||||
"push",
|
||||
"fetch",
|
||||
"merge",
|
||||
"rebase",
|
||||
"add",
|
||||
"restore",
|
||||
"reset",
|
||||
"stash",
|
||||
]);
|
||||
const globalWithValue = new Set([
|
||||
"-C",
|
||||
"-c",
|
||||
"--git-dir",
|
||||
"--work-tree",
|
||||
"--namespace",
|
||||
"--config-env",
|
||||
]);
|
||||
|
||||
const gitCwd = optionValue(words, ["-C"]);
|
||||
|
||||
let sub: string | undefined;
|
||||
for (let i = 1; i < words.length; i += 1) {
|
||||
const token = words[i];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
if (token === "--") {
|
||||
sub = firstPositional(words, i + 1);
|
||||
break;
|
||||
}
|
||||
if (token.startsWith("--")) {
|
||||
if (token.includes("=")) {
|
||||
continue;
|
||||
}
|
||||
if (globalWithValue.has(token)) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith("-")) {
|
||||
if (globalWithValue.has(token)) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
sub = token;
|
||||
break;
|
||||
}
|
||||
|
||||
const map: Record<string, string> = {
|
||||
status: "check git status",
|
||||
diff: "check git diff",
|
||||
log: "view git history",
|
||||
show: "show git object",
|
||||
branch: "list git branches",
|
||||
checkout: "switch git branch",
|
||||
switch: "switch git branch",
|
||||
commit: "create git commit",
|
||||
pull: "pull git changes",
|
||||
push: "push git changes",
|
||||
fetch: "fetch git changes",
|
||||
merge: "merge git changes",
|
||||
rebase: "rebase git branch",
|
||||
add: "stage git changes",
|
||||
restore: "restore git files",
|
||||
reset: "reset git state",
|
||||
stash: "stash git changes",
|
||||
};
|
||||
|
||||
if (sub && map[sub]) {
|
||||
return map[sub];
|
||||
}
|
||||
if (!sub || sub.startsWith("/") || sub.startsWith("~") || sub.includes("/")) {
|
||||
return gitCwd ? `run git command in ${gitCwd}` : "run git command";
|
||||
}
|
||||
return `run git ${sub}`;
|
||||
}
|
||||
|
||||
if (bin === "grep" || bin === "rg" || bin === "ripgrep") {
|
||||
const positional = positionalArgs(words, 1, [
|
||||
"-e",
|
||||
"--regexp",
|
||||
"-f",
|
||||
"--file",
|
||||
"-m",
|
||||
"--max-count",
|
||||
"-A",
|
||||
"--after-context",
|
||||
"-B",
|
||||
"--before-context",
|
||||
"-C",
|
||||
"--context",
|
||||
]);
|
||||
const pattern = optionValue(words, ["-e", "--regexp"]) ?? positional[0];
|
||||
const target = positional.length > 1 ? positional.at(-1) : undefined;
|
||||
if (pattern) {
|
||||
return target ? `search "${pattern}" in ${target}` : `search "${pattern}"`;
|
||||
}
|
||||
return "search text";
|
||||
}
|
||||
|
||||
if (bin === "find") {
|
||||
const path = words[1] && !words[1].startsWith("-") ? words[1] : ".";
|
||||
const name = optionValue(words, ["-name", "-iname"]);
|
||||
return name ? `find files named "${name}" in ${path}` : `find files in ${path}`;
|
||||
}
|
||||
|
||||
if (bin === "ls") {
|
||||
const target = firstPositional(words, 1);
|
||||
return target ? `list files in ${target}` : "list files";
|
||||
}
|
||||
|
||||
if (bin === "head" || bin === "tail") {
|
||||
const lines =
|
||||
optionValue(words, ["-n", "--lines"]) ??
|
||||
words.slice(1).find((token) => /^-\d+$/.test(token))?.slice(1);
|
||||
const positional = positionalArgs(words, 1, ["-n", "--lines"]);
|
||||
let target = positional.at(-1);
|
||||
if (target && /^\d+$/.test(target) && positional.length === 1) {
|
||||
target = undefined;
|
||||
}
|
||||
const side = bin === "head" ? "first" : "last";
|
||||
const unit = lines === "1" ? "line" : "lines";
|
||||
if (lines && target) {
|
||||
return `show ${side} ${lines} ${unit} of ${target}`;
|
||||
}
|
||||
if (lines) {
|
||||
return `show ${side} ${lines} ${unit}`;
|
||||
}
|
||||
if (target) {
|
||||
return `show ${target}`;
|
||||
}
|
||||
return `show ${bin} output`;
|
||||
}
|
||||
|
||||
if (bin === "cat") {
|
||||
const target = firstPositional(words, 1);
|
||||
return target ? `show ${target}` : "show output";
|
||||
}
|
||||
|
||||
if (bin === "sed") {
|
||||
const expression = optionValue(words, ["-e", "--expression"]);
|
||||
const positional = positionalArgs(words, 1, ["-e", "--expression", "-f", "--file"]);
|
||||
const script = expression ?? positional[0];
|
||||
const target = expression ? positional[0] : positional[1];
|
||||
|
||||
if (script) {
|
||||
const compact = (stripOuterQuotes(script) ?? script).replace(/\s+/g, "");
|
||||
const range = compact.match(/^([0-9]+),([0-9]+)p$/);
|
||||
if (range) {
|
||||
return target
|
||||
? `print lines ${range[1]}-${range[2]} from ${target}`
|
||||
: `print lines ${range[1]}-${range[2]}`;
|
||||
}
|
||||
const single = compact.match(/^([0-9]+)p$/);
|
||||
if (single) {
|
||||
return target ? `print line ${single[1]} from ${target}` : `print line ${single[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
return target ? `run sed on ${target}` : "run sed transform";
|
||||
}
|
||||
|
||||
if (bin === "printf" || bin === "echo") {
|
||||
return "print text";
|
||||
}
|
||||
|
||||
if (bin === "cp" || bin === "mv") {
|
||||
const positional = positionalArgs(words, 1, ["-t", "--target-directory", "-S", "--suffix"]);
|
||||
const src = positional[0];
|
||||
const dst = positional[1];
|
||||
const action = bin === "cp" ? "copy" : "move";
|
||||
if (src && dst) {
|
||||
return `${action} ${src} to ${dst}`;
|
||||
}
|
||||
if (src) {
|
||||
return `${action} ${src}`;
|
||||
}
|
||||
return `${action} files`;
|
||||
}
|
||||
|
||||
if (bin === "rm") {
|
||||
const target = firstPositional(words, 1);
|
||||
return target ? `remove ${target}` : "remove files";
|
||||
}
|
||||
|
||||
if (bin === "mkdir") {
|
||||
const target = firstPositional(words, 1);
|
||||
return target ? `create folder ${target}` : "create folder";
|
||||
}
|
||||
|
||||
if (bin === "touch") {
|
||||
const target = firstPositional(words, 1);
|
||||
return target ? `create file ${target}` : "create file";
|
||||
}
|
||||
|
||||
if (bin === "curl" || bin === "wget") {
|
||||
const url = words.find((token) => /^https?:\/\//i.test(token));
|
||||
return url ? `fetch ${url}` : "fetch url";
|
||||
}
|
||||
|
||||
if (bin === "npm" || bin === "pnpm" || bin === "yarn" || bin === "bun") {
|
||||
const positional = positionalArgs(words, 1, ["--prefix", "-C", "--cwd", "--config"]);
|
||||
const sub = positional[0] ?? "command";
|
||||
const map: Record<string, string> = {
|
||||
install: "install dependencies",
|
||||
test: "run tests",
|
||||
build: "run build",
|
||||
start: "start app",
|
||||
lint: "run lint",
|
||||
run: positional[1] ? `run ${positional[1]}` : "run script",
|
||||
};
|
||||
return map[sub] ?? `run ${bin} ${sub}`;
|
||||
}
|
||||
|
||||
if (bin === "node" || bin === "python" || bin === "python3" || bin === "ruby" || bin === "php") {
|
||||
const heredoc = words.slice(1).find((token) => token.startsWith("<<"));
|
||||
if (heredoc) {
|
||||
return `run ${bin} inline script (heredoc)`;
|
||||
}
|
||||
|
||||
const inline =
|
||||
bin === "node"
|
||||
? optionValue(words, ["-e", "--eval"])
|
||||
: bin === "python" || bin === "python3"
|
||||
? optionValue(words, ["-c"])
|
||||
: undefined;
|
||||
if (inline !== undefined) {
|
||||
return `run ${bin} inline script`;
|
||||
}
|
||||
|
||||
const script = firstPositional(words, 1, ["-c", "-e", "--eval", "-m"]);
|
||||
if (!script) {
|
||||
return `run ${bin}`;
|
||||
}
|
||||
|
||||
if (bin === "node") {
|
||||
const mode = words.includes("--check") || words.includes("-c")
|
||||
? "check js syntax for"
|
||||
: "run node script";
|
||||
return `${mode} ${script}`;
|
||||
}
|
||||
|
||||
return `run ${bin} ${script}`;
|
||||
}
|
||||
|
||||
if (bin === "openclaw") {
|
||||
const sub = firstPositional(words, 1);
|
||||
return sub ? `run openclaw ${sub}` : "run openclaw";
|
||||
}
|
||||
|
||||
const arg = firstPositional(words, 1);
|
||||
if (!arg || arg.length > 48) {
|
||||
return `run ${bin}`;
|
||||
}
|
||||
return /^[A-Za-z0-9._\/-]+$/.test(arg) ? `run ${bin} ${arg}` : `run ${bin}`;
|
||||
}
|
||||
|
||||
function summarizeExecCommand(command: string): string | undefined {
|
||||
const cleaned = stripShellPreamble(command);
|
||||
const stage = firstTopLevelStage(cleaned).trim();
|
||||
if (!stage) {
|
||||
return cleaned ? summarizeKnownExec(trimLeadingEnv(splitShellWords(cleaned))) : undefined;
|
||||
}
|
||||
|
||||
const pipeline = splitTopLevelPipes(stage);
|
||||
if (pipeline.length > 1) {
|
||||
const first = summarizeKnownExec(trimLeadingEnv(splitShellWords(pipeline[0])));
|
||||
const last = summarizeKnownExec(trimLeadingEnv(splitShellWords(pipeline[pipeline.length - 1])));
|
||||
const extra = pipeline.length > 2 ? ` (+${pipeline.length - 2} steps)` : "";
|
||||
return `${first} -> ${last}${extra}`;
|
||||
}
|
||||
|
||||
return summarizeKnownExec(trimLeadingEnv(splitShellWords(stage)));
|
||||
}
|
||||
|
||||
export function resolveExecDetail(args: unknown): string | undefined {
|
||||
const record = asRecord(args);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const raw = typeof record.command === "string" ? record.command.trim() : undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const unwrapped = unwrapShellWrapper(raw);
|
||||
const summary = summarizeExecCommand(unwrapped) ?? summarizeExecCommand(raw) ?? "run command";
|
||||
|
||||
const cwdRaw =
|
||||
typeof record.workdir === "string"
|
||||
? record.workdir
|
||||
: typeof record.cwd === "string"
|
||||
? record.cwd
|
||||
: undefined;
|
||||
const cwd = cwdRaw?.trim();
|
||||
|
||||
return cwd ? `${summary} (in ${cwd})` : summary;
|
||||
}
|
||||
|
||||
export function resolveActionSpec(
|
||||
|
||||
@@ -51,4 +51,80 @@ describe("tool display details", () => {
|
||||
expect(detail).toContain("limit 20");
|
||||
expect(detail).toContain("tools true");
|
||||
});
|
||||
|
||||
it("formats read/write/edit with intent-first file detail", () => {
|
||||
const readDetail = formatToolDetail(
|
||||
resolveToolDisplay({
|
||||
name: "read",
|
||||
args: { file_path: "/tmp/a.txt", offset: 2, limit: 2 },
|
||||
}),
|
||||
);
|
||||
const writeDetail = formatToolDetail(
|
||||
resolveToolDisplay({
|
||||
name: "write",
|
||||
args: { file_path: "/tmp/a.txt", content: "abc" },
|
||||
}),
|
||||
);
|
||||
const editDetail = formatToolDetail(
|
||||
resolveToolDisplay({
|
||||
name: "edit",
|
||||
args: { path: "/tmp/a.txt", newText: "abcd" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readDetail).toBe("lines 2-3 from /tmp/a.txt");
|
||||
expect(writeDetail).toBe("to /tmp/a.txt (3 chars)");
|
||||
expect(editDetail).toBe("in /tmp/a.txt (4 chars)");
|
||||
});
|
||||
|
||||
it("formats web_search query with quotes", () => {
|
||||
const detail = formatToolDetail(
|
||||
resolveToolDisplay({
|
||||
name: "web_search",
|
||||
args: { query: "OpenClaw docs", count: 3 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(detail).toBe('for "OpenClaw docs" (top 3)');
|
||||
});
|
||||
|
||||
it("summarizes exec commands with context", () => {
|
||||
const detail = formatToolDetail(
|
||||
resolveToolDisplay({
|
||||
name: "exec",
|
||||
args: {
|
||||
command:
|
||||
"set -euo pipefail\ngit -C /Users/adityasingh/.openclaw/workspace status --short | head -n 3",
|
||||
workdir: "/Users/adityasingh/.openclaw/workspace",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(detail).toContain("check git status -> show first 3 lines");
|
||||
expect(detail).toContain(".openclaw/workspace)");
|
||||
});
|
||||
|
||||
it("recognizes heredoc/inline script exec details", () => {
|
||||
const pyDetail = formatToolDetail(
|
||||
resolveToolDisplay({
|
||||
name: "exec",
|
||||
args: {
|
||||
command: "python3 <<PY\nprint('x')\nPY",
|
||||
workdir: "/Users/adityasingh/.openclaw/workspace",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const nodeCheckDetail = formatToolDetail(
|
||||
resolveToolDisplay({
|
||||
name: "exec",
|
||||
args: {
|
||||
command: "node --check /tmp/test.js",
|
||||
workdir: "/Users/adityasingh/.openclaw/workspace",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(pyDetail).toContain("run python3 inline script (heredoc)");
|
||||
expect(nodeCheckDetail).toContain("check js syntax for /tmp/test.js");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
normalizeVerb,
|
||||
resolveActionSpec,
|
||||
resolveDetailFromKeys,
|
||||
resolveExecDetail,
|
||||
resolveReadDetail,
|
||||
resolveWriteDetail,
|
||||
type ToolDisplaySpec as ToolDisplaySpecBase,
|
||||
@@ -72,14 +73,55 @@ export function resolveToolDisplay(params: {
|
||||
: undefined;
|
||||
const action = typeof actionRaw === "string" ? actionRaw.trim() : undefined;
|
||||
const actionSpec = resolveActionSpec(spec, action);
|
||||
const verb = normalizeVerb(actionSpec?.label ?? action);
|
||||
const fallbackVerb =
|
||||
key === "web_search"
|
||||
? "search"
|
||||
: key === "web_fetch"
|
||||
? "fetch"
|
||||
: key.replace(/_/g, " ").replace(/\./g, " ");
|
||||
const verb = normalizeVerb(actionSpec?.label ?? action ?? fallbackVerb);
|
||||
|
||||
let detail: string | undefined;
|
||||
if (key === "read") {
|
||||
if (key === "exec") {
|
||||
detail = resolveExecDetail(params.args);
|
||||
}
|
||||
if (!detail && key === "read") {
|
||||
detail = resolveReadDetail(params.args);
|
||||
}
|
||||
if (!detail && (key === "write" || key === "edit" || key === "attach")) {
|
||||
detail = resolveWriteDetail(params.args);
|
||||
detail = resolveWriteDetail(key, params.args);
|
||||
}
|
||||
|
||||
if (!detail && key === "web_search" && params.args && typeof params.args === "object") {
|
||||
const record = params.args as Record<string, unknown>;
|
||||
const query = typeof record.query === "string" ? record.query.trim() : undefined;
|
||||
const count =
|
||||
typeof record.count === "number" && Number.isFinite(record.count) && record.count > 0
|
||||
? Math.floor(record.count)
|
||||
: undefined;
|
||||
if (query) {
|
||||
detail = count !== undefined ? `for "${query}" (top ${count})` : `for "${query}"`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!detail && key === "web_fetch" && params.args && typeof params.args === "object") {
|
||||
const record = params.args as Record<string, unknown>;
|
||||
const url = typeof record.url === "string" ? record.url.trim() : undefined;
|
||||
const mode =
|
||||
typeof record.extractMode === "string" ? record.extractMode.trim() : undefined;
|
||||
const maxChars =
|
||||
typeof record.maxChars === "number" && Number.isFinite(record.maxChars) && record.maxChars > 0
|
||||
? Math.floor(record.maxChars)
|
||||
: undefined;
|
||||
if (url) {
|
||||
const suffix = [
|
||||
mode ? `mode ${mode}` : undefined,
|
||||
maxChars !== undefined ? `max ${maxChars} chars` : undefined,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(", ");
|
||||
detail = suffix ? `from ${url} (${suffix})` : `from ${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
const detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
|
||||
@@ -110,17 +152,19 @@ export function resolveToolDisplay(params: {
|
||||
}
|
||||
|
||||
export function formatToolDetail(display: ToolDisplay): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (display.verb) {
|
||||
parts.push(display.verb);
|
||||
}
|
||||
if (display.detail) {
|
||||
parts.push(redactToolDetail(display.detail));
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
const detailRaw = display.detail ? redactToolDetail(display.detail) : undefined;
|
||||
if (!detailRaw) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.join(" · ");
|
||||
if (detailRaw.includes(" · ")) {
|
||||
const compact = detailRaw
|
||||
.split(" · ")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.join(", ");
|
||||
return compact ? `with ${compact}` : undefined;
|
||||
}
|
||||
return detailRaw;
|
||||
}
|
||||
|
||||
export function formatToolSummary(display: ToolDisplay): string {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
normalizeVerb,
|
||||
resolveActionSpec,
|
||||
resolveDetailFromKeys,
|
||||
resolveExecDetail,
|
||||
resolveReadDetail,
|
||||
resolveWriteDetail,
|
||||
type ToolDisplaySpec as ToolDisplaySpecBase,
|
||||
@@ -65,21 +66,62 @@ export function resolveToolDisplay(params: {
|
||||
const spec = TOOL_MAP[key];
|
||||
const icon = (spec?.icon ?? FALLBACK.icon ?? "puzzle") as IconName;
|
||||
const title = spec?.title ?? defaultTitle(name);
|
||||
const label = spec?.label ?? name;
|
||||
const label = spec?.label ?? title;
|
||||
const actionRaw =
|
||||
params.args && typeof params.args === "object"
|
||||
? ((params.args as Record<string, unknown>).action as string | undefined)
|
||||
: undefined;
|
||||
const action = typeof actionRaw === "string" ? actionRaw.trim() : undefined;
|
||||
const actionSpec = resolveActionSpec(spec, action);
|
||||
const verb = normalizeVerb(actionSpec?.label ?? action);
|
||||
const fallbackVerb =
|
||||
key === "web_search"
|
||||
? "search"
|
||||
: key === "web_fetch"
|
||||
? "fetch"
|
||||
: key.replace(/_/g, " ").replace(/\./g, " ");
|
||||
const verb = normalizeVerb(actionSpec?.label ?? action ?? fallbackVerb);
|
||||
|
||||
let detail: string | undefined;
|
||||
if (key === "read") {
|
||||
if (key === "exec") {
|
||||
detail = resolveExecDetail(params.args);
|
||||
}
|
||||
if (!detail && key === "read") {
|
||||
detail = resolveReadDetail(params.args);
|
||||
}
|
||||
if (!detail && (key === "write" || key === "edit" || key === "attach")) {
|
||||
detail = resolveWriteDetail(params.args);
|
||||
detail = resolveWriteDetail(key, params.args);
|
||||
}
|
||||
|
||||
if (!detail && key === "web_search" && params.args && typeof params.args === "object") {
|
||||
const record = params.args as Record<string, unknown>;
|
||||
const query = typeof record.query === "string" ? record.query.trim() : undefined;
|
||||
const count =
|
||||
typeof record.count === "number" && Number.isFinite(record.count) && record.count > 0
|
||||
? Math.floor(record.count)
|
||||
: undefined;
|
||||
if (query) {
|
||||
detail = count !== undefined ? `for "${query}" (top ${count})` : `for "${query}"`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!detail && key === "web_fetch" && params.args && typeof params.args === "object") {
|
||||
const record = params.args as Record<string, unknown>;
|
||||
const url = typeof record.url === "string" ? record.url.trim() : undefined;
|
||||
const mode =
|
||||
typeof record.extractMode === "string" ? record.extractMode.trim() : undefined;
|
||||
const maxChars =
|
||||
typeof record.maxChars === "number" && Number.isFinite(record.maxChars) && record.maxChars > 0
|
||||
? Math.floor(record.maxChars)
|
||||
: undefined;
|
||||
if (url) {
|
||||
const suffix = [
|
||||
mode ? `mode ${mode}` : undefined,
|
||||
maxChars !== undefined ? `max ${maxChars} chars` : undefined,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(", ");
|
||||
detail = suffix ? `from ${url} (${suffix})` : `from ${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
const detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
|
||||
@@ -109,17 +151,18 @@ export function resolveToolDisplay(params: {
|
||||
}
|
||||
|
||||
export function formatToolDetail(display: ToolDisplay): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (display.verb) {
|
||||
parts.push(display.verb);
|
||||
}
|
||||
if (display.detail) {
|
||||
parts.push(display.detail);
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
if (!display.detail) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.join(" · ");
|
||||
if (display.detail.includes(" · ")) {
|
||||
const compact = display.detail
|
||||
.split(" · ")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.join(", ");
|
||||
return compact ? `with ${compact}` : undefined;
|
||||
}
|
||||
return display.detail;
|
||||
}
|
||||
|
||||
export function formatToolSummary(display: ToolDisplay): string {
|
||||
|
||||
Reference in New Issue
Block a user