oh-pi 0.1.29 → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/utils/install.js +8 -9
- package/dist/utils/resources.d.ts +8 -0
- package/dist/utils/resources.js +15 -0
- package/package.json +1 -1
- package/pi-package/extensions/ant-colony/index.ts +112 -67
- package/pi-package/extensions/ant-colony/nest.ts +11 -7
- package/pi-package/extensions/ant-colony/queen.ts +37 -66
- package/pi-package/extensions/ant-colony/spawner.ts +16 -2
- package/pi-package/extensions/ant-colony/types.ts +0 -70
package/dist/utils/install.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { writeFileSync, mkdirSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs";
|
|
2
|
-
import { join
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { join } from "node:path";
|
|
4
3
|
import { homedir } from "node:os";
|
|
5
4
|
import { execSync } from "node:child_process";
|
|
6
5
|
import { KEYBINDING_SCHEMES, MODEL_CAPABILITIES, PROVIDERS } from "../types.js";
|
|
7
|
-
|
|
6
|
+
import { resources } from "./resources.js";
|
|
8
7
|
/**
|
|
9
8
|
* 确保目录存在,若不存在则递归创建
|
|
10
9
|
* @param dir - 目标目录路径
|
|
@@ -123,7 +122,7 @@ export function applyConfig(config) {
|
|
|
123
122
|
writeFileSync(join(agentDir, "keybindings.json"), JSON.stringify(kb, null, 2));
|
|
124
123
|
}
|
|
125
124
|
// 5. AGENTS.md
|
|
126
|
-
const agentsSrc =
|
|
125
|
+
const agentsSrc = resources.agent(config.agents);
|
|
127
126
|
try {
|
|
128
127
|
let content = readFileSync(agentsSrc, "utf8");
|
|
129
128
|
if (config.locale && config.locale !== "en") {
|
|
@@ -138,8 +137,8 @@ export function applyConfig(config) {
|
|
|
138
137
|
const extDir = join(agentDir, "extensions");
|
|
139
138
|
cleanDir(extDir);
|
|
140
139
|
for (const ext of config.extensions) {
|
|
141
|
-
const dirSrc =
|
|
142
|
-
const fileSrc =
|
|
140
|
+
const dirSrc = resources.extension(ext);
|
|
141
|
+
const fileSrc = resources.extensionFile(ext);
|
|
143
142
|
if (existsSync(dirSrc) && statSync(dirSrc).isDirectory()) {
|
|
144
143
|
copyDir(dirSrc, join(extDir, ext));
|
|
145
144
|
}
|
|
@@ -154,7 +153,7 @@ export function applyConfig(config) {
|
|
|
154
153
|
const promptDir = join(agentDir, "prompts");
|
|
155
154
|
cleanDir(promptDir);
|
|
156
155
|
for (const p of config.prompts) {
|
|
157
|
-
const src =
|
|
156
|
+
const src = resources.prompt(p);
|
|
158
157
|
try {
|
|
159
158
|
copyFileSync(src, join(promptDir, `${p}.md`));
|
|
160
159
|
}
|
|
@@ -164,7 +163,7 @@ export function applyConfig(config) {
|
|
|
164
163
|
const skillDir = join(agentDir, "skills");
|
|
165
164
|
cleanDir(skillDir);
|
|
166
165
|
for (const s of config.skills) {
|
|
167
|
-
const srcDir =
|
|
166
|
+
const srcDir = resources.skill(s);
|
|
168
167
|
const destDir = join(skillDir, s);
|
|
169
168
|
ensureDir(destDir);
|
|
170
169
|
try {
|
|
@@ -175,7 +174,7 @@ export function applyConfig(config) {
|
|
|
175
174
|
// 9. Copy themes (only custom ones)
|
|
176
175
|
const themeDir = join(agentDir, "themes");
|
|
177
176
|
cleanDir(themeDir);
|
|
178
|
-
const themeSrc =
|
|
177
|
+
const themeSrc = resources.theme(config.theme);
|
|
179
178
|
try {
|
|
180
179
|
copyFileSync(themeSrc, join(themeDir, `${config.theme}.json`));
|
|
181
180
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 资源路径解析 — 解耦 install.ts 对 pi-package/ 目录的硬编码引用
|
|
3
|
+
*/
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
7
|
+
const RESOURCES = join(PKG_ROOT, "pi-package");
|
|
8
|
+
export const resources = {
|
|
9
|
+
agent: (name) => join(RESOURCES, "agents", `${name}.md`),
|
|
10
|
+
extension: (name) => join(RESOURCES, "extensions", name),
|
|
11
|
+
extensionFile: (name) => join(RESOURCES, "extensions", `${name}.ts`),
|
|
12
|
+
prompt: (name) => join(RESOURCES, "prompts", `${name}.md`),
|
|
13
|
+
skill: (name) => join(RESOURCES, "skills", name),
|
|
14
|
+
theme: (name) => join(RESOURCES, "themes", `${name}.json`),
|
|
15
|
+
};
|
package/package.json
CHANGED
|
@@ -10,11 +10,10 @@
|
|
|
10
10
|
import { readFileSync, appendFileSync, existsSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
13
|
-
import { Text, Container, Spacer } from "@mariozechner/pi-tui";
|
|
13
|
+
import { Text, Container, Spacer, Box } from "@mariozechner/pi-tui";
|
|
14
14
|
import { Type } from "@sinclair/typebox";
|
|
15
15
|
import { runColony, type QueenCallbacks } from "./queen.js";
|
|
16
|
-
import type { ColonyState, ColonyMetrics, Ant, Task
|
|
17
|
-
import { resolveModelForCaste } from "./types.js";
|
|
16
|
+
import type { ColonyState, ColonyMetrics, Ant, Task } from "./types.js";
|
|
18
17
|
|
|
19
18
|
interface ColonyDetails {
|
|
20
19
|
state: ColonyState | null;
|
|
@@ -49,20 +48,34 @@ function casteIcon(caste: string): string {
|
|
|
49
48
|
return caste === "scout" ? "🔍" : caste === "soldier" ? "🛡️" : "⚒️";
|
|
50
49
|
}
|
|
51
50
|
|
|
51
|
+
/** 渲染进度条 ▓░ */
|
|
52
|
+
function progressBar(done: number, total: number, width: number, theme: any): string {
|
|
53
|
+
if (total === 0) return "";
|
|
54
|
+
const pct = Math.min(done / total, 1);
|
|
55
|
+
const filled = Math.round(pct * width);
|
|
56
|
+
const empty = width - filled;
|
|
57
|
+
const bar = theme.fg("success", "█".repeat(filled)) + theme.fg("muted", "░".repeat(empty));
|
|
58
|
+
return `${bar} ${theme.fg("accent", `${done}/${total}`)}`;
|
|
59
|
+
}
|
|
52
60
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
/** 渲染阶段流水线 scout → work → review → done */
|
|
62
|
+
function phasePipeline(status: string, theme: any): string {
|
|
63
|
+
const phases = [
|
|
64
|
+
{ key: "scouting", icon: "🔍", label: "Scout" },
|
|
65
|
+
{ key: "working", icon: "⚒️", label: "Work" },
|
|
66
|
+
{ key: "reviewing", icon: "🛡️", label: "Review" },
|
|
67
|
+
{ key: "done", icon: "✅", label: "Done" },
|
|
68
|
+
];
|
|
69
|
+
const idx = phases.findIndex(p => p.key === status);
|
|
70
|
+
return phases.map((p, i) => {
|
|
71
|
+
const label = `${p.icon} ${p.label}`;
|
|
72
|
+
if (i < idx) return theme.fg("success", label);
|
|
73
|
+
if (i === idx) return theme.fg("accent", theme.bold(label));
|
|
74
|
+
return theme.fg("muted", label);
|
|
75
|
+
}).join(theme.fg("muted", " → "));
|
|
64
76
|
}
|
|
65
77
|
|
|
78
|
+
|
|
66
79
|
export default function antColonyExtension(pi: ExtensionAPI) {
|
|
67
80
|
|
|
68
81
|
// ═══ Auto-trigger: 注入蚁群意识,LLM 自动判断何时启动 ═══
|
|
@@ -102,8 +115,14 @@ For simple single-file tasks, work directly without the colony.`,
|
|
|
102
115
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
103
116
|
const details: ColonyDetails = { state: null, phase: "initializing", log: [] };
|
|
104
117
|
|
|
105
|
-
//
|
|
106
|
-
const
|
|
118
|
+
// 所有蚂蚁统一使用当前会话模型
|
|
119
|
+
const currentModel = ctx.model?.id;
|
|
120
|
+
if (!currentModel) {
|
|
121
|
+
return {
|
|
122
|
+
content: [{ type: "text", text: "Colony failed: no model available in current session" }],
|
|
123
|
+
isError: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
107
126
|
|
|
108
127
|
const emit = () => {
|
|
109
128
|
const summary = details.state
|
|
@@ -155,8 +174,6 @@ For simple single-file tasks, work directly without the colony.`,
|
|
|
155
174
|
goal: params.goal,
|
|
156
175
|
maxAnts: params.maxAnts,
|
|
157
176
|
maxCost: params.maxCost,
|
|
158
|
-
modelOverrides,
|
|
159
|
-
availableModels: available,
|
|
160
177
|
currentModel,
|
|
161
178
|
signal: signal ?? undefined,
|
|
162
179
|
callbacks,
|
|
@@ -212,108 +229,136 @@ For simple single-file tasks, work directly without the colony.`,
|
|
|
212
229
|
// ═══ TUI Rendering ═══
|
|
213
230
|
|
|
214
231
|
renderCall(args, theme) {
|
|
215
|
-
|
|
216
|
-
text
|
|
217
|
-
|
|
218
|
-
text +=
|
|
219
|
-
|
|
220
|
-
if (args.maxCost) text += theme.fg("warning", ` (budget: $${args.maxCost})`);
|
|
232
|
+
const goal = args.goal?.length > 70 ? args.goal.slice(0, 67) + "..." : args.goal;
|
|
233
|
+
let text = theme.fg("toolTitle", theme.bold("🐜 ant_colony"));
|
|
234
|
+
if (args.maxAnts) text += theme.fg("muted", ` ×${args.maxAnts}`);
|
|
235
|
+
if (args.maxCost) text += theme.fg("warning", ` $${args.maxCost}`);
|
|
236
|
+
text += "\n" + theme.fg("dim", ` ${goal || "..."}`);
|
|
221
237
|
return new Text(text, 0, 0);
|
|
222
238
|
},
|
|
223
239
|
|
|
224
240
|
renderResult(result, { expanded }, theme) {
|
|
225
241
|
const details = result.details as ColonyDetails | undefined;
|
|
242
|
+
|
|
243
|
+
// ─── 运行中 ───
|
|
226
244
|
if (!details?.state) {
|
|
227
|
-
// Still running or no state
|
|
228
245
|
const log = details?.log ?? [];
|
|
229
|
-
|
|
230
|
-
|
|
246
|
+
const container = new Container();
|
|
247
|
+
container.addChild(new Text(
|
|
248
|
+
theme.fg("warning", "🐜 ") + theme.fg("toolTitle", theme.bold("Colony ")) +
|
|
249
|
+
theme.fg("accent", details?.phase || "initializing..."),
|
|
250
|
+
0, 0,
|
|
251
|
+
));
|
|
252
|
+
const recent = log.slice(expanded ? -20 : -5);
|
|
231
253
|
if (recent.length > 0) {
|
|
232
|
-
|
|
254
|
+
container.addChild(new Text(recent.map(l => theme.fg("dim", ` ${l}`)).join("\n"), 0, 0));
|
|
233
255
|
}
|
|
234
|
-
if (!expanded && log.length >
|
|
235
|
-
|
|
256
|
+
if (!expanded && log.length > 5) {
|
|
257
|
+
container.addChild(new Text(theme.fg("muted", ` ⋯ ${log.length - 5} more`), 0, 0));
|
|
236
258
|
}
|
|
237
|
-
return
|
|
259
|
+
return container;
|
|
238
260
|
}
|
|
239
261
|
|
|
240
262
|
const state = details.state;
|
|
241
263
|
const m = state.metrics;
|
|
242
|
-
const icon = state.status === "done" ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
243
264
|
const elapsed = state.finishedAt ? formatDuration(state.finishedAt - state.createdAt) : "?";
|
|
265
|
+
const ok = state.status === "done";
|
|
244
266
|
|
|
267
|
+
// ─── 折叠视图 ───
|
|
245
268
|
if (!expanded) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
269
|
+
const container = new Container();
|
|
270
|
+
|
|
271
|
+
// 标题行:状态 + 统计
|
|
272
|
+
const icon = ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
273
|
+
container.addChild(new Text(
|
|
274
|
+
`${icon} ${theme.fg("toolTitle", theme.bold("ant colony "))}` +
|
|
275
|
+
theme.fg("muted", `${elapsed} │ `) +
|
|
276
|
+
theme.fg("accent", `${m.antsSpawned} ants`) +
|
|
277
|
+
theme.fg("muted", ` │ ${formatTokens(m.totalTokens)} │ ${formatCost(m.totalCost)}`),
|
|
278
|
+
0, 0,
|
|
279
|
+
));
|
|
280
|
+
|
|
281
|
+
// 进度条
|
|
282
|
+
container.addChild(new Text(` ${progressBar(m.tasksDone, m.tasksTotal, 20, theme)} ${theme.fg("muted", `(${m.tasksFailed} failed)`)}`, 0, 0));
|
|
283
|
+
|
|
284
|
+
// 任务列表(最多6条)
|
|
285
|
+
for (const t of state.tasks.slice(0, 6)) {
|
|
286
|
+
const ti = t.status === "done" ? theme.fg("success", "✓")
|
|
287
|
+
: t.status === "failed" ? theme.fg("error", "✗")
|
|
288
|
+
: theme.fg("muted", "○");
|
|
289
|
+
container.addChild(new Text(
|
|
290
|
+
` ${ti} ${theme.fg("dim", `${casteIcon(t.caste)}`)} ${t.title.slice(0, 60)}`,
|
|
291
|
+
0, 0,
|
|
292
|
+
));
|
|
255
293
|
}
|
|
256
|
-
if (state.tasks.length >
|
|
257
|
-
|
|
258
|
-
|
|
294
|
+
if (state.tasks.length > 6) {
|
|
295
|
+
container.addChild(new Text(theme.fg("muted", ` ⋯ +${state.tasks.length - 6} more (Ctrl+O)`), 0, 0));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return container;
|
|
259
299
|
}
|
|
260
300
|
|
|
261
|
-
//
|
|
301
|
+
// ─── 展开视图 ───
|
|
262
302
|
const container = new Container();
|
|
303
|
+
|
|
304
|
+
// 标题 + 阶段流水线
|
|
305
|
+
const icon = ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
263
306
|
container.addChild(new Text(
|
|
264
307
|
`${icon} ${theme.fg("toolTitle", theme.bold("ant colony "))}` +
|
|
265
308
|
theme.fg("accent", state.status) +
|
|
266
|
-
theme.fg("muted", `
|
|
309
|
+
theme.fg("muted", ` │ ${elapsed} │ ${formatCost(m.totalCost)} │ ${formatTokens(m.totalTokens)} tokens`),
|
|
267
310
|
0, 0,
|
|
268
311
|
));
|
|
269
|
-
container.addChild(new Text(
|
|
312
|
+
container.addChild(new Text(` ${phasePipeline(state.status, theme)}`, 0, 0));
|
|
313
|
+
container.addChild(new Text(theme.fg("dim", ` ${state.goal}`), 0, 0));
|
|
314
|
+
|
|
315
|
+
// 进度条
|
|
270
316
|
container.addChild(new Spacer(1));
|
|
317
|
+
container.addChild(new Text(` ${progressBar(m.tasksDone, m.tasksTotal, 30, theme)}`, 0, 0));
|
|
271
318
|
|
|
272
|
-
//
|
|
273
|
-
container.addChild(new
|
|
319
|
+
// 任务区
|
|
320
|
+
container.addChild(new Spacer(1));
|
|
321
|
+
container.addChild(new Text(theme.fg("muted", ` ─── Tasks (${m.tasksDone}/${m.tasksTotal}) ───`), 0, 0));
|
|
274
322
|
for (const t of state.tasks) {
|
|
275
323
|
const ti = t.status === "done" ? theme.fg("success", "✓")
|
|
276
324
|
: t.status === "failed" ? theme.fg("error", "✗")
|
|
277
|
-
: t.status === "active" ? theme.fg("warning", "
|
|
325
|
+
: t.status === "active" ? theme.fg("warning", "◉")
|
|
278
326
|
: theme.fg("muted", "○");
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
container.addChild(new Text(line, 0, 0));
|
|
327
|
+
const dur = (t.finishedAt && t.startedAt) ? theme.fg("dim", ` ${formatDuration(t.finishedAt - t.startedAt)}`) : "";
|
|
328
|
+
container.addChild(new Text(` ${ti} ${casteIcon(t.caste)} ${t.title}${dur}`, 0, 0));
|
|
282
329
|
if (t.status === "done" && t.result) {
|
|
283
|
-
|
|
284
|
-
container.addChild(new Text(theme.fg("dim", ` ${preview}`), 0, 0));
|
|
330
|
+
container.addChild(new Text(theme.fg("dim", ` ${t.result.split("\n")[0]?.slice(0, 100)}`), 0, 0));
|
|
285
331
|
}
|
|
286
332
|
if (t.status === "failed" && t.error) {
|
|
287
|
-
container.addChild(new Text(theme.fg("error", `
|
|
333
|
+
container.addChild(new Text(theme.fg("error", ` ${t.error.slice(0, 100)}`), 0, 0));
|
|
288
334
|
}
|
|
289
335
|
}
|
|
290
336
|
|
|
291
|
-
//
|
|
337
|
+
// 蚂蚁区
|
|
292
338
|
container.addChild(new Spacer(1));
|
|
293
|
-
container.addChild(new Text(theme.fg("muted",
|
|
339
|
+
container.addChild(new Text(theme.fg("muted", ` ─── Ants (${m.antsSpawned}) ───`), 0, 0));
|
|
294
340
|
for (const a of state.ants) {
|
|
295
|
-
const ai = a.status === "done" ? theme.fg("success", "✓") : a.status === "failed" ? theme.fg("error", "✗") : theme.fg("warning", "
|
|
341
|
+
const ai = a.status === "done" ? theme.fg("success", "✓") : a.status === "failed" ? theme.fg("error", "✗") : theme.fg("warning", "◉");
|
|
296
342
|
const dur = a.finishedAt ? formatDuration(a.finishedAt - a.startedAt) : "...";
|
|
297
343
|
container.addChild(new Text(
|
|
298
|
-
|
|
344
|
+
` ${ai} ${casteIcon(a.caste)} ${theme.fg("accent", a.id)} ${theme.fg("dim", `${dur} │ ${formatCost(a.usage.cost)} │ ${a.usage.turns}t`)}`,
|
|
299
345
|
0, 0,
|
|
300
346
|
));
|
|
301
347
|
}
|
|
302
348
|
|
|
303
|
-
//
|
|
349
|
+
// 并发 + 日志
|
|
304
350
|
container.addChild(new Spacer(1));
|
|
305
351
|
const c = state.concurrency;
|
|
306
352
|
container.addChild(new Text(
|
|
307
|
-
theme.fg("muted",
|
|
308
|
-
theme.fg("dim", `current: ${c.current}
|
|
353
|
+
theme.fg("muted", ` ─── Concurrency ───`) + "\n" +
|
|
354
|
+
theme.fg("dim", ` current: ${c.current} │ optimal: ${c.optimal} │ range: ${c.min}-${c.max}`),
|
|
309
355
|
0, 0,
|
|
310
356
|
));
|
|
311
357
|
|
|
312
|
-
// Activity log
|
|
313
358
|
container.addChild(new Spacer(1));
|
|
314
|
-
container.addChild(new Text(theme.fg("muted",
|
|
315
|
-
for (const l of details.log.slice(-
|
|
316
|
-
container.addChild(new Text(theme.fg("dim", l), 0, 0));
|
|
359
|
+
container.addChild(new Text(theme.fg("muted", ` ─── Log ───`), 0, 0));
|
|
360
|
+
for (const l of details.log.slice(-15)) {
|
|
361
|
+
container.addChild(new Text(theme.fg("dim", ` ${l}`), 0, 0));
|
|
317
362
|
}
|
|
318
363
|
|
|
319
364
|
return container;
|
|
@@ -80,7 +80,7 @@ export class Nest {
|
|
|
80
80
|
.map(f => this.readJson<Task>(path.join(this.tasksDir, f)));
|
|
81
81
|
for (const t of tasks) this.taskCache.set(t.id, t);
|
|
82
82
|
return tasks;
|
|
83
|
-
} catch { return []; }
|
|
83
|
+
} catch (e) { console.error("[nest] failed to read tasks dir:", e); return []; }
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
claimTask(taskId: string, antId: string): boolean {
|
|
@@ -217,7 +217,7 @@ export class Nest {
|
|
|
217
217
|
|
|
218
218
|
private withStateLock<T>(fn: () => T): T {
|
|
219
219
|
const MAX_WAIT = 5000;
|
|
220
|
-
const SPIN_MS =
|
|
220
|
+
const SPIN_MS = 15;
|
|
221
221
|
const start = Date.now();
|
|
222
222
|
while (true) {
|
|
223
223
|
try {
|
|
@@ -225,18 +225,22 @@ export class Nest {
|
|
|
225
225
|
break;
|
|
226
226
|
} catch {
|
|
227
227
|
if (Date.now() - start > MAX_WAIT) {
|
|
228
|
-
//
|
|
229
|
-
try {
|
|
228
|
+
// 超时:检查锁持有者是否存活
|
|
229
|
+
try {
|
|
230
|
+
const holder = parseInt(fs.readFileSync(this.lockFile, "utf-8"), 10);
|
|
231
|
+
try { process.kill(holder, 0); } catch { /* 进程已死,清除死锁 */ fs.unlinkSync(this.lockFile); continue; }
|
|
232
|
+
} catch { /* 读取失败,强制清除 */ try { fs.unlinkSync(this.lockFile); } catch {} }
|
|
230
233
|
continue;
|
|
231
234
|
}
|
|
232
|
-
|
|
233
|
-
|
|
235
|
+
// 简单 busy-wait,避免 SharedArrayBuffer 依赖
|
|
236
|
+
const until = Date.now() + SPIN_MS + Math.random() * SPIN_MS;
|
|
237
|
+
while (Date.now() < until) { /* spin */ }
|
|
234
238
|
}
|
|
235
239
|
}
|
|
236
240
|
try {
|
|
237
241
|
return fn();
|
|
238
242
|
} finally {
|
|
239
|
-
try { fs.unlinkSync(this.lockFile); } catch { /*
|
|
243
|
+
try { fs.unlinkSync(this.lockFile); } catch { /* lock already removed */ }
|
|
240
244
|
}
|
|
241
245
|
}
|
|
242
246
|
|
|
@@ -16,9 +16,9 @@ import * as fs from "node:fs";
|
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import type {
|
|
18
18
|
ColonyState, Task, Ant, AntCaste, ColonyMetrics,
|
|
19
|
-
ConcurrencyConfig, TaskPriority, ModelOverrides,
|
|
19
|
+
ConcurrencyConfig, TaskPriority, ModelOverrides,
|
|
20
20
|
} from "./types.js";
|
|
21
|
-
import { DEFAULT_ANT_CONFIGS
|
|
21
|
+
import { DEFAULT_ANT_CONFIGS } from "./types.js";
|
|
22
22
|
import { Nest } from "./nest.js";
|
|
23
23
|
import { spawnAnt, makeTaskId } from "./spawner.js";
|
|
24
24
|
import { adapt, sampleSystem, defaultConcurrency } from "./concurrency.js";
|
|
@@ -36,9 +36,7 @@ export interface QueenOptions {
|
|
|
36
36
|
goal: string;
|
|
37
37
|
maxAnts?: number;
|
|
38
38
|
maxCost?: number;
|
|
39
|
-
|
|
40
|
-
availableModels?: AvailableModel[];
|
|
41
|
-
currentModel?: string;
|
|
39
|
+
currentModel: string;
|
|
42
40
|
signal?: AbortSignal;
|
|
43
41
|
callbacks: QueenCallbacks;
|
|
44
42
|
}
|
|
@@ -139,25 +137,17 @@ interface WaveOptions {
|
|
|
139
137
|
nest: Nest;
|
|
140
138
|
cwd: string;
|
|
141
139
|
caste: AntCaste;
|
|
140
|
+
currentModel: string;
|
|
142
141
|
signal?: AbortSignal;
|
|
143
142
|
callbacks: QueenCallbacks;
|
|
144
|
-
modelOverrides?: ModelOverrides;
|
|
145
|
-
maxCost?: number;
|
|
146
|
-
availableModels?: AvailableModel[];
|
|
147
|
-
currentModel?: string;
|
|
148
143
|
}
|
|
149
144
|
|
|
150
145
|
/**
|
|
151
146
|
* 并发执行一批蚂蚁,自适应调节并发度
|
|
152
147
|
*/
|
|
153
|
-
async function runAntWave(opts: WaveOptions): Promise<"ok"
|
|
154
|
-
const { nest, cwd, caste, signal, callbacks,
|
|
155
|
-
const config = { ...DEFAULT_ANT_CONFIGS[caste] };
|
|
156
|
-
if (modelOverrides?.[caste]) {
|
|
157
|
-
config.model = modelOverrides[caste];
|
|
158
|
-
} else if (!config.model && availableModels?.length) {
|
|
159
|
-
config.model = resolveModelForCaste(caste, availableModels, currentModel) ?? "";
|
|
160
|
-
}
|
|
148
|
+
async function runAntWave(opts: WaveOptions): Promise<"ok"> {
|
|
149
|
+
const { nest, cwd, caste, signal, callbacks, currentModel } = opts;
|
|
150
|
+
const config = { ...DEFAULT_ANT_CONFIGS[caste], model: currentModel };
|
|
161
151
|
|
|
162
152
|
let backoffMs = 0; // 429 退避时间
|
|
163
153
|
let consecutiveRateLimits = 0; // 连续限流计数
|
|
@@ -217,26 +207,6 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget_exceeded">
|
|
|
217
207
|
// 调度循环:持续派蚂蚁直到没有待处理任务
|
|
218
208
|
let lastSampleTime = 0;
|
|
219
209
|
while (!signal?.aborted) {
|
|
220
|
-
// 预算检查:派蚂蚁前预估,基于已完成蚂蚁的平均 cost
|
|
221
|
-
if (maxCost != null) {
|
|
222
|
-
const ants = nest.getState().ants;
|
|
223
|
-
const currentCost = ants.reduce((s, a) => s + a.usage.cost, 0);
|
|
224
|
-
if (currentCost >= maxCost) {
|
|
225
|
-
callbacks.onPhase("working", `Budget exceeded ($${currentCost.toFixed(3)} >= $${maxCost.toFixed(2)}). Stopping.`);
|
|
226
|
-
return "budget_exceeded";
|
|
227
|
-
}
|
|
228
|
-
// 预估:如果剩余预算不够一只蚂蚁的平均花费,也停止
|
|
229
|
-
const doneAnts = ants.filter(a => a.status === "done" && a.usage.cost > 0);
|
|
230
|
-
if (doneAnts.length > 0) {
|
|
231
|
-
const avgCost = doneAnts.reduce((s, a) => s + a.usage.cost, 0) / doneAnts.length;
|
|
232
|
-
const remaining = maxCost - currentCost;
|
|
233
|
-
if (remaining < avgCost * 0.5) {
|
|
234
|
-
callbacks.onPhase("working", `Budget nearly exhausted ($${currentCost.toFixed(3)}, avg/ant: $${avgCost.toFixed(3)}). Stopping.`);
|
|
235
|
-
return "budget_exceeded";
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
210
|
const state = nest.getState();
|
|
241
211
|
const pending = state.tasks.filter(t => t.status === "pending" && t.caste === caste);
|
|
242
212
|
if (pending.length === 0) break;
|
|
@@ -327,7 +297,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
327
297
|
startTime: Date.now(), throughputHistory: [],
|
|
328
298
|
},
|
|
329
299
|
maxCost: opts.maxCost ?? null,
|
|
330
|
-
modelOverrides:
|
|
300
|
+
modelOverrides: {},
|
|
331
301
|
createdAt: Date.now(),
|
|
332
302
|
finishedAt: null,
|
|
333
303
|
};
|
|
@@ -340,8 +310,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
340
310
|
const { signal, callbacks } = opts;
|
|
341
311
|
const waveBase: Omit<WaveOptions, "caste"> = {
|
|
342
312
|
nest, cwd: opts.cwd, signal, callbacks,
|
|
343
|
-
|
|
344
|
-
availableModels: opts.availableModels, currentModel: opts.currentModel,
|
|
313
|
+
currentModel: opts.currentModel,
|
|
345
314
|
};
|
|
346
315
|
|
|
347
316
|
const cleanup = () => {
|
|
@@ -353,26 +322,32 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
353
322
|
} catch { /* ignore */ }
|
|
354
323
|
};
|
|
355
324
|
|
|
356
|
-
const budgetStop = (phase: string) => {
|
|
357
|
-
nest.updateState({ status: "budget_exceeded", finishedAt: Date.now() });
|
|
358
|
-
callbacks.onPhase("budget_exceeded" as any, phase);
|
|
359
|
-
const s = nest.getState();
|
|
360
|
-
callbacks.onComplete(s);
|
|
361
|
-
cleanup();
|
|
362
|
-
return s;
|
|
363
|
-
};
|
|
364
|
-
|
|
365
325
|
try {
|
|
366
|
-
// ═══ Phase 1:
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
326
|
+
// ═══ Phase 1: 侦察(最多重试2次) ═══
|
|
327
|
+
let scoutAttempt = 0;
|
|
328
|
+
const MAX_SCOUT_RETRIES = 2;
|
|
329
|
+
let workerTasks: Task[] = [];
|
|
330
|
+
|
|
331
|
+
while (scoutAttempt <= MAX_SCOUT_RETRIES) {
|
|
332
|
+
callbacks.onPhase("scouting", scoutAttempt === 0
|
|
333
|
+
? "Dispatching scout ants to explore codebase..."
|
|
334
|
+
: `Scout retry ${scoutAttempt}/${MAX_SCOUT_RETRIES}...`);
|
|
335
|
+
|
|
336
|
+
await runAntWave({ ...waveBase, caste: "scout" });
|
|
337
|
+
|
|
338
|
+
workerTasks = nest.getAllTasks().filter(t => t.caste === "worker" && t.status === "pending");
|
|
339
|
+
if (workerTasks.length > 0) break;
|
|
340
|
+
|
|
341
|
+
scoutAttempt++;
|
|
342
|
+
if (scoutAttempt <= MAX_SCOUT_RETRIES) {
|
|
343
|
+
// 重置失败的 scout 任务为 pending 以便重试
|
|
344
|
+
for (const t of nest.getAllTasks().filter(t => t.caste === "scout" && (t.status === "done" || t.status === "failed"))) {
|
|
345
|
+
nest.updateTaskStatus(t.id, "pending");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
374
350
|
if (workerTasks.length === 0) {
|
|
375
|
-
// 侦察蚁没产生任务,标记失败
|
|
376
351
|
nest.updateState({ status: "failed", finishedAt: Date.now() });
|
|
377
352
|
const finalState = nest.getState();
|
|
378
353
|
callbacks.onComplete(finalState);
|
|
@@ -383,8 +358,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
383
358
|
// ═══ Phase 2: 工作 ═══
|
|
384
359
|
nest.updateState({ status: "working" });
|
|
385
360
|
callbacks.onPhase("working", `${workerTasks.length} tasks discovered. Dispatching worker ants...`);
|
|
386
|
-
|
|
387
|
-
return budgetStop("Budget exceeded during working");
|
|
361
|
+
await runAntWave({ ...waveBase, caste: "worker" });
|
|
388
362
|
|
|
389
363
|
// 处理工蚁产生的子任务(可能有多轮)
|
|
390
364
|
let rounds = 0;
|
|
@@ -395,8 +369,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
395
369
|
if (remaining.length === 0) break;
|
|
396
370
|
rounds++;
|
|
397
371
|
callbacks.onPhase("working", `Round ${rounds + 1}: ${remaining.length} sub-tasks from workers...`);
|
|
398
|
-
|
|
399
|
-
return budgetStop("Budget exceeded during sub-tasks");
|
|
372
|
+
await runAntWave({ ...waveBase, caste: "worker" });
|
|
400
373
|
}
|
|
401
374
|
|
|
402
375
|
// ═══ Phase 3: 审查 ═══
|
|
@@ -406,8 +379,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
406
379
|
callbacks.onPhase("reviewing", "Dispatching soldier ants to review changes...");
|
|
407
380
|
const reviewTask = makeReviewTask(completedWorkerTasks);
|
|
408
381
|
nest.writeTask(reviewTask);
|
|
409
|
-
|
|
410
|
-
return budgetStop("Budget exceeded during review");
|
|
382
|
+
await runAntWave({ ...waveBase, caste: "soldier" });
|
|
411
383
|
|
|
412
384
|
// 兵蚁产生的修复任务
|
|
413
385
|
const fixTasks = nest.getAllTasks().filter(t =>
|
|
@@ -416,8 +388,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
416
388
|
if (fixTasks.length > 0) {
|
|
417
389
|
nest.updateState({ status: "working" });
|
|
418
390
|
callbacks.onPhase("working", `${fixTasks.length} fix tasks from review. Dispatching workers...`);
|
|
419
|
-
|
|
420
|
-
return budgetStop("Budget exceeded during fixes");
|
|
391
|
+
await runAntWave({ ...waveBase, caste: "worker" });
|
|
421
392
|
}
|
|
422
393
|
}
|
|
423
394
|
|
|
@@ -114,8 +114,11 @@ Output format (MUST follow exactly):
|
|
|
114
114
|
PASS or FAIL with summary.`,
|
|
115
115
|
};
|
|
116
116
|
|
|
117
|
-
function buildPrompt(task: Task, pheromoneContext: string, castePrompt: string): string {
|
|
117
|
+
function buildPrompt(task: Task, pheromoneContext: string, castePrompt: string, maxTurns?: number): string {
|
|
118
118
|
let prompt = castePrompt + "\n\n";
|
|
119
|
+
if (maxTurns) {
|
|
120
|
+
prompt += `## ⚠️ Turn Limit\nYou have a MAXIMUM of ${maxTurns} turns. Plan accordingly — reserve your LAST turn to output the structured result format above. Do NOT waste turns on unnecessary exploration.\n\n`;
|
|
121
|
+
}
|
|
119
122
|
if (pheromoneContext) {
|
|
120
123
|
prompt += `## Colony Pheromone Trail (intelligence from other ants)\n${pheromoneContext}\n\n`;
|
|
121
124
|
}
|
|
@@ -223,7 +226,7 @@ export async function spawnAnt(
|
|
|
223
226
|
// 构建 prompt
|
|
224
227
|
const pheromoneCtx = nest.getPheromoneContext(task.files);
|
|
225
228
|
const castePrompt = CASTE_PROMPTS[antConfig.caste];
|
|
226
|
-
const fullPrompt = buildPrompt(task, pheromoneCtx, castePrompt);
|
|
229
|
+
const fullPrompt = buildPrompt(task, pheromoneCtx, castePrompt, antConfig.maxTurns);
|
|
227
230
|
const tmpFile = writePromptFile(nest.dir, antId, fullPrompt);
|
|
228
231
|
|
|
229
232
|
const args = [
|
|
@@ -246,6 +249,7 @@ export async function spawnAnt(
|
|
|
246
249
|
nest.updateAnt(ant);
|
|
247
250
|
|
|
248
251
|
let buffer = "";
|
|
252
|
+
let turnCount = 0;
|
|
249
253
|
|
|
250
254
|
proc.stdout.on("data", (data) => {
|
|
251
255
|
buffer += data.toString();
|
|
@@ -255,6 +259,15 @@ export async function spawnAnt(
|
|
|
255
259
|
if (!line.trim()) continue;
|
|
256
260
|
try {
|
|
257
261
|
const event = JSON.parse(line);
|
|
262
|
+
if (event.type === "turn_end") {
|
|
263
|
+
turnCount++;
|
|
264
|
+
if (antConfig.maxTurns && turnCount === antConfig.maxTurns) {
|
|
265
|
+
stderr += `[ant-colony] Warning: ant reached maxTurns (${antConfig.maxTurns}), 1 grace turn remaining\n`;
|
|
266
|
+
} else if (antConfig.maxTurns && turnCount > antConfig.maxTurns) {
|
|
267
|
+
proc.kill("SIGTERM");
|
|
268
|
+
setTimeout(() => { if (!proc.killed) proc.kill("SIGKILL"); }, 3000);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
258
271
|
if (event.type === "message_end" && event.message) {
|
|
259
272
|
messages.push(event.message);
|
|
260
273
|
if (event.message.role === "assistant") {
|
|
@@ -288,6 +301,7 @@ export async function spawnAnt(
|
|
|
288
301
|
|
|
289
302
|
if (signal) {
|
|
290
303
|
const kill = () => {
|
|
304
|
+
try { fs.unlinkSync(tmpFile); } catch { /* already cleaned */ }
|
|
291
305
|
proc.kill("SIGTERM");
|
|
292
306
|
setTimeout(() => { if (!proc.killed) proc.kill("SIGKILL"); }, 3000);
|
|
293
307
|
};
|
|
@@ -19,79 +19,9 @@ export const DEFAULT_ANT_CONFIGS: Record<AntCaste, Omit<AntConfig, "systemPrompt
|
|
|
19
19
|
soldier: { caste: "soldier", model: "", tools: ["read", "bash", "grep", "find", "ls"], maxTurns: 8 },
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
// ═══ 模型层级 ═══
|
|
23
|
-
export type ModelTier = "fast" | "balanced" | "powerful";
|
|
24
|
-
|
|
25
|
-
export const CASTE_MODEL_TIER: Record<AntCaste, ModelTier> = {
|
|
26
|
-
scout: "fast",
|
|
27
|
-
worker: "powerful",
|
|
28
|
-
soldier: "balanced",
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export const MODEL_TIER_KEYWORDS: Record<ModelTier, string[]> = {
|
|
32
|
-
fast: ["haiku", "mini", "flash", "nano", "small"],
|
|
33
|
-
balanced: ["sonnet", "gpt-4o", "pro"],
|
|
34
|
-
powerful: ["opus", "o1", "o3", "deepthink"],
|
|
35
|
-
};
|
|
36
|
-
|
|
37
22
|
/** Per-caste model overrides from user config */
|
|
38
23
|
export type ModelOverrides = Partial<Record<AntCaste, string>>;
|
|
39
24
|
|
|
40
|
-
export interface AvailableModel {
|
|
41
|
-
id: string;
|
|
42
|
-
cost?: { input: number };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Extract version score from model name: "claude-sonnet-4-0" → 4.0, "claude-3-5-haiku" → 3.5 */
|
|
46
|
-
function modelVersionScore(id: string): number {
|
|
47
|
-
// Match patterns like "4-0", "3-5", "4.5", "4o" etc.
|
|
48
|
-
const nums = id.match(/(\d+)[-.](\d+)/g);
|
|
49
|
-
if (!nums) {
|
|
50
|
-
const single = id.match(/(\d+)/g);
|
|
51
|
-
return single ? Math.max(...single.map(Number)) : 0;
|
|
52
|
-
}
|
|
53
|
-
// Take the highest version-like number pair
|
|
54
|
-
return Math.max(...nums.map(n => {
|
|
55
|
-
const [a, b] = n.split(/[-.]/).map(Number);
|
|
56
|
-
return a + (b ?? 0) / 10;
|
|
57
|
-
}));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* 根据 caste 的 ModelTier 从可用模型中匹配最合适的模型
|
|
62
|
-
*
|
|
63
|
-
* 策略:从模型名提取版本号排序
|
|
64
|
-
* - fast(侦察蚁):选版本号最低的(轻量便宜)
|
|
65
|
-
* - powerful(工蚁):选版本号最高的(最强)
|
|
66
|
-
* - balanced(兵蚁):优先当前会话模型
|
|
67
|
-
* - 所有 tier 最终 fallback 到 currentModel
|
|
68
|
-
*/
|
|
69
|
-
export function resolveModelForCaste(
|
|
70
|
-
caste: AntCaste,
|
|
71
|
-
available: AvailableModel[],
|
|
72
|
-
currentModel?: string,
|
|
73
|
-
): string | undefined {
|
|
74
|
-
if (available.length === 0) return currentModel;
|
|
75
|
-
const tier = CASTE_MODEL_TIER[caste];
|
|
76
|
-
|
|
77
|
-
// 工蚁/兵蚁优先使用当前会话模型
|
|
78
|
-
if (tier !== "fast" && currentModel) return currentModel;
|
|
79
|
-
|
|
80
|
-
// 按版本号排序
|
|
81
|
-
const scored = available.map(m => ({ id: m.id, score: modelVersionScore(m.id) }));
|
|
82
|
-
scored.sort((a, b) => a.score - b.score);
|
|
83
|
-
|
|
84
|
-
// 优先同 provider
|
|
85
|
-
const provider = currentModel?.split("/")[0] ?? currentModel?.split("-")[0];
|
|
86
|
-
const sameProvider = provider ? scored.filter(m => m.id.toLowerCase().includes(provider.toLowerCase())) : [];
|
|
87
|
-
const pool = sameProvider.length > 0 ? sameProvider : scored;
|
|
88
|
-
|
|
89
|
-
if (tier === "fast") return pool[pool.length - 1]?.id ?? currentModel;
|
|
90
|
-
if (tier === "powerful") return pool[pool.length - 1]?.id ?? currentModel;
|
|
91
|
-
// balanced: middle
|
|
92
|
-
return pool[Math.floor(pool.length / 2)]?.id ?? currentModel;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
25
|
// ═══ 任务 (Food Source) ═══
|
|
96
26
|
export type TaskStatus = "pending" | "claimed" | "active" | "done" | "failed" | "blocked";
|
|
97
27
|
export type TaskPriority = 1 | 2 | 3 | 4 | 5; // 1=highest
|