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.
- package/dist/i18n.test.d.ts +1 -0
- package/dist/i18n.test.js +56 -0
- package/dist/registry.test.d.ts +1 -0
- package/dist/registry.test.js +68 -0
- package/dist/tui/confirm-apply.d.ts +7 -0
- package/dist/tui/confirm-apply.js +1 -1
- package/dist/tui/confirm-apply.test.d.ts +1 -0
- package/dist/tui/confirm-apply.test.js +20 -0
- package/dist/tui/provider-setup.d.ts +2 -0
- package/dist/tui/provider-setup.js +33 -3
- package/dist/tui/provider-setup.test.d.ts +1 -0
- package/dist/tui/provider-setup.test.js +40 -0
- package/dist/tui/welcome.d.ts +6 -0
- package/dist/tui/welcome.js +1 -1
- package/dist/tui/welcome.test.d.ts +1 -0
- package/dist/tui/welcome.test.js +25 -0
- package/dist/utils/resources.test.d.ts +1 -0
- package/dist/utils/resources.test.js +39 -0
- package/package.json +5 -3
- package/pi-package/extensions/ant-colony/concurrency.test.ts +70 -0
- package/pi-package/extensions/ant-colony/concurrency.ts +17 -11
- package/pi-package/extensions/ant-colony/deps.test.ts +62 -0
- package/pi-package/extensions/ant-colony/index.ts +27 -44
- package/pi-package/extensions/ant-colony/nest.ts +106 -43
- package/pi-package/extensions/ant-colony/parser.test.ts +110 -0
- package/pi-package/extensions/ant-colony/parser.ts +34 -12
- package/pi-package/extensions/ant-colony/prompts.test.ts +57 -0
- package/pi-package/extensions/ant-colony/queen.ts +82 -33
- package/pi-package/extensions/ant-colony/spawner.test.ts +44 -0
- package/pi-package/extensions/ant-colony/spawner.ts +24 -5
- package/pi-package/extensions/ant-colony/types.test.ts +36 -0
- package/pi-package/extensions/ant-colony/types.ts +1 -0
- package/pi-package/extensions/ant-colony/ui.test.ts +66 -0
- package/pi-package/extensions/auto-update.test.ts +15 -0
- package/pi-package/extensions/auto-update.ts +1 -1
- package/pi-package/extensions/safe-guard.test.ts +26 -0
- package/pi-package/extensions/safe-guard.ts +2 -2
- package/pi-package/extensions/smart-compact.test.ts +64 -0
- 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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
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
|
+
});
|