338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
import { join } from "path";
|
|
import { existsSync } from "fs";
|
|
import { getSession, createSession } from "./sessions";
|
|
import { getSettings, type ModelConfig, type SecurityConfig } from "./config";
|
|
import { buildClockPromptPrefix } from "./timezone";
|
|
|
|
const LOGS_DIR = join(process.cwd(), ".qwen/qwenclaw/logs");
|
|
// Resolve prompts relative to the qwenclaw installation, not the project dir
|
|
const PROMPTS_DIR = join(import.meta.dir, "..", "prompts");
|
|
const HEARTBEAT_PROMPT_FILE = join(PROMPTS_DIR, "heartbeat", "HEARTBEAT.md");
|
|
const PROJECT_QWEN_MD = join(process.cwd(), "QWEN.md");
|
|
const LEGACY_PROJECT_QWEN_MD = join(process.cwd(), ".qwen", "QWEN.md");
|
|
const QWENCLAW_BLOCK_START = "<!-- QWENCLAW_MANAGED_BLOCK_START -->";
|
|
const QWENCLAW_BLOCK_END = "<!-- QWENCLAW_MANAGED_BLOCK_END -->";
|
|
|
|
export interface RunResult {
|
|
stdout: string;
|
|
stderr: string;
|
|
exitCode: number;
|
|
}
|
|
|
|
const RATE_LIMIT_PATTERN = /you(?:'|')ve hit your limit/i;
|
|
|
|
// Serial queue — prevents concurrent --resume on the same session
|
|
let queue: Promise<unknown> = Promise.resolve();
|
|
function enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
|
const task = queue.then(fn, fn);
|
|
queue = task.catch((): Promise<unknown> => Promise.resolve());
|
|
return task;
|
|
}
|
|
|
|
function extractRateLimitMessage(stdout: string, stderr: string): string | null {
|
|
const candidates = [stdout, stderr];
|
|
for (const text of candidates) {
|
|
const trimmed = text.trim();
|
|
if (trimmed && RATE_LIMIT_PATTERN.test(trimmed)) return trimmed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function sameModelConfig(a: ModelConfig, b: ModelConfig): boolean {
|
|
return a.model.trim().toLowerCase() === b.model.trim().toLowerCase() && a.api.trim() === b.api.trim();
|
|
}
|
|
|
|
function hasModelConfig(value: ModelConfig): boolean {
|
|
return value.model.trim().length > 0 || value.api.trim().length > 0;
|
|
}
|
|
|
|
function buildChildEnv(
|
|
baseEnv: Record<string, string>,
|
|
model: string,
|
|
api: string
|
|
): Record<string, string> {
|
|
const childEnv: Record<string, string> = { ...baseEnv };
|
|
if (api.trim()) childEnv.ANTHROPIC_AUTH_TOKEN = api.trim();
|
|
// Add any Qwen-specific environment variables here
|
|
return childEnv;
|
|
}
|
|
|
|
async function runQwenOnce(
|
|
baseArgs: string[],
|
|
model: string,
|
|
api: string,
|
|
baseEnv: Record<string, string>
|
|
): Promise<{ rawStdout: string; stderr: string; exitCode: number }> {
|
|
const args = [...baseArgs];
|
|
if (model.trim()) args.push("--model", model.trim());
|
|
|
|
// Cross-platform command detection
|
|
const qwenCommand = process.platform === "win32"
|
|
? "qwen.cmd"
|
|
: process.platform === "darwin"
|
|
? "qwen"
|
|
: "qwen";
|
|
|
|
const proc = Bun.spawn([qwenCommand, ...args], {
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: buildChildEnv(baseEnv, model, api),
|
|
shell: true,
|
|
// Detach on non-Windows to allow background execution
|
|
detached: process.platform !== "win32",
|
|
});
|
|
|
|
const [rawStdout, stderr] = await Promise.all([
|
|
new Response(proc.stdout).text(),
|
|
new Response(proc.stderr).text(),
|
|
]);
|
|
|
|
await proc.exited;
|
|
return { rawStdout, stderr, exitCode: proc.exitCode ?? 1 };
|
|
}
|
|
|
|
const PROJECT_DIR = process.cwd();
|
|
const DIR_SCOPE_PROMPT = [
|
|
`CRITICAL SECURITY CONSTRAINT: You are scoped to the project directory: ${PROJECT_DIR}`,
|
|
"You MUST NOT read, write, edit, or delete any file outside this directory.",
|
|
"You MUST NOT run bash commands that modify anything outside this directory (no cd /, no /etc, no ~/, no ../.. escapes).",
|
|
"If a request requires accessing files outside the project, refuse and explain why.",
|
|
].join("\n");
|
|
|
|
export async function ensureProjectQwenMd(): Promise<void> {
|
|
// Preflight-only initialization: never rewrite an existing project QWEN.md.
|
|
if (existsSync(PROJECT_QWEN_MD)) return;
|
|
|
|
const promptContent = (await loadPrompts()).trim();
|
|
const managedBlock = [
|
|
QWENCLAW_BLOCK_START,
|
|
promptContent,
|
|
QWENCLAW_BLOCK_END,
|
|
].join("\n");
|
|
|
|
let content = "";
|
|
if (existsSync(LEGACY_PROJECT_QWEN_MD)) {
|
|
try {
|
|
const legacy = await readFile(LEGACY_PROJECT_QWEN_MD, "utf8");
|
|
content = legacy.trim();
|
|
} catch (e) {
|
|
console.error(`[${new Date().toLocaleTimeString()}] Failed to read legacy .qwen/QWEN.md:`, e);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const normalized = content.trim();
|
|
const hasManagedBlock =
|
|
normalized.includes(QWENCLAW_BLOCK_START) &&
|
|
normalized.includes(QWENCLAW_BLOCK_END);
|
|
|
|
const managedPattern = new RegExp(
|
|
`${QWENCLAW_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${QWENCLAW_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
|
|
"m"
|
|
);
|
|
|
|
const merged = hasManagedBlock
|
|
? `${normalized.replace(managedPattern, managedBlock)}\n`
|
|
: normalized
|
|
? `${normalized}\n\n${managedBlock}\n`
|
|
: `${managedBlock}\n`;
|
|
|
|
try {
|
|
await writeFile(PROJECT_QWEN_MD, merged, "utf8");
|
|
} catch (e) {
|
|
console.error(`[${new Date().toLocaleTimeString()}] Failed to write project QWEN.md:`, e);
|
|
}
|
|
}
|
|
|
|
function buildSecurityArgs(security: SecurityConfig): string[] {
|
|
const args: string[] = [];
|
|
|
|
// Qwen-specific security flags - adjust based on Qwen's CLI options
|
|
if (security.allowedTools.length > 0) {
|
|
args.push("--allowed-tools", security.allowedTools.join(","));
|
|
}
|
|
|
|
if (security.disallowedTools.length > 0) {
|
|
args.push("--disallowed-tools", security.disallowedTools.join(","));
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
/** Load and concatenate all prompt files from the prompts/ directory. */
|
|
async function loadPrompts(): Promise<string> {
|
|
const selectedPromptFiles = [
|
|
join(PROMPTS_DIR, "IDENTITY.md"),
|
|
join(PROMPTS_DIR, "USER.md"),
|
|
join(PROMPTS_DIR, "SOUL.md"),
|
|
];
|
|
|
|
const parts: string[] = [];
|
|
for (const file of selectedPromptFiles) {
|
|
try {
|
|
const content = await Bun.file(file).text();
|
|
if (content.trim()) parts.push(content.trim());
|
|
} catch (e) {
|
|
console.error(`[${new Date().toLocaleTimeString()}] Failed to read prompt file ${file}:`, e);
|
|
}
|
|
}
|
|
|
|
return parts.join("\n\n");
|
|
}
|
|
|
|
export async function loadHeartbeatPromptTemplate(): Promise<string> {
|
|
try {
|
|
const content = await Bun.file(HEARTBEAT_PROMPT_FILE).text();
|
|
return content.trim();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
async function execQwen(name: string, prompt: string): Promise<RunResult> {
|
|
await mkdir(LOGS_DIR, { recursive: true });
|
|
|
|
const existing = await getSession();
|
|
const isNew = !existing;
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
const logFile = join(LOGS_DIR, `${name}-${timestamp}.log`);
|
|
|
|
const { security, model, api, fallback } = getSettings();
|
|
const primaryConfig: ModelConfig = { model, api };
|
|
const fallbackConfig: ModelConfig = {
|
|
model: fallback?.model ?? "",
|
|
api: fallback?.api ?? "",
|
|
};
|
|
|
|
const securityArgs = buildSecurityArgs(security);
|
|
|
|
console.log(
|
|
`[${new Date().toLocaleTimeString()}] Running: ${name} (${isNew ? "new session" : `resume ${existing.sessionId.slice(0, 8)}`}, security: ${security.level})`
|
|
);
|
|
|
|
// Build the base command for Qwen
|
|
const args = [
|
|
"qwen",
|
|
"-p",
|
|
prompt,
|
|
...securityArgs,
|
|
];
|
|
|
|
if (!isNew) {
|
|
args.push("--resume", existing.sessionId);
|
|
}
|
|
|
|
// Build the appended system prompt: prompt files + directory scoping
|
|
const promptContent = await loadPrompts();
|
|
const appendParts: string[] = ["You are running inside QwenClaw."];
|
|
if (promptContent) appendParts.push(promptContent);
|
|
|
|
// Load the project's QWEN.md if it exists
|
|
if (existsSync(PROJECT_QWEN_MD)) {
|
|
try {
|
|
const qwenMd = await Bun.file(PROJECT_QWEN_MD).text();
|
|
if (qwenMd.trim()) appendParts.push(qwenMd.trim());
|
|
} catch (e) {
|
|
console.error(`[${new Date().toLocaleTimeString()}] Failed to read project QWEN.md:`, e);
|
|
}
|
|
}
|
|
|
|
if (security.level !== "unrestricted") appendParts.push(DIR_SCOPE_PROMPT);
|
|
|
|
if (appendParts.length > 0) {
|
|
args.push("--system-prompt", appendParts.join("\n\n"));
|
|
}
|
|
|
|
// Strip any nested env vars
|
|
const { QWEN_CODE: _, ...cleanEnv } = process.env;
|
|
const baseEnv = { ...cleanEnv } as Record<string, string>;
|
|
|
|
let exec = await runQwenOnce(args, primaryConfig.model, primaryConfig.api, baseEnv);
|
|
const primaryRateLimit = extractRateLimitMessage(exec.rawStdout, exec.stderr);
|
|
|
|
let usedFallback = false;
|
|
if (
|
|
primaryRateLimit &&
|
|
hasModelConfig(fallbackConfig) &&
|
|
!sameModelConfig(primaryConfig, fallbackConfig)
|
|
) {
|
|
console.warn(
|
|
`[${new Date().toLocaleTimeString()}] Qwen limit reached; retrying with fallback${fallbackConfig.model ? ` (${fallbackConfig.model})` : ""}...`
|
|
);
|
|
exec = await runQwenOnce(args, fallbackConfig.model, fallbackConfig.api, baseEnv);
|
|
usedFallback = true;
|
|
}
|
|
|
|
const rawStdout = exec.rawStdout;
|
|
const stderr = exec.stderr;
|
|
const exitCode = exec.exitCode;
|
|
let stdout = rawStdout;
|
|
let sessionId = existing?.sessionId ?? "unknown";
|
|
|
|
const rateLimitMessage = extractRateLimitMessage(rawStdout, stderr);
|
|
if (rateLimitMessage) {
|
|
stdout = rateLimitMessage;
|
|
}
|
|
|
|
// For new sessions, try to capture session ID if Qwen provides one
|
|
if (!rateLimitMessage && isNew && exitCode === 0) {
|
|
// Try to parse session ID from output or use a generated one
|
|
sessionId = `qwenclaw-${Date.now()}`;
|
|
await createSession(sessionId);
|
|
console.log(`[${new Date().toLocaleTimeString()}] Session created: ${sessionId}`);
|
|
}
|
|
|
|
const result: RunResult = { stdout, stderr, exitCode };
|
|
|
|
const output = [
|
|
`# ${name}`,
|
|
`Date: ${new Date().toISOString()}`,
|
|
`Session: ${sessionId} (${isNew ? "new" : "resumed"})`,
|
|
`Model config: ${usedFallback ? "fallback" : "primary"}`,
|
|
`Prompt: ${prompt}`,
|
|
`Exit code: ${result.exitCode}`,
|
|
"",
|
|
"## Output",
|
|
stdout,
|
|
...(stderr ? ["## Stderr", stderr] : []),
|
|
].join("\n");
|
|
|
|
await Bun.write(logFile, output);
|
|
console.log(`[${new Date().toLocaleTimeString()}] Done: ${name} → ${logFile}`);
|
|
|
|
return result;
|
|
}
|
|
|
|
export async function run(name: string, prompt: string): Promise<RunResult> {
|
|
return enqueue(() => execQwen(name, prompt));
|
|
}
|
|
|
|
function prefixUserMessageWithClock(prompt: string): string {
|
|
try {
|
|
const settings = getSettings();
|
|
const prefix = buildClockPromptPrefix(new Date(), settings.timezoneOffsetMinutes);
|
|
return `${prefix}\n${prompt}`;
|
|
} catch {
|
|
const prefix = buildClockPromptPrefix(new Date(), 0);
|
|
return `${prefix}\n${prompt}`;
|
|
}
|
|
}
|
|
|
|
export async function runUserMessage(name: string, prompt: string): Promise<RunResult> {
|
|
return run(name, prefixUserMessageWithClock(prompt));
|
|
}
|
|
|
|
/**
|
|
* Bootstrap the session: fires Qwen with the system prompt so the
|
|
* session is created immediately. No-op if a session already exists.
|
|
*/
|
|
export async function bootstrap(): Promise<void> {
|
|
const existing = await getSession();
|
|
if (existing) return;
|
|
|
|
console.log(`[${new Date().toLocaleTimeString()}] Bootstrapping new session...`);
|
|
await execQwen("bootstrap", "Wakeup, my friend!");
|
|
console.log(`[${new Date().toLocaleTimeString()}] Bootstrap complete — session is live.`);
|
|
}
|