oh-pi 0.1.69 → 0.1.71

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 (39) 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 +33 -3
  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/concurrency.ts +17 -11
  22. package/pi-package/extensions/ant-colony/deps.test.ts +62 -0
  23. package/pi-package/extensions/ant-colony/index.ts +27 -44
  24. package/pi-package/extensions/ant-colony/nest.ts +106 -43
  25. package/pi-package/extensions/ant-colony/parser.test.ts +110 -0
  26. package/pi-package/extensions/ant-colony/parser.ts +34 -12
  27. package/pi-package/extensions/ant-colony/prompts.test.ts +57 -0
  28. package/pi-package/extensions/ant-colony/queen.ts +82 -33
  29. package/pi-package/extensions/ant-colony/spawner.test.ts +44 -0
  30. package/pi-package/extensions/ant-colony/spawner.ts +24 -5
  31. package/pi-package/extensions/ant-colony/types.test.ts +36 -0
  32. package/pi-package/extensions/ant-colony/types.ts +1 -0
  33. package/pi-package/extensions/ant-colony/ui.test.ts +66 -0
  34. package/pi-package/extensions/auto-update.test.ts +15 -0
  35. package/pi-package/extensions/auto-update.ts +1 -1
  36. package/pi-package/extensions/safe-guard.test.ts +26 -0
  37. package/pi-package/extensions/safe-guard.ts +2 -2
  38. package/pi-package/extensions/smart-compact.test.ts +64 -0
  39. package/pi-package/extensions/smart-compact.ts +2 -2
@@ -18,10 +18,19 @@ import { runColony, resumeColony, type QueenCallbacks } from "./queen.js";
18
18
  import { Nest } from "./nest.js";
19
19
  import type { ColonyState, ColonyMetrics, AntStreamEvent } from "./types.js";
20
20
 
21
- import { formatDuration, formatCost, formatTokens, statusIcon, casteIcon } from "./ui.js";
21
+ import { formatDuration, formatCost, formatTokens, statusIcon, casteIcon, buildReport } from "./ui.js";
22
22
 
23
23
  // ═══ Background colony state ═══
24
24
 
