Files
memind/tool-gateway.mjs
T
john f95d766f69
Memind CI / Test, build, and release guards (push) Failing after 14m36s
feat(billing): bill Cursor executor usage via stream-json token parsing
Parse Cursor CLI usage from stream-json output and charge through billSessionUsage so subscription quota is consumed first and wallet overage applies when needed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 10:16:38 +08:00

354 lines
12 KiB
JavaScript

import { spawn as nodeSpawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import path from 'node:path';
import { buildCursorExecutorLaunchPlan } from './cursor-agent-launch.mjs';
import {
extractCursorAgentDisplayText,
parseCursorAgentUsage,
} from './cursor-agent-usage.mjs';
import { resolveExecutorDisplayLabel } from './executor-display-label.mjs';
const BASE_CODE_EXECUTORS = ['aider', 'openhands'];
function cursorExecutorEnabled(env = process.env) {
const raw = String(env.MEMIND_CURSOR_EXECUTOR_ENABLED ?? '').trim().toLowerCase();
if (!raw) return false;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
function codeExecutorsForEnv(env = process.env) {
return cursorExecutorEnabled(env)
? ['cursor', ...BASE_CODE_EXECUTORS]
: [...BASE_CODE_EXECUTORS];
}
const DEFAULT_STDIO_LIMIT = 64 * 1024;
function envFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
function positiveInteger(value, fallback) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.floor(n);
}
function normalizeExecutor(value, fallback = 'aider', env = process.env) {
const normalized = String(value ?? fallback).trim().toLowerCase();
const allowed = new Set(codeExecutorsForEnv(env));
if (allowed.has(normalized)) return normalized;
return fallback;
}
function csvSet(value) {
return new Set(
String(value ?? '')
.split(',')
.map((item) => item.trim().toLowerCase())
.filter(Boolean),
);
}
function appendLimited(current, chunk, limit) {
const next = `${current}${Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk ?? '')}`;
if (next.length <= limit) return next;
return next.slice(next.length - limit);
}
export function extractToolInstruction(userMessage) {
const content = userMessage?.content;
if (typeof content === 'string') return content.trim();
if (!Array.isArray(content)) {
return String(userMessage?.text ?? userMessage?.value ?? '').trim();
}
return content
.map((item) => {
if (typeof item === 'string') return item;
if (item?.type === 'text') return item.text ?? '';
return '';
})
.map((item) => String(item ?? '').trim())
.filter(Boolean)
.join('\n')
.trim();
}
function runValidation(userMessage) {
const metadata = userMessage?.metadata ?? {};
const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {};
const validation = runMetadata.validation ?? metadata.toolGatewayValidation;
return validation && typeof validation === 'object' && !Array.isArray(validation)
? validation
: null;
}
function validationFilePaths(userMessage) {
const validation = runValidation(userMessage);
if (!validation) return [];
const candidates = [
validation.expectedFile ?? validation.expectedPath,
...(Array.isArray(validation.expectedFiles) ? validation.expectedFiles : []),
];
return candidates
.map((item) => {
if (typeof item === 'string') return item.trim();
if (!item || typeof item !== 'object' || Array.isArray(item)) return '';
return String(item.path ?? item.file ?? item.relativePath ?? '').trim();
})
.filter(Boolean);
}
export function resolveAiderReceiptPath(userMessage, requestId) {
const expected = `.memind/agent-runs/${String(requestId ?? '').trim()}.json`;
return validationFilePaths(userMessage).find((item) => item === expected) ?? null;
}
export async function prepareAiderReceiptFile(cwd, relativePath) {
if (!cwd || !relativePath) return null;
const root = path.resolve(String(cwd));
const target = path.resolve(root, relativePath);
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
throw new Error(`Aider receipt path escapes working directory: ${relativePath}`);
}
await fs.mkdir(path.dirname(target), { recursive: true });
try {
await fs.access(target);
} catch {
await fs.writeFile(
target,
`${JSON.stringify({ status: 'pending', executor: 'aider' }, null, 2)}\n`,
{ flag: 'wx' },
);
}
return target;
}
async function resolveAiderContextFiles(userMessage, cwd) {
const metadata = userMessage?.metadata ?? {};
const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {};
const candidates = Array.isArray(runMetadata.aiderContextFiles)
? runMetadata.aiderContextFiles
: [];
if (!cwd || candidates.length === 0) return [];
const root = path.resolve(String(cwd));
const resolved = [];
for (const relativePath of candidates.slice(0, 40)) {
const target = path.resolve(root, String(relativePath ?? ''));
if (target === root || !target.startsWith(`${root}${path.sep}`)) continue;
try {
const stat = await fs.stat(target);
if (stat.isFile()) resolved.push(target);
} catch {
// Review context is best-effort; delivery validation remains authoritative.
}
}
return resolved;
}
export function hardenAiderLaunchPlan(plan, receiptPath = null, contextFiles = []) {
const args = [...(plan?.args ?? [])];
for (const flag of ['--no-git', '--no-auto-commits', '--no-dirty-commits']) {
if (!args.includes(flag)) args.push(flag);
}
for (const contextFile of contextFiles) {
if (!args.includes(contextFile)) args.push('--file', contextFile);
}
if (receiptPath && !args.includes(receiptPath)) {
args.push('--file', receiptPath);
}
return { ...plan, args };
}
export function createToolGateway({
llmProviderService,
env = process.env,
spawnImpl = nodeSpawn,
} = {}) {
const enabled = envFlag(env.MEMIND_TOOL_GATEWAY_ENABLED, false);
const dryRun = envFlag(env.MEMIND_TOOL_GATEWAY_DRY_RUN, false);
const cursorEnabled = cursorExecutorEnabled(env);
const defaultExecutor = normalizeExecutor(env.MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR, 'aider', env);
const cursorTaskTypes = csvSet(
env.MEMIND_TOOL_GATEWAY_CURSOR_TASK_TYPES
?? 'h5_chat_code_task,mindspace_page,mindspace_html_page',
);
const openhandsTaskTypes = csvSet(
env.MEMIND_TOOL_GATEWAY_OPENHANDS_TASK_TYPES
?? 'repo_refactor,multi_file,complex_repo,page_data_dev_complex',
);
const stdioLimit = positiveInteger(env.MEMIND_TOOL_GATEWAY_STDIO_LIMIT, DEFAULT_STDIO_LIMIT);
function getStatus() {
return {
enabled,
dryRun,
protocol: 'agent-run-v1',
executors: codeExecutorsForEnv(env),
defaultExecutor,
cursorEnabled,
cursorTaskTypes: [...cursorTaskTypes],
openhandsTaskTypes: [...openhandsTaskTypes],
};
}
function selectExecutor({ userMessage, taskType } = {}) {
const metadata = userMessage?.metadata ?? {};
const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {};
const requested = normalizeExecutor(
runMetadata.executor || runMetadata.requiredExecutor,
'',
env,
);
if (requested) return requested;
const normalizedTaskType = String(taskType ?? runMetadata.taskType ?? '').trim().toLowerCase();
if (cursorEnabled && cursorTaskTypes.has(normalizedTaskType)) return 'cursor';
if (openhandsTaskTypes.has(normalizedTaskType)) return 'openhands';
return defaultExecutor;
}
async function executeJob({
runId,
requestId,
userId,
userMessage,
taskType,
cwd,
timeoutMs = 15 * 60 * 1000,
} = {}) {
if (!enabled) {
throw new Error('Tool Gateway is disabled');
}
const instruction = extractToolInstruction(userMessage);
if (!instruction) {
throw new Error('Tool Gateway job missing instruction');
}
const executor = selectExecutor({ userMessage, taskType });
const receiptPath = executor === 'aider'
? resolveAiderReceiptPath(userMessage, requestId)
: null;
const executorInstruction = receiptPath
? `${instruction}\n\nThe validation receipt is already included in the Aider chat. Edit it directly; do not ask the user to add it.`
: instruction;
let plan;
if (executor === 'cursor') {
plan = buildCursorExecutorLaunchPlan({
cwd,
instruction: executorInstruction,
env,
});
} else {
if (!llmProviderService?.getExecutorLaunchPlan) {
throw new Error('Tool Gateway missing llm provider service');
}
plan = await llmProviderService.getExecutorLaunchPlan(executor, {
cwd,
mode: 'headless',
instruction: executorInstruction,
purpose: 'default',
includeSecret: true,
});
}
if (!plan?.ok) {
throw new Error(plan?.message ?? `Tool Gateway launch plan unavailable for ${executor}`);
}
if (executor === 'aider') {
const preparedReceipt = dryRun
? (receiptPath ? path.resolve(String(cwd), receiptPath) : null)
: await prepareAiderReceiptFile(cwd, receiptPath);
const contextFiles = await resolveAiderContextFiles(userMessage, cwd);
plan = hardenAiderLaunchPlan(plan, preparedReceipt, contextFiles);
}
if (dryRun) {
return {
ok: true,
dryRun: true,
executor,
executorLabel: resolveExecutorDisplayLabel(executor, plan.executorLabel),
protocol: 'agent-run-v1',
cwd: plan.cwd,
command: plan.command,
args: plan.args ?? [],
runId,
requestId,
userId,
};
}
return await new Promise((resolve, reject) => {
let stdout = '';
let stderr = '';
const child = spawnImpl(plan.command, plan.args ?? [], {
cwd: plan.cwd,
env: { ...process.env, ...(plan.env ?? {}) },
stdio: ['ignore', 'pipe', 'pipe'],
});
const cleanup = () => {
if (timer) clearTimeout(timer);
};
const timer = setTimeout(() => {
child.kill('SIGTERM');
const err = new Error(`Tool Gateway job timed out after ${timeoutMs}ms`);
err.code = 'TOOL_GATEWAY_TIMEOUT';
reject(err);
}, timeoutMs);
if (child.stdout instanceof EventEmitter) {
child.stdout.on('data', (chunk) => {
stdout = appendLimited(stdout, chunk, stdioLimit);
});
}
if (child.stderr instanceof EventEmitter) {
child.stderr.on('data', (chunk) => {
stderr = appendLimited(stderr, chunk, stdioLimit);
});
}
child.on('error', (err) => {
cleanup();
reject(err);
});
child.on('exit', (code, signal) => {
cleanup();
if (code === 0) {
const usage = executor === 'cursor' ? parseCursorAgentUsage(stdout) : null;
const displayStdout = executor === 'cursor'
? extractCursorAgentDisplayText(stdout)
: '';
resolve({
ok: true,
executor,
executorLabel: resolveExecutorDisplayLabel(executor, plan.executorLabel),
protocol: 'agent-run-v1',
cwd: plan.cwd,
command: plan.command,
args: plan.args ?? [],
exitCode: code,
signal: signal ?? null,
stdout,
displayStdout: displayStdout || undefined,
usage: usage ?? undefined,
stderr,
});
return;
}
const err = new Error(`Tool Gateway executor ${executor} exited with ${signal ?? code}`);
err.code = 'TOOL_GATEWAY_EXECUTOR_FAILED';
err.executor = executor;
err.exitCode = code;
err.signal = signal ?? null;
err.stdout = stdout;
err.stderr = stderr;
reject(err);
});
});
}
return {
getStatus,
selectExecutor,
executeJob,
};
}