oh-pi 0.1.70 → 0.1.72

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.
Files changed (37) hide show
  1. package/dist/i18n.test.d.ts +1 -0
  2. package/dist/i18n.test.js +56 -0
  3. package/dist/registry.test.d.ts +1 -0
  4. package/dist/registry.test.js +68 -0
  5. package/dist/tui/confirm-apply.d.ts +7 -0
  6. package/dist/tui/confirm-apply.js +1 -1
  7. package/dist/tui/confirm-apply.test.d.ts +1 -0
  8. package/dist/tui/confirm-apply.test.js +20 -0
  9. package/dist/tui/provider-setup.d.ts +2 -0
  10. package/dist/tui/provider-setup.js +1 -1
  11. package/dist/tui/provider-setup.test.d.ts +1 -0
  12. package/dist/tui/provider-setup.test.js +40 -0
  13. package/dist/tui/welcome.d.ts +6 -0
  14. package/dist/tui/welcome.js +1 -1
  15. package/dist/tui/welcome.test.d.ts +1 -0
  16. package/dist/tui/welcome.test.js +25 -0
  17. package/dist/utils/resources.test.d.ts +1 -0
  18. package/dist/utils/resources.test.js +39 -0
  19. package/package.json +5 -3
  20. package/pi-package/extensions/ant-colony/concurrency.test.ts +70 -0
  21. package/pi-package/extensions/ant-colony/deps.test.ts +62 -0
  22. package/pi-package/extensions/ant-colony/index.ts +4 -0
  23. package/pi-package/extensions/ant-colony/nest.ts +37 -44
  24. package/pi-package/extensions/ant-colony/parser.test.ts +110 -0
  25. package/pi-package/extensions/ant-colony/prompts.test.ts +57 -0
  26. package/pi-package/extensions/ant-colony/prompts.ts +7 -1
  27. package/pi-package/extensions/ant-colony/queen.ts +159 -21
  28. package/pi-package/extensions/ant-colony/spawner.test.ts +44 -0
  29. package/pi-package/extensions/ant-colony/spawner.ts +16 -1
  30. package/pi-package/extensions/ant-colony/types.test.ts +36 -0
  31. package/pi-package/extensions/ant-colony/ui.test.ts +66 -0
  32. package/pi-package/extensions/auto-update.test.ts +15 -0
  33. package/pi-package/extensions/auto-update.ts +1 -1
  34. package/pi-package/extensions/safe-guard.test.ts +26 -0
  35. package/pi-package/extensions/safe-guard.ts +2 -2
  36. package/pi-package/extensions/smart-compact.test.ts +64 -0
  37. package/pi-package/extensions/smart-compact.ts +2 -2
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ vi.mock("@mariozechner/pi-coding-agent", () => ({
4
+ AuthStorage: class {},
5
+ createAgentSession: vi.fn(),
6
+ createReadTool: vi.fn(), createBashTool: vi.fn(), createEditTool: vi.fn(),
7
+ createWriteTool: vi.fn(), createGrepTool: vi.fn(), createFindTool: vi.fn(),
8
+ createLsTool: vi.fn(), ModelRegistry: class {}, SessionManager: { inMemory: vi.fn() },
9
+ SettingsManager: { inMemory: vi.fn() }, createExtensionRuntime: vi.fn(),
10
+ }));
11
+ vi.mock("@mariozechner/pi-ai", () => ({ getModel: vi.fn() }));
12
+ vi.mock("./spawner.js", async () => {
13
+ const actual = await vi.importActual<any>("./spawner.js");
14
+ return { ...actual, makePheromoneId: () => "p-test" };
15
+ });
16
+
17
+ import { parseSubTasks, extractPheromones } from "./parser.js";
18
+
19
+ describe("parseSubTasks", () => {
20
+ it("parses markdown TASK blocks", () => {
21
+ const output = `## Recommended Tasks
22
+ ### TASK: Fix login
23
+ - description: Fix the login bug
24
+ - files: src/auth.ts
25
+ - caste: worker
26
+ - priority: 2`;
27
+ const tasks = parseSubTasks(output);
28
+ expect(tasks).toHaveLength(1);
29
+ expect(tasks[0].title).toBe("Fix login");
30
+ expect(tasks[0].description).toBe("Fix the login bug");
31
+ expect(tasks[0].files).toEqual(["src/auth.ts"]);
32
+ expect(tasks[0].caste).toBe("worker");
33
+ expect(tasks[0].priority).toBe(2);
34
+ });
35
+
36
+ it("parses JSON block", () => {
37
+ const output = '```json\n[{"title":"Task A","description":"Do A","files":["a.ts"],"caste":"scout","priority":1}]\n```';
38
+ const tasks = parseSubTasks(output);
39
+ expect(tasks).toHaveLength(1);
40
+ expect(tasks[0].title).toBe("Task A");
41
+ expect(tasks[0].caste).toBe("scout");
42
+ });
43
+
44
+ it("defaults caste to worker for invalid", () => {
45
+ const tasks = parseSubTasks('```json\n[{"title":"X","caste":"invalid"}]\n```');
46
+ expect(tasks[0].caste).toBe("worker");
47
+ });
48
+
49
+ it("defaults priority to 3", () => {
50
+ const tasks = parseSubTasks('```json\n[{"title":"X"}]\n```');
51
+ expect(tasks[0].priority).toBe(3);
52
+ });
53
+
54
+ it("returns empty for no tasks", () => {
55
+ expect(parseSubTasks("no tasks here")).toEqual([]);
56
+ });
57
+
58
+ it("parses multiple markdown tasks", () => {
59
+ const output = `### TASK: A
60
+ - description: Do A
61
+ - files: a.ts
62
+ - caste: worker
63
+ - priority: 1
64
+
65
+ ### TASK: B
66
+ - description: Do B
67
+ - files: b.ts
68
+ - caste: soldier
69
+ - priority: 2`;
70
+ const tasks = parseSubTasks(output);
71
+ expect(tasks).toHaveLength(2);
72
+ });
73
+
74
+ it("parses context field", () => {
75
+ const output = `### TASK: Fix it
76
+ - description: Fix bug
77
+ - files: x.ts
78
+ - caste: worker
79
+ - priority: 3
80
+ - context: some relevant code`;
81
+ const tasks = parseSubTasks(output);
82
+ expect(tasks[0].context).toBeTruthy();
83
+ });
84
+ });
85
+
86
+ describe("extractPheromones", () => {
87
+ it("extracts discovery section", () => {
88
+ const p = extractPheromones("ant-1", "scout", "t-1", "## Discoveries\n- Found auth\n\n## Other\nstuff", ["a.ts"]);
89
+ expect(p.some(x => x.type === "discovery")).toBe(true);
90
+ });
91
+
92
+ it("extracts warning section", () => {
93
+ const p = extractPheromones("ant-1", "scout", "t-1", "## Warnings\n- Conflict\n", []);
94
+ expect(p.some(x => x.type === "warning")).toBe(true);
95
+ });
96
+
97
+ it("adds repellent on failure", () => {
98
+ const p = extractPheromones("ant-1", "worker", "t-1", "output", ["a.ts"], true);
99
+ expect(p.some(x => x.type === "repellent")).toBe(true);
100
+ });
101
+
102
+ it("returns empty for no matching sections", () => {
103
+ expect(extractPheromones("ant-1", "worker", "t-1", "nothing", [])).toEqual([]);
104
+ });
105
+
106
+ it("extracts Files Changed as completion", () => {
107
+ const p = extractPheromones("ant-1", "worker", "t-1", "## Files Changed\n- src/foo.ts\n", ["src/foo.ts"]);
108
+ expect(p.some(x => x.type === "completion")).toBe(true);
109
+ });
110
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { CASTE_PROMPTS, buildPrompt } from "./prompts.js";
3
+ import type { Task } from "./types.js";
4
+
5
+ const mkTask = (overrides: Partial<Task> = {}): Task => ({
6
+ id: "t-1", parentId: null, title: "Test task", description: "Do something",
7
+ caste: "worker", status: "pending", priority: 3, files: [], claimedBy: null,
8
+ result: null, error: null, spawnedTasks: [], createdAt: 0, startedAt: null, finishedAt: null,
9
+ ...overrides,
10
+ });
11
+
12
+ describe("CASTE_PROMPTS", () => {
13
+ it("has all castes", () => {
14
+ for (const c of ["scout", "worker", "soldier"] as const) {
15
+ expect(typeof CASTE_PROMPTS[c]).toBe("string");
16
+ expect(CASTE_PROMPTS[c].length).toBeGreaterThan(0);
17
+ }
18
+ });
19
+ });
20
+
21
+ describe("buildPrompt", () => {
22
+ it("includes task title and description", () => {
23
+ const r = buildPrompt(mkTask(), "", "System");
24
+ expect(r).toContain("Test task");
25
+ expect(r).toContain("Do something");
26
+ });
27
+
28
+ it("includes pheromone context", () => {
29
+ const r = buildPrompt(mkTask(), "Found auth at src/auth.ts", "System");
30
+ expect(r).toContain("Found auth at src/auth.ts");
31
+ });
32
+
33
+ it("includes files scope", () => {
34
+ const r = buildPrompt(mkTask({ files: ["a.ts", "b.ts"] }), "", "System");
35
+ expect(r).toContain("a.ts, b.ts");
36
+ });
37
+
38
+ it("includes turn limit when provided", () => {
39
+ const r = buildPrompt(mkTask(), "", "System", 10);
40
+ expect(r).toContain("10");
41
+ expect(r).toContain("Turn Limit");
42
+ });
43
+
44
+ it("omits turn limit when not provided", () => {
45
+ expect(buildPrompt(mkTask(), "", "System")).not.toContain("Turn Limit");
46
+ });
47
+
48
+ it("includes pre-loaded context", () => {
49
+ const r = buildPrompt(mkTask({ context: "code snippet" }), "", "System");
50
+ expect(r).toContain("code snippet");
51
+ });
52
+
53
+ it("adds Chinese hint for Chinese descriptions", () => {
54
+ const r = buildPrompt(mkTask({ description: "修复登录问题" }), "", "System");
55
+ expect(r).toContain("Chinese");
56
+ });
57
+ });
@@ -76,7 +76,7 @@ Output format (MUST follow exactly):
76
76
  PASS or FAIL with summary.`,
77
77
  };
78
78
 
79
- export function buildPrompt(task: Task, pheromoneContext: string, castePrompt: string, maxTurns?: number): string {
79
+ export function buildPrompt(task: Task, pheromoneContext: string, castePrompt: string, maxTurns?: number, tandem?: { parentResult?: string; priorError?: string }): string {
80
80
  let prompt = castePrompt + "\n\n";
81
81
  if (maxTurns) {
82
82
  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`;
@@ -84,6 +84,12 @@ export function buildPrompt(task: Task, pheromoneContext: string, castePrompt: s
84
84
  if (pheromoneContext) {
85
85
  prompt += `## Colony Pheromone Trail (intelligence from other ants)\n${pheromoneContext}\n\n`;
86
86
  }
87
+ if (tandem?.parentResult) {
88
+ prompt += `## Tandem Context (from parent task)\n${tandem.parentResult.slice(0, 3000)}\n\n`;
89
+ }
90
+ if (tandem?.priorError) {
91
+ prompt += `## ⚠️ Prior Attempt Failed\nA previous ant failed on this task. Learn from their mistake:\n${tandem.priorError.slice(0, 1500)}\n\n`;
92
+ }
87
93
  prompt += `## Your Assignment\n**Task:** ${task.title}\n**Description:** ${task.description}\n`;
88
94
  if (task.files.length > 0) {
89
95
  prompt += `**Files scope:** ${task.files.join(", ")}\n`;
@@ -20,7 +20,7 @@ import type {
20
20
  } from "./types.js";
21
21
  import { DEFAULT_ANT_CONFIGS } from "./types.js";
22
22
  import { Nest } from "./nest.js";
23
- import { spawnAnt, runDrone, makeTaskId, makePheromoneId } from "./spawner.js";
23
+ import { spawnAnt, runDrone, makeTaskId, makePheromoneId, resetAntCounter } from "./spawner.js";
24
24
  import { adapt, sampleSystem, defaultConcurrency } from "./concurrency.js";
25
25
  import { buildImportGraph, taskDependsOn, type ImportGraph } from "./deps.js";
26
26
  import type { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
@@ -98,6 +98,44 @@ function childTaskFromParsed(
98
98
  };
99
99
  }
100
100
 
101
+ /**
102
+ * Bio 5: 蚁群投票 — 合并多 Scout 产生的重复任务
103
+ * 相同文件集合的任务合并,被多 Scout 提及的任务 priority 提升
104
+ */
105
+ function quorumMergeTasks(nest: Nest): void {
106
+ const tasks = nest.getAllTasks().filter(t =>
107
+ (t.caste === "worker" || t.caste === "drone") && t.status === "pending"
108
+ );
109
+ if (tasks.length < 2) return;
110
+
111
+ // 按文件集合分组(排序后 join 作为 key)
112
+ const groups = new Map<string, Task[]>();
113
+ for (const t of tasks) {
114
+ const key = [...t.files].sort().join("|") || t.title;
115
+ const arr = groups.get(key) ?? [];
116
+ arr.push(t);
117
+ groups.set(key, arr);
118
+ }
119
+
120
+ for (const [, group] of groups) {
121
+ if (group.length < 2) continue;
122
+ // 保留第一个,删除重复的,合并 description
123
+ const keeper = group[0];
124
+ // Quorum 达成:被多 Scout 提及 → priority 提升
125
+ keeper.priority = Math.max(1, keeper.priority - 1) as 1 | 2 | 3 | 4 | 5;
126
+ // 合并其他任务的 context 到 keeper
127
+ for (let i = 1; i < group.length; i++) {
128
+ const dup = group[i];
129
+ if (dup.context && dup.context !== keeper.context) {
130
+ keeper.context = (keeper.context || "") + "\n\n--- Additional scout context ---\n" + dup.context;
131
+ }
132
+ // 标记重复任务为 done(已合并)
133
+ nest.updateTaskStatus(dup.id, "done", `Merged into ${keeper.id} (quorum)`);
134
+ }
135
+ nest.writeTask(keeper);
136
+ }
137
+ }
138
+
101
139
  function makeReviewTask(completedTasks: Task[]): Task {
102
140
  const files = [...new Set(completedTasks.flatMap(t => t.files))];
103
141
  return {
@@ -120,8 +158,8 @@ function makeReviewTask(completedTasks: Task[]): Task {
120
158
  }
121
159
 
122
160
  function updateMetrics(nest: Nest): ColonyMetrics {
123
- const tasks = nest.getAllTasks();
124
- const state = nest.getState();
161
+ const state = nest.getStateLight();
162
+ const tasks = state.tasks;
125
163
  const now = Date.now();
126
164
  const elapsed = (now - state.metrics.startTime) / 60000; // minutes
127
165
 
@@ -157,25 +195,49 @@ interface WaveOptions {
157
195
  importGraph?: ImportGraph;
158
196
  }
159
197
 
198
+ /**
199
+ * Bio 6: 尸体清理 — 错误模式分类
200
+ */
201
+ function classifyError(errStr: string): string {
202
+ if (errStr.includes("TypeError") || errStr.includes("type") || errStr.includes("TS")) return "type_error";
203
+ if (errStr.includes("permission") || errStr.includes("401") || errStr.includes("EACCES")) return "permission";
204
+ if (errStr.includes("timeout") || errStr.includes("Timeout") || errStr.includes("ETIMEDOUT")) return "timeout";
205
+ if (errStr.includes("ENOENT") || errStr.includes("not found") || errStr.includes("Cannot find")) return "not_found";
206
+ if (errStr.includes("syntax") || errStr.includes("SyntaxError") || errStr.includes("Unexpected")) return "syntax";
207
+ if (errStr.includes("429") || errStr.includes("rate limit")) return "rate_limit";
208
+ return "unknown";
209
+ }
210
+
160
211
  /**
161
212
  * 并发执行一批蚂蚁,自适应调节并发度
162
213
  */
163
214
  async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
164
215
  const { nest, cwd, caste, signal, callbacks, currentModel, emitSignal } = opts;
165
216
  const casteModel = opts.modelOverrides?.[caste] || currentModel;
166
- const config = { ...DEFAULT_ANT_CONFIGS[caste], model: casteModel };
217
+ const baseConfig = { ...DEFAULT_ANT_CONFIGS[caste], model: casteModel };
167
218
 
168
219
  let backoffMs = 0; // 429 退避时间
169
220
  let consecutiveRateLimits = 0; // 连续限流计数
170
221
  const retryCount = new Map<string, number>(); // taskId → retry count
171
222
  const MAX_RETRIES = 2;
172
223
 
224
+ // Bio 6: 尸体清理 — 错误模式追踪
225
+ const errorPatterns = new Map<string, { count: number; files: Set<string>; errors: string[] }>();
226
+
173
227
  const runOne = async (): Promise<"done" | "empty" | "rate_limited" | "budget"> => {
174
228
  // Budget 刹车:预算用完就不出发(drone 免费,不检查)
175
- const state = nest.getState();
229
+ const state = nest.getStateLight();
176
230
  if (state.maxCost != null && caste !== "drone") {
177
231
  const spent = state.ants.reduce((s, a) => s + a.usage.cost, 0);
178
232
  if (spent >= state.maxCost) return "budget";
233
+
234
+ // Bio 4: 巢穴温度 — 成本渐进调控
235
+ const temperature = spent / state.maxCost;
236
+ if (temperature > 0.9) {
237
+ // 紧急模式:只跑 priority 1 任务
238
+ const pending = state.tasks.filter(t => t.status === "pending" && t.caste === caste);
239
+ if (!pending.some(t => t.priority === 1)) return "budget";
240
+ }
179
241
  }
180
242
 
181
243
  const task = nest.claimNextTask(caste, "queen");
@@ -194,6 +256,14 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
194
256
  const antAbort = new AbortController();
195
257
  signal?.addEventListener("abort", () => antAbort.abort(), { once: true });
196
258
  const antSignal = antAbort.signal;
259
+ // Bio 7: 年龄多态 — 前期保守,后期收敛
260
+ const progress = state.metrics.tasksTotal > 0 ? state.metrics.tasksDone / state.metrics.tasksTotal : 0;
261
+ const config = { ...baseConfig };
262
+ if (progress < 0.3) {
263
+ config.maxTurns = Math.max(baseConfig.maxTurns - 3, 5); // 前期保守
264
+ } else if (progress > 0.7) {
265
+ config.maxTurns = Math.max(baseConfig.maxTurns - 5, 5); // 后期收敛,只修复收尾
266
+ }
197
267
  const antPromise = caste === "drone"
198
268
  ? runDrone(cwd, nest, task)
199
269
  : spawnAnt(cwd, nest, task, config, antSignal, callbacks.onAntStream, opts.authStorage, opts.modelRegistry);
@@ -211,7 +281,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
211
281
  }
212
282
 
213
283
  // 成本预警:超 80% 预算时发信号
214
- const curState = nest.getState();
284
+ const curState = nest.getStateLight();
215
285
  if (curState.maxCost != null) {
216
286
  const spent = curState.ants.reduce((s, a) => s + a.usage.cost, 0);
217
287
  if (spent >= curState.maxCost * 0.8) {
@@ -220,8 +290,11 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
220
290
  }
221
291
 
222
292
  // 蚂蚁产生的子任务加入巢穴(限制繁殖上限,防止任务膨胀)
293
+ // Bio 7: 年龄多态 — 后期限制子任务生成
294
+ const m = curState.metrics;
295
+ const colonyProgress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
223
296
  const MAX_TOTAL_TASKS = 30;
224
- const MAX_SUB_PER_TASK = 5;
297
+ const MAX_SUB_PER_TASK = colonyProgress > 0.7 ? 2 : 5; // 后期收敛
225
298
  const accepted = result.newTasks.slice(0, MAX_SUB_PER_TASK);
226
299
  for (const sub of accepted) {
227
300
  if (nest.getAllTasks().length >= MAX_TOTAL_TASKS) break;
@@ -240,13 +313,14 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
240
313
  nest.addSubTask(task.id, child);
241
314
  }
242
315
 
243
- // 路径强化:成功完成释放 completion 信息素,强化相关文件路径
316
+ // 路径强化:成功完成释放 completion 信息素,强度与任务规模成正比(招募信号)
244
317
  if (task.files.length > 0) {
318
+ const recruitStrength = Math.min(1.0, 0.5 + task.files.length * 0.1 + result.newTasks.length * 0.15);
245
319
  nest.dropPheromone({
246
320
  id: makePheromoneId(), type: "completion", antId: result.ant.id,
247
321
  antCaste: caste, taskId: task.id,
248
322
  content: `Success: ${task.title}`,
249
- files: task.files, strength: 1.0, createdAt: Date.now(),
323
+ files: task.files, strength: recruitStrength, createdAt: Date.now(),
250
324
  });
251
325
  }
252
326
 
@@ -264,16 +338,49 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
264
338
  retryCount.set(task.id, count + 1);
265
339
  nest.updateTaskStatus(task.id, "pending");
266
340
  } else {
267
- // 负信息素:失败任务释放 warning,阻止后续蚂蚁走同一条路
341
+ // 负信息素:失败任务释放 warning,强度与任务规模成正比
268
342
  if (task.files.length > 0) {
343
+ const warnStrength = Math.min(1.0, 0.5 + task.files.length * 0.1);
269
344
  nest.dropPheromone({
270
345
  id: makePheromoneId(), type: "warning", antId: "queen",
271
346
  antCaste: caste, taskId: task.id,
272
347
  content: `Failed: ${task.title} — ${String(e).slice(0, 100)}`,
273
- files: task.files, strength: 1.0, createdAt: Date.now(),
348
+ files: task.files, strength: warnStrength, createdAt: Date.now(),
274
349
  });
275
350
  }
276
351
  nest.updateTaskStatus(task.id, "failed", undefined, String(e));
352
+
353
+ // Bio 6: 尸体清理 — 错误模式追踪 + 诊断任务
354
+ const pattern = classifyError(errStr);
355
+ const entry = errorPatterns.get(pattern) ?? { count: 0, files: new Set<string>(), errors: [] };
356
+ entry.count++;
357
+ for (const f of task.files) entry.files.add(f);
358
+ entry.errors.push(errStr.slice(0, 200));
359
+ errorPatterns.set(pattern, entry);
360
+
361
+ if (entry.count >= 2 && entry.files.size > 0) {
362
+ const affectedFiles = [...entry.files];
363
+ // 释放 repellent 信息素
364
+ nest.dropPheromone({
365
+ id: makePheromoneId(), type: "repellent", antId: "queen",
366
+ antCaste: caste, taskId: task.id,
367
+ content: `Recurring ${pattern} errors (${entry.count}x): ${entry.errors[0]?.slice(0, 80)}`,
368
+ files: affectedFiles, strength: 1.0, createdAt: Date.now(),
369
+ });
370
+ // 生成诊断任务(仅首次触发)
371
+ if (entry.count === 2 && nest.getAllTasks().length < 30) {
372
+ const diagTask: Task = {
373
+ id: makeTaskId(), parentId: null,
374
+ title: `Diagnose recurring ${pattern} errors`,
375
+ description: `Multiple ants failed with ${pattern} errors on these files:\n${affectedFiles.map(f => `- ${f}`).join("\n")}\n\nErrors:\n${entry.errors.map(e => `- ${e}`).join("\n")}\n\nInvestigate root cause and generate fix tasks.`,
376
+ caste: "scout", status: "pending", priority: 1,
377
+ files: affectedFiles, claimedBy: null, result: null, error: null,
378
+ spawnedTasks: [], createdAt: Date.now(), startedAt: null, finishedAt: null,
379
+ };
380
+ nest.writeTask(diagTask);
381
+ emitSignal("working", `Diagnosing recurring ${pattern} errors...`);
382
+ }
383
+ }
277
384
  }
278
385
  return "done";
279
386
  }
@@ -282,7 +389,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
282
389
  // 调度循环:持续派蚂蚁直到没有待处理任务
283
390
  let lastSampleTime = 0;
284
391
  while (!signal?.aborted) {
285
- const state = nest.getState();
392
+ const state = nest.getStateLight();
286
393
  const pending = state.tasks.filter(t => t.status === "pending" && t.caste === caste);
287
394
  if (pending.length === 0) break;
288
395
 
@@ -347,7 +454,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
347
454
  // 429 处理:降低并发 + 渐进退避(2s → 5s → 10s,上限 10s)+ 记录时间戳
348
455
  if (results.includes("rate_limited")) {
349
456
  consecutiveRateLimits++;
350
- const cur = nest.getState().concurrency;
457
+ const cur = nest.getStateLight().concurrency;
351
458
  const reduced = Math.max(cur.min, cur.current - 1); // 每次只减 1,不砍半
352
459
  nest.updateState({ concurrency: { ...cur, current: reduced, lastRateLimitAt: Date.now() } });
353
460
  backoffMs = Math.min(consecutiveRateLimits * 2000, 10000);
@@ -366,6 +473,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
366
473
  if (!opts.goal || !opts.goal.trim()) {
367
474
  throw new Error("Colony goal is empty or undefined. Please provide a clear goal.");
368
475
  }
476
+ resetAntCounter();
369
477
  const colonyId = makeColonyId();
370
478
  const nest = new Nest(opts.cwd, colonyId);
371
479
 
@@ -405,7 +513,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
405
513
  };
406
514
 
407
515
  const emitSignal = (phase: ColonyState["status"], message: string) => {
408
- const state = nest.getState();
516
+ const state = nest.getStateLight();
409
517
  const m = state.metrics;
410
518
  const active = state.ants.filter(a => a.status === "working").length;
411
519
  const progress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
@@ -421,11 +529,38 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
421
529
  };
422
530
 
423
531
  try {
424
- // ═══ Phase 1: 侦察(快速单次,不再多轮接力) ═══
425
- callbacks.onPhase?.("scouting", "Dispatching scout ant to explore codebase...");
426
- emitSignal("scouting", "Exploring codebase...");
532
+ // ═══ Phase 1: 侦察(Bio 5: 蚁群投票 — 复杂目标派多 Scout) ═══
533
+ const scoutCount = opts.goal.length > 500 ? 3 : opts.goal.length > 200 ? 2 : 1;
534
+ if (scoutCount > 1) {
535
+ // 多 Scout 并行:为每只 Scout 创建独立任务
536
+ for (let i = 1; i < scoutCount; i++) {
537
+ const extraScout: Task = {
538
+ id: makeTaskId(),
539
+ parentId: null,
540
+ title: `Scout ${i + 1}: explore codebase for goal`,
541
+ description: `Explore the codebase from a different angle and identify files, modules, and dependencies relevant to this goal:\n\n${opts.goal}\n\nFocus on areas other scouts might miss. Be thorough.`,
542
+ caste: "scout",
543
+ status: "pending",
544
+ priority: 1,
545
+ files: [],
546
+ claimedBy: null,
547
+ result: null,
548
+ error: null,
549
+ spawnedTasks: [],
550
+ createdAt: Date.now(),
551
+ startedAt: null,
552
+ finishedAt: null,
553
+ };
554
+ nest.writeTask(extraScout);
555
+ }
556
+ }
557
+ callbacks.onPhase?.("scouting", `Dispatching ${scoutCount} scout ant(s) to explore codebase...`);
558
+ emitSignal("scouting", `${scoutCount} scouts exploring...`);
427
559
  await runAntWave({ ...waveBase, caste: "scout" });
428
560
 
561
+ // Bio 5: 合并多 Scout 产生的重复任务
562
+ if (scoutCount > 1) quorumMergeTasks(nest);
563
+
429
564
  let workerTasks = nest.getAllTasks().filter(t => (t.caste === "worker" || t.caste === "drone") && t.status === "pending");
430
565
 
431
566
  // 只在完全没有 worker 任务时才重试一次
@@ -513,11 +648,14 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
513
648
  }
514
649
 
515
650
  // ═══ 持续探索:Worker 完成后检查是否有新发现,有则再派 Scout ═══
651
+ // Bio 4: 巢穴温度 — 超过 50% 预算禁止新 Scout 探索
516
652
  const discoveries = nest.getAllPheromones().filter(p => p.type === "discovery");
517
653
  const allDone = nest.getAllTasks().filter(t => t.status === "done");
518
- if (discoveries.length > allDone.length) {
519
- const spent = nest.getState().ants.reduce((s, a) => s + a.usage.cost, 0);
520
- if (spent < (nest.getState().maxCost ?? Infinity)) {
654
+ const preExploreSpent = nest.getStateLight().ants.reduce((s, a) => s + a.usage.cost, 0);
655
+ const preExploreBudget = nest.getStateLight().maxCost ?? Infinity;
656
+ const costTemperature = preExploreSpent / preExploreBudget;
657
+ if (discoveries.length > allDone.length && costTemperature < 0.5) {
658
+ if (preExploreSpent < preExploreBudget) {
521
659
  callbacks.onPhase?.("scouting", "Re-exploring based on new discoveries...");
522
660
  emitSignal("scouting", "Re-exploring...");
523
661
  await runAntWave({ ...waveBase, caste: "scout" });
@@ -606,7 +744,7 @@ export async function resumeColony(opts: QueenOptions): Promise<ColonyState> {
606
744
  const { signal, callbacks } = opts;
607
745
 
608
746
  const emitSignal = (phase: ColonyState["status"], message: string) => {
609
- const state = nest.getState();
747
+ const state = nest.getStateLight();
610
748
  const m = state.metrics;
611
749
  const active = state.ants.filter(a => a.status === "working").length;
612
750
  const progress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ vi.mock("@mariozechner/pi-coding-agent", () => ({
4
+ AuthStorage: class {},
5
+ createAgentSession: vi.fn(),
6
+ createReadTool: vi.fn(), createBashTool: vi.fn(), createEditTool: vi.fn(),
7
+ createWriteTool: vi.fn(), createGrepTool: vi.fn(), createFindTool: vi.fn(),
8
+ createLsTool: vi.fn(), ModelRegistry: class {}, SessionManager: { inMemory: vi.fn() },
9
+ SettingsManager: { inMemory: vi.fn() }, createExtensionRuntime: vi.fn(),
10
+ }));
11
+ vi.mock("@mariozechner/pi-ai", () => ({ getModel: vi.fn() }));
12
+
13
+ import { makeAntId, makePheromoneId, makeTaskId } from "./spawner.js";
14
+
15
+ describe("makeAntId", () => {
16
+ it("includes caste name", () => {
17
+ expect(makeAntId("scout")).toContain("scout");
18
+ expect(makeAntId("worker")).toContain("worker");
19
+ });
20
+
21
+ it("returns unique ids", () => {
22
+ expect(makeAntId("worker")).not.toBe(makeAntId("worker"));
23
+ });
24
+ });
25
+
26
+ describe("makePheromoneId", () => {
27
+ it("starts with p-", () => {
28
+ expect(makePheromoneId()).toMatch(/^p-/);
29
+ });
30
+
31
+ it("returns unique ids", () => {
32
+ expect(makePheromoneId()).not.toBe(makePheromoneId());
33
+ });
34
+ });
35
+
36
+ describe("makeTaskId", () => {
37
+ it("starts with t-", () => {
38
+ expect(makeTaskId()).toMatch(/^t-/);
39
+ });
40
+
41
+ it("returns unique ids", () => {
42
+ expect(makeTaskId()).not.toBe(makeTaskId());
43
+ });
44
+ });
@@ -32,6 +32,8 @@ import { parseSubTasks, extractPheromones, type ParsedSubTask } from "./parser.j
32
32
 
33
33
  let antCounter = 0;
34
34
 
35
+ export function resetAntCounter(): void { antCounter = 0; }
36
+
35
37
  export function makeAntId(caste: AntCaste): string {
36
38
  return `${caste}-${++antCounter}-${Date.now().toString(36)}`;
37
39
  }
@@ -179,9 +181,22 @@ export async function spawnAnt(
179
181
  nest.updateAnt(ant);
180
182
  nest.updateTaskStatus(task.id, "active");
181
183
 
184
+ // Bio 2: 任务难度感知 — 动态 maxTurns
185
+ const warnings = nest.countWarnings(task.files);
186
+ const difficultyTurns = Math.min(25, (antConfig.maxTurns || 15) + task.files.length + warnings * 2);
187
+ const effectiveMaxTurns = antConfig.caste === "drone" ? 1 : difficultyTurns;
188
+
189
+ // Bio 3: 串联觅食 — 继承父任务 result 和失败前任 error
190
+ const tandem: { parentResult?: string; priorError?: string } = {};
191
+ if (task.parentId) {
192
+ const parent = nest.getTask(task.parentId);
193
+ if (parent?.result) tandem.parentResult = parent.result;
194
+ }
195
+ if (task.error) tandem.priorError = task.error;
196
+
182
197
  const pheromoneCtx = nest.getPheromoneContext(task.files);
183
198
  const castePrompt = CASTE_PROMPTS[antConfig.caste];
184
- const systemPrompt = buildPrompt(task, pheromoneCtx, castePrompt, antConfig.maxTurns);
199
+ const systemPrompt = buildPrompt(task, pheromoneCtx, castePrompt, effectiveMaxTurns, tandem);
185
200
 
186
201
  const auth = authStorage ?? new AuthStorage();
187
202
  const registry = modelRegistry ?? new ModelRegistry(auth);
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { DEFAULT_ANT_CONFIGS } from "./types.js";
3
+ import type { AntCaste } from "./types.js";
4
+
5
+ describe("DEFAULT_ANT_CONFIGS", () => {
6
+ const castes: AntCaste[] = ["scout", "worker", "soldier", "drone"];
7
+
8
+ it("has all castes", () => {
9
+ for (const c of castes) expect(DEFAULT_ANT_CONFIGS).toHaveProperty(c);
10
+ });
11
+
12
+ it("each config has caste/model/tools/maxTurns", () => {
13
+ for (const c of castes) {
14
+ const cfg = DEFAULT_ANT_CONFIGS[c];
15
+ expect(cfg.caste).toBe(c);
16
+ expect(typeof cfg.model).toBe("string");
17
+ expect(Array.isArray(cfg.tools)).toBe(true);
18
+ expect(cfg.maxTurns).toBeGreaterThan(0);
19
+ }
20
+ });
21
+
22
+ it("scout has no write tools", () => {
23
+ expect(DEFAULT_ANT_CONFIGS.scout.tools).not.toContain("edit");
24
+ expect(DEFAULT_ANT_CONFIGS.scout.tools).not.toContain("write");
25
+ });
26
+
27
+ it("worker has edit and write", () => {
28
+ expect(DEFAULT_ANT_CONFIGS.worker.tools).toContain("edit");
29
+ expect(DEFAULT_ANT_CONFIGS.worker.tools).toContain("write");
30
+ });
31
+
32
+ it("drone only has bash with 1 turn", () => {
33
+ expect(DEFAULT_ANT_CONFIGS.drone.tools).toEqual(["bash"]);
34
+ expect(DEFAULT_ANT_CONFIGS.drone.maxTurns).toBe(1);
35
+ });
36
+ });