25
+ /** Ensure .ant-colony/ is in .gitignore */
26
+ function ensureGitignore(cwd: string) {
27
+ const gitignorePath = join(cwd, ".gitignore");
28
+ const content = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
29
+ if (!content.includes(".ant-colony/")) {
30
+ appendFileSync(gitignorePath, `${content.length && !content.endsWith("\n") ? "\n" : ""}.ant-colony/\n`);
31
+ }
32
+ }
33
+
25
34
  interface AntStreamState {
26
35
  antId: string;
27
36
  caste: string;
@@ -42,6 +51,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
42
51
 
43
52
  // 当前运行中的后台蚁群(同时只允许一个)
44
53
  let activeColony: BackgroundColony | null = null;
54
+ let uiListenersRegistered = false;
45
55
 
46
56
  // ─── Status 渲染 ───
47
57
 
@@ -55,6 +65,9 @@ export default function antColonyExtension(pi: ExtensionAPI) {
55
65
 
56
66
  // 监听事件来更新 UI(确保在有 ctx 的上下文中)
57
67
  pi.on("session_start", async (_event, ctx) => {
68
+ if (uiListenersRegistered) return;
69
+ uiListenersRegistered = true;
70
+
58
71
  pi.events.on("ant-colony:render", () => {
59
72
  if (!activeColony) return;
60
73
  const { state } = activeColony;
@@ -89,11 +102,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
89
102
  cwd: string;
90
103
  modelRegistry?: any;
91
104
  }, signal?: AbortSignal | null) {
92
- const gitignorePath = join(params.cwd, ".gitignore");
93
- const gitContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
94
- if (!gitContent.includes(".ant-colony/")) {
95
- appendFileSync(gitignorePath, `${gitContent.length && !gitContent.endsWith("\n") ? "\n" : ""}.ant-colony/\n`);
96
- }
105
+ ensureGitignore(params.cwd);
97
106
 
98
107
  const callbacks: QueenCallbacks = {};
99
108
 
@@ -110,24 +119,8 @@ export default function antColonyExtension(pi: ExtensionAPI) {
110
119
  modelRegistry: params.modelRegistry,
111
120
  });
112
121
 
113
- const m = state.metrics;
114
- const elapsed = state.finishedAt ? formatDuration(state.finishedAt - state.createdAt) : "?";
115
- const report = [
116
- `## 🐜 Ant Colony Report`,
117
- `**Goal:** ${state.goal}`,
118
- `**Status:** ${statusIcon(state.status)} ${state.status} │ ${elapsed} │ ${formatCost(m.totalCost)}`,
119
- `**Tasks:** ${m.tasksDone}/${m.tasksTotal} done${m.tasksFailed > 0 ? `, ${m.tasksFailed} failed` : ""}`,
120
- ``,
121
- ...state.tasks.filter(t => t.status === "done").map(t =>
122
- `- ✓ **${t.title}**`
123
- ),
124
- ...state.tasks.filter(t => t.status === "failed").map(t =>
125
- `- ✗ **${t.title}** — ${t.error?.slice(0, 80) || "unknown"}`
126
- ),
127
- ].join("\n");
128
-
129
122
  return {
130
- content: [{ type: "text" as const, text: report }],
123
+ content: [{ type: "text" as const, text: buildReport(state) }],
131
124
  isError: state.status === "failed" || state.status === "budget_exceeded",
132
125
  };
133
126
  } catch (e) {
@@ -226,11 +219,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
226
219
  };
227
220
 
228
221
  // Ensure .ant-colony/ is in .gitignore
229
- const gitignorePath = join(params.cwd, ".gitignore");
230
- const gitContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
231
- if (!gitContent.includes(".ant-colony/")) {
232
- appendFileSync(gitignorePath, `${gitContent.length && !gitContent.endsWith("\n") ? "\n" : ""}.ant-colony/\n`);
233
- }
222
+ ensureGitignore(params.cwd);
234
223
 
235
224
  const colonyOpts = {
236
225
  cwd: params.cwd,
@@ -251,23 +240,9 @@ export default function antColonyExtension(pi: ExtensionAPI) {
251
240
 
252
241
  // 后台等待完成,注入结果
253
242
  colony.promise.then((state) => {
254
- const m = state.metrics;
255
- const elapsed = state.finishedAt ? formatDuration(state.finishedAt - state.createdAt) : "?";
256
243
  const ok = state.status === "done";
257
-
258
- const report = [
259
- `## 🐜 Ant Colony Report`,
260
- `**Goal:** ${state.goal}`,
261
- `**Status:** ${statusIcon(state.status)} ${state.status} │ ${elapsed} │ ${formatCost(m.totalCost)}`,
262
- `**Tasks:** ${m.tasksDone}/${m.tasksTotal} done${m.tasksFailed > 0 ? `, ${m.tasksFailed} failed` : ""}`,
263
- ``,
264
- ...state.tasks.filter(t => t.status === "done").map(t =>
265
- `- ✓ **${t.title}**`
266
- ),
267
- ...state.tasks.filter(t => t.status === "failed").map(t =>
268
- `- ✗ **${t.title}** — ${t.error?.slice(0, 80) || "unknown"}`
269
- ),
270
- ].join("\n");
244
+ const report = buildReport(state);
245
+ const m = state.metrics;
271
246
 
272
247
  // 清理 UI
273
248
  pi.events.emit("ant-colony:clear-ui");
@@ -594,6 +569,14 @@ export default function antColonyExtension(pi: ExtensionAPI) {
594
569
  pi.on("session_shutdown", async () => {
595
570
  if (activeColony) {
596
571
  activeColony.abortController.abort();
572
+ // Wait for colony to finish gracefully (max 5s)
573
+ try {
574
+ await Promise.race([
575
+ activeColony.promise,
576
+ new Promise(r => setTimeout(r, 5000)),
577
+ ]);
578
+ } catch { /* ignore */ }
579
+ pi.events.emit("ant-colony:clear-ui");
597
580
  activeColony = null;
598
581
  }
599
582
  });
@@ -22,6 +22,9 @@ export class Nest {
22
22
  private pheromoneOffset: number = 0;
23
23
  private taskCache: Map<string, Task> = new Map();
24
24
  private stateCache: ColonyState | null = null;
25
+ private gcCounter: number = 0;
26
+ private pheromoneByFile: Map<string, Pheromone[]> = new Map();
27
+ private pheromoneIndexDirty: boolean = true;
25
28
 
26
29
  constructor(private cwd: string, private colonyId: string) {
27
30
  this.dir = path.join(cwd, ".ant-colony", colonyId);
@@ -51,6 +54,14 @@ export class Nest {
51
54
  return base;
52
55
  }
53
56
 
57
+ /** 轻量版 getState:只返回 stateCache + tasks,不触发 pheromone 读取 */
58
+ getStateLight(): ColonyState {
59
+ if (!this.stateCache) {
60
+ this.stateCache = this.readJson<ColonyState>(this.stateFile);
61
+ }
62
+ return { ...this.stateCache, tasks: this.getAllTasks() };
63
+ }
64
+
54
65
  updateState(patch: Partial<Pick<ColonyState, "status" | "concurrency" | "metrics" | "ants" | "finishedAt">>): void {
55
66
  this.withStateLock(() => {
56
67
  if (!this.stateCache) {
@@ -90,12 +101,50 @@ export class Nest {
90
101
  }
91
102
 
92
103
  claimTask(taskId: string, antId: string): boolean {
93
- const task = this.getTask(taskId);
94
- if (!task || task.status !== "pending") return false;
95
- task.status = "claimed";
96
- task.claimedBy = antId;
97
- this.writeTask(task);
98
- return true;
104
+ return this.withStateLock(() => {
105
+ const task = this.getTask(taskId);
106
+ if (!task || task.status !== "pending") return false;
107
+ task.status = "claimed";
108
+ task.claimedBy = antId;
109
+ this.writeTask(task);
110
+ return true;
111
+ });
112
+ }
113
+
114
+ /** 原子选取并 claim 下一个任务,消灭 nextPendingTask+claimTask 之间的竞态窗口 */
115
+ claimNextTask(caste: "scout" | "worker" | "soldier" | "drone", antId: string): Task | null {
116
+ return this.withStateLock(() => {
117
+ const tasks = this.getAllTasks().filter(t => t.status === "pending" && t.caste === caste);
118
+ if (tasks.length === 0) return null;
119
+
120
+ this.getAllPheromones();
121
+ let chosen: Task;
122
+ if (tasks.length > 1 && Math.random() < 0.1) {
123
+ chosen = tasks[Math.floor(Math.random() * tasks.length)];
124
+ } else {
125
+ const scored = tasks.map(t => {
126
+ let pScore = 0;
127
+ const seen = new Set<Pheromone>();
128
+ for (const f of t.files) {
129
+ for (const p of this.pheromoneByFile.get(f) ?? []) {
130
+ if (seen.has(p) || p.strength <= 0.1) continue;
131
+ seen.add(p);
132
+ if (p.type === "discovery" || p.type === "completion") pScore += p.strength;
133
+ else if (p.type === "repellent") pScore -= p.strength * 3;
134
+ else if (p.type === "warning") pScore -= p.strength;
135
+ }
136
+ }
137
+ return { task: t, score: (6 - t.priority) + pScore };
138
+ });
139
+ scored.sort((a, b) => b.score - a.score);
140
+ chosen = scored[0].task;
141
+ }
142
+
143
+ chosen.status = "claimed";
144
+ chosen.claimedBy = antId;
145
+ this.writeTask(chosen);
146
+ return chosen;
147
+ });
99
148
  }
100
149
 
101
150
  updateTaskStatus(taskId: string, status: TaskStatus, result?: string, error?: string): void {
@@ -118,37 +167,11 @@ export class Nest {
118
167
  }
119
168
  }
120
169
 
121
- /** 获取下一个可领取的任务(按优先级 + 信息素强度 - repellent负信息素排序,ε-greedy 随机觅食) */
122
- nextPendingTask(caste: "scout" | "worker" | "soldier"): Task | null {
123
- const tasks = this.getAllTasks()
124
- .filter(t => t.status === "pending" && t.caste === caste);
125
- if (tasks.length === 0) return null;
126
-
127
- // ε-greedy:10% 概率随机选任务,避免蚂蚁全挤同一条路
128
- if (tasks.length > 1 && Math.random() < 0.1) {
129
- return tasks[Math.floor(Math.random() * tasks.length)];
130
- }
131
-
132
- // 信息素加权:discovery/completion 加分,warning/repellent 减分(负信息素)
133
- const pheromones = this.getAllPheromones();
134
- const scored = tasks.map(t => {
135
- let pScore = 0;
136
- for (const p of pheromones) {
137
- if (!p.files.some(f => t.files.includes(f)) || p.strength <= 0.1) continue;
138
- if (p.type === "discovery" || p.type === "completion") pScore += p.strength;
139
- else if (p.type === "repellent") pScore -= p.strength * 3; // repellent 负信息素惩罚最重
140
- else if (p.type === "warning") pScore -= p.strength;
141
- }
142
- return { task: t, score: (6 - t.priority) + pScore };
143
- });
144
- scored.sort((a, b) => b.score - a.score);
145
- return scored[0]?.task ?? null;
146
- }
147
-
148
170
  // ═══ Pheromones ═══
149
171
 
150
172
  dropPheromone(p: Pheromone): void {
151
173
  fs.appendFileSync(this.pheromoneFile, JSON.stringify(p) + "\n");
174
+ this.pheromoneIndexDirty = true;
152
175
  }
153
176
 
154
177
  getAllPheromones(): Pheromone[] {
@@ -171,10 +194,36 @@ export class Nest {
171
194
  }
172
195
 
173
196
  // 衰减 + 过滤弱信息素
197
+ const beforeLen = this.pheromoneCache.length;
174
198
  this.pheromoneCache = this.pheromoneCache.filter(p => {
175
- p.strength = p.strength * Math.pow(0.5, (now - p.createdAt) / HALF_LIFE);
199
+ p.strength = Math.pow(0.5, (now - p.createdAt) / HALF_LIFE);
176
200
  return p.strength > 0.05;
177
201
  });
202
+ const hadGarbage = this.pheromoneCache.length < beforeLen;
203
+ if (hadGarbage) this.pheromoneIndexDirty = true;
204
+
205
+ // 重建文件索引(仅在 dirty 时)
206
+ if (this.pheromoneIndexDirty) {
207
+ this.pheromoneByFile.clear();
208
+ for (const p of this.pheromoneCache) {
209
+ for (const f of p.files) {
210
+ let arr = this.pheromoneByFile.get(f);
211
+ if (!arr) { arr = []; this.pheromoneByFile.set(f, arr); }
212
+ arr.push(p);
213
+ }
214
+ }
215
+ this.pheromoneIndexDirty = false;
216
+ }
217
+
218
+ // GC:每 10 次调用,若有弱条目被过滤则重写文件
219
+ this.gcCounter++;
220
+ if (this.gcCounter >= 10 && hadGarbage) {
221
+ this.gcCounter = 0;
222
+ const tmp = this.pheromoneFile + ".tmp";
223
+ fs.writeFileSync(tmp, this.pheromoneCache.map(p => JSON.stringify(p)).join("\n") + (this.pheromoneCache.length ? "\n" : ""));
224
+ fs.renameSync(tmp, this.pheromoneFile);
225
+ this.pheromoneOffset = fs.statSync(this.pheromoneFile).size;
226
+ }
178
227
 
179
228
  return this.pheromoneCache;
180
229
  }
@@ -230,23 +279,29 @@ export class Nest {
230
279
 
231
280
  private withStateLock<T>(fn: () => T): T {
232
281
  const MAX_WAIT = 3000;
233
- const SPIN_MS = 1;
282
+ const SPIN_MS = 5;
234
283
  const start = Date.now();
235
284
  while (true) {
236
285
  try {
237
- fs.writeFileSync(this.lockFile, `${process.pid}`, { flag: "wx" });
286
+ fs.writeFileSync(this.lockFile, `${process.pid}:${Date.now()}`, { flag: "wx" });
238
287
  break;
239
288
  } catch {
240
289
  if (Date.now() - start > MAX_WAIT) {
241
- // 超时:检查锁持有者是否存活
242
290
  try {
243
- const holder = parseInt(fs.readFileSync(this.lockFile, "utf-8"), 10);
244
- try { process.kill(holder, 0); } catch { /* 进程已死,清除死锁 */ fs.unlinkSync(this.lockFile); continue; }
245
- } catch { /* 读取失败,强制清除 */ try { fs.unlinkSync(this.lockFile); } catch {} }
246
- continue;
291
+ const content = fs.readFileSync(this.lockFile, "utf-8");
292
+ const [pidStr, tsStr] = content.split(":");
293
+ const holder = parseInt(pidStr, 10);
294
+ const lockTime = parseInt(tsStr, 10);
295
+ // 超过 30s 的锁视为过期
296
+ if (lockTime && Date.now() - lockTime > 30_000) { fs.unlinkSync(this.lockFile); continue; }
297
+ // 进程存活检查作为第二道防线
298
+ try { process.kill(holder, 0); } catch { fs.unlinkSync(this.lockFile); continue; }
299
+ } catch { try { fs.unlinkSync(this.lockFile); } catch {} }
300
+ // 进程存活且锁未过期,放弃等待
301
+ throw new Error(`[Nest] withStateLock timeout after ${MAX_WAIT}ms`);
247
302
  }
248
- // 简单 busy-wait,避免 SharedArrayBuffer 依赖
249
- const until = Date.now() + SPIN_MS + Math.random() * SPIN_MS;
303
+ // 简单 busy-wait + jitter,避免 SharedArrayBuffer 依赖
304
+ const until = Date.now() + SPIN_MS + Math.random() * SPIN_MS * 2;
250
305
  while (Date.now() < until) { /* spin */ }
251
306
  }
252
307
  }
@@ -294,5 +349,13 @@ export class Nest {
294
349
  this.writeTask(task);
295
350
  }
296
351
  }
352
+ // 将 orphaned working/idle ants 标记为 failed
353
+ for (const ant of this.stateCache.ants) {
354
+ if (ant.status === "working" || ant.status === "idle") {
355
+ ant.status = "failed";
356
+ ant.finishedAt = Date.now();
357
+ }
358
+ }
359
+ this.writeJson(this.stateFile, this.stateCache);
297
360
  }
298
361
  }
@@ -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
+ });
@@ -1,6 +1,8 @@
1
1
  import type { AntCaste, Pheromone, PheromoneType } from "./types.js";
2
2
  import { makePheromoneId } from "./spawner.js";
3
3
 
4
+ const VALID_CASTES = new Set(["scout", "worker", "soldier", "drone"]);
5
+
4
6
  export interface ParsedSubTask {
5
7
  title: string;
6
8
  description: string;
@@ -11,21 +13,41 @@ export interface ParsedSubTask {
11
13
  }
12
14
 
13
15
  export function parseSubTasks(output: string): ParsedSubTask[] {
16
+ // Try JSON block first
17
+ const jsonMatch = output.match(/```json\s*([\s\S]*?)```/);
18
+ if (jsonMatch?.[1]) {
19
+ try {
20
+ const parsed = JSON.parse(jsonMatch[1].trim());
21
+ const arr = Array.isArray(parsed) ? parsed : [parsed];
22
+ return arr.map((t: Record<string, unknown>) => ({
23
+ title: String(t.title || "Untitled"),
24
+ description: String(t.description || t.title || ""),
25
+ files: Array.isArray(t.files) ? t.files.map(String) : String(t.files || "").split(",").map((f: string) => f.trim()).filter(Boolean),
26
+ caste: (VALID_CASTES.has(String(t.caste)) ? String(t.caste) : "worker") as AntCaste,
27
+ priority: (Math.min(5, Math.max(1, parseInt(String(t.priority || "3")))) as 1 | 2 | 3 | 4 | 5),
28
+ context: t.context ? String(t.context) : undefined,
29
+ }));
30
+ } catch { /* fallback to regex */ }
31
+ }
32
+
33
+ // Fallback: regex parsing with per-task try-catch
14
34
  const tasks: ParsedSubTask[] = [];
15
35
  const regex = /### TASK:\s*(.+)\n(?:- description:\s*(.+)\n)?(?:- files:\s*(.+)\n)?(?:- caste:\s*(\w+)\n)?(?:- priority:\s*(\d))?/g;
16
36
  const taskBlocks = output.split(/(?=### TASK:)/);
17
37
  for (const m of output.matchAll(regex)) {
18
- const block = taskBlocks.find(b => b.includes(`### TASK: ${m[1]?.trim()}`)) || "";
19
- const ctxMatch = block.match(/- context:\s*([\s\S]*?)(?=### TASK:|## |\n\n|$)/);
20
- const context = ctxMatch?.[1]?.trim() || undefined;
21
- tasks.push({
22
- title: m[1]?.trim() || "Untitled",
23
- description: m[2]?.trim() || m[1]?.trim() || "",
24
- files: (m[3]?.trim() || "").split(",").map((f: string) => f.trim()).filter(Boolean),
25
- caste: (m[4]?.trim() as AntCaste) || "worker",
26
- priority: (parseInt(m[5] || "3") as 1 | 2 | 3 | 4 | 5) || 3,
27
- context,
28
- });
38
+ try {
39
+ const block = taskBlocks.find(b => b.includes(`### TASK: ${m[1]?.trim()}`)) || "";
40
+ const ctxMatch = block.match(/- context:\s*([\s\S]*?)(?=### TASK:|## |\n\n|$)/);
41
+ const context = ctxMatch?.[1]?.trim() || undefined;
42
+ tasks.push({
43
+ title: m[1]?.trim() || "Untitled",
44
+ description: m[2]?.trim() || m[1]?.trim() || "",
45
+ files: (m[3]?.trim() || "").split(",").map((f: string) => f.trim()).filter(Boolean),
46
+ caste: (VALID_CASTES.has(m[4]?.trim() ?? "") ? m[4]!.trim() : "worker") as AntCaste,
47
+ priority: (parseInt(m[5] || "3") as 1 | 2 | 3 | 4 | 5) || 3,
48
+ context,
49
+ });
50
+ } catch { /* skip malformed task, continue */ }
29
51
  }
30
52
  return tasks;
31
53
  }
@@ -35,7 +57,7 @@ export function extractPheromones(antId: string, caste: AntCaste, taskId: string
35
57
  const now = Date.now();
36
58
  const sections = ["Discoveries", "Pheromone", "Files Changed", "Warnings", "Review"];
37
59
  for (const section of sections) {
38
- const regex = new RegExp(`## ${section}\\n([\\s\\S]*?)(?=\\n## |$)`, "i");
60
+ const regex = new RegExp(`#{1,2} ${section}\\n([\\s\\S]*?)(?=\\n#{1,2} |$)`, "i");
39
61
  const match = output.match(regex);
40
62
  if (match?.[1]?.trim()) {
41
63
  const type: PheromoneType =
@@ -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
+ });