Files
2026-11-12 15:29:12 +08:00

177 lines
8.3 KiB
TypeScript

import { spawn } from 'node:child_process';
import { link, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ToolSpec } from '../shared/types.ts';
import { aiwork, childEnvironment, within } from './evidence.ts';
const evidenceScript = fileURLToPath(new URL('./evidence.ts', import.meta.url));
const allowedFile = /\.(?:md|txt|html|ts|tsx|js|mjs|css|py|rs|go|java|kt|swift|vue)$/i;
const excludedFile = /(?:^|[/_.-])(?:secret[s]?|token[s]?|credential[s]?|private[_-]?key|id_rsa|id_ed25519)(?:$|[/_.-])/i;
const bufferLimit = 160_000;
export type Args = Record<string, unknown>;
export type ProcessResult = { exit_code: number | null; stdout: string; stderr: string };
export type Evidence = {
window: { from: string; through: string };
sources: { source: string; status: string; sessions: number }[];
repositories: {
path: string; worktrees: string[];
commits: { sha: string; subject: string }[];
artifacts: { cwd: string; path: string }[];
}[];
};
export function spec(name: string, description: string, properties: Record<string, unknown>, required: string[]): ToolSpec {
return { type: 'function', function: { name, description, parameters: {
type: 'object', properties, required, additionalProperties: false,
} } };
}
const string = { type: 'string' };
export const readSpec = spec('read', '读取证据仓库或本次运行目录中的文本行。', {
path: string, start_line: { type: 'integer' }, end_line: { type: 'integer' },
}, ['path']);
export function baseTools(background = false) {
return [
spec('grep', '在已定位的证据仓库中搜索固定字符串,返回路径和行号。', { pattern: string, path: string }, ['pattern', 'path']),
readSpec,
spec('write', '在本次运行目录生成 weekly-report.md。', { path: string, content: string }, ['path', 'content']),
spec('bash', '执行只读取证命令 weekly-evidence、git log 或 git show <sha>。git 命令通过 cwd 指定仓库绝对路径。', {
command: string,
cwd: string,
...(background ? { run_in_background: { type: 'boolean', description: '在后台执行 weekly-evidence。' } } : {}),
}, ['command']),
];
}
export function runCommand(file: string, args: string[], cwd: string, signal: AbortSignal): Promise<ProcessResult> {
return new Promise((resolvePromise, reject) => {
// Shell receives argv verbatim. Evidence text and model arguments cannot expand as Bash source.
const child = spawn('/bin/bash', ['-c', 'exec "$@"', 'bash', file, ...args], {
cwd, env: childEnvironment(), signal, stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
const timer = setTimeout(() => child.kill('SIGTERM'), 20_000);
child.stdout.setEncoding('utf8').on('data', (chunk: string) => {
if (stdout.length < bufferLimit) stdout += chunk;
});
child.stderr.setEncoding('utf8').on('data', (chunk: string) => {
if (stderr.length < bufferLimit) stderr += chunk;
});
child.once('error', reject);
child.once('close', (exit_code, reason) => {
clearTimeout(timer);
resolvePromise({
exit_code,
stdout: redact(stdout.slice(0, bufferLimit)).trimEnd(),
stderr: reason ? `进程结束:${reason}` : redact(stderr.slice(0, bufferLimit)).trimEnd(),
});
});
});
}
function redact(text: string) {
const key = process.env.EASTMONEY_API_KEY;
return key ? text.replaceAll(key, '[已隐藏]') : text;
}
export class Tools {
readonly workspace: string;
readonly signal: AbortSignal;
private roots = new Set<string>();
evidence?: Evidence;
constructor(workspace: string, signal: AbortSignal) {
this.workspace = workspace;
this.signal = signal;
}
async bash(args: Args) {
const command = requireString(args, 'command').trim();
if (command === 'weekly-evidence') {
const result = await runCommand(process.execPath, [evidenceScript], this.workspace, this.signal);
if (result.exit_code === 0) {
const evidence = JSON.parse(result.stdout) as Evidence;
this.evidence = evidence;
for (const repo of evidence.repositories) {
for (const root of repo.worktrees) this.roots.add(root);
}
}
return result;
}
const show = /^git show ([0-9a-f]{7,64})$/.exec(command);
if (command !== 'git log' && !show) throw new Error('支持 weekly-evidence、git log、git show <sha>。');
const cwd = await realpath(typeof args.cwd === 'string' ? args.cwd : aiwork);
const configuredAiwork = await realpath(aiwork).catch(() => aiwork);
if (cwd !== configuredAiwork && !this.roots.has(cwd)) throw new Error('请使用 AiWork 或取证发现的仓库绝对路径。');
if (show) {
return runCommand('git', ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-C', cwd,
'show', '--no-ext-diff', '--no-textconv', '--format=fuller', '--stat', show[1], '--'], this.workspace, this.signal);
}
return runCommand(process.execPath, [evidenceScript, cwd], this.workspace, this.signal);
}
async read(args: Args) {
const path = await this.evidencePath(requireString(args, 'path'), true);
const info = await stat(path);
if (!info.isFile() || info.size > 350_000) throw new Error('请选择 350 KB 以内的文本文件。');
const start = Math.max(1, Math.floor(Number(args.start_line) || 1));
const end = Math.max(start, Math.min(Math.floor(Number(args.end_line) || start + 23), start + 49));
const lines = redact(await readFile(path, 'utf8')).split(/\r?\n/);
return lines.slice(start - 1, end).map((line, offset) => `${start + offset} ${line.slice(0, 1000)}`).join('\n');
}
async grep(args: Args) {
const path = await this.evidencePath(requireString(args, 'path'), false);
const pattern = requireString(args, 'pattern');
if (!pattern.trim() || pattern.length > 240 || pattern.includes('\n')) throw new Error('请输入一行搜索词。');
const result = await runCommand('rg', ['--no-config', '--color=never', '-n', '-F', '--max-count', '8',
'--max-columns', '360', '--max-filesize', '350K',
'-g', '*.{md,txt,html,ts,tsx,js,mjs,css,py,rs,go,java,kt,swift,vue}',
'-g', '!**/{node_modules,dist,vendor}/**', '-g', '!**/*{secret,token,credential,private-key}*',
'--', pattern, path], this.workspace, this.signal);
if (result.exit_code === 1) return '没有匹配。';
if (result.exit_code !== 0) throw new Error(result.stderr || 'grep 执行失败。');
return result.stdout.split('\n').slice(0, 35).join('\n');
}
async write(args: Args, expected?: string) {
const path = requireString(args, 'path');
const content = requireString(args, 'content');
if (path !== 'weekly-report.md') throw new Error('请写入 weekly-report.md。');
if (expected === undefined || content.trim() !== expected.trim()) throw new Error('请逐字写入已确认的周报正文。');
const target = join(this.workspace, path);
const draft = join(this.workspace, '.weekly-report.tmp');
try {
await writeFile(draft, expected, { flag: 'wx', mode: 0o600, signal: this.signal });
this.signal.throwIfAborted();
await link(draft, target);
} finally {
await rm(draft, { force: true });
}
return { path: target, markdown: expected };
}
private async evidencePath(value: string, file: boolean) {
const given = isAbsolute(value) ? value : resolve(this.workspace, value);
const path = await realpath(given);
const root = [...this.roots, this.workspace].find((candidate) => within(candidate, path) && within(candidate, given));
if (!root) throw new Error('请使用本次运行目录或已发现的证据仓库路径。');
for (const candidate of [given, path]) {
const rel = relative(root, candidate);
if (rel.split('/').some((part) => part.startsWith('.') || ['node_modules', 'vendor', 'dist'].includes(part)) ||
excludedFile.test(rel)) throw new Error('该路径不在取证范围内。');
}
if ((file || (await stat(path)).isFile()) && !allowedFile.test(path)) throw new Error('请指定可读文本文件。');
return path;
}
}
export function requireString(args: Args, key: string) {
if (typeof args[key] !== 'string') throw new Error(`${key} 必须是字符串。`);
return args[key];
}