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; 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, 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 。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 { 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(); 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 。'); 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]; }