oh-pi 0.1.54 → 0.1.56
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/package.json +1 -1
- package/pi-package/extensions/ant-colony/deps.ts +94 -0
- package/pi-package/extensions/ant-colony/index.ts +1 -1
- package/pi-package/extensions/ant-colony/queen.ts +42 -10
- package/pi-package/extensions/ant-colony/spawner.ts +47 -2
- package/pi-package/extensions/ant-colony/types.ts +2 -1
package/package.json
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 轻量 import graph — 静态分析 ts/js 文件的依赖关系
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
|
|
7
|
+
export interface ImportGraph {
|
|
8
|
+
/** file → files it imports */
|
|
9
|
+
imports: Map<string, Set<string>>;
|
|
10
|
+
/** file → files that import it */
|
|
11
|
+
importedBy: Map<string, Set<string>>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 从 import/require 语句中提取相对路径 */
|
|
15
|
+
const IMPORT_RE = /(?:import|export)\s+.*?from\s+['"](\.[^'"]+)['"]/g;
|
|
16
|
+
const REQUIRE_RE = /require\s*\(\s*['"](\.[^'"]+)['"]\s*\)/g;
|
|
17
|
+
|
|
18
|
+
function resolveImport(from: string, specifier: string, cwd: string): string | null {
|
|
19
|
+
const dir = path.dirname(path.resolve(cwd, from));
|
|
20
|
+
const base = path.resolve(dir, specifier);
|
|
21
|
+
const exts = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.js"];
|
|
22
|
+
for (const ext of exts) {
|
|
23
|
+
const full = base + ext;
|
|
24
|
+
if (fs.existsSync(full)) return path.relative(cwd, full);
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 构建项目的 import graph
|
|
31
|
+
* @param files - 相对于 cwd 的文件路径列表
|
|
32
|
+
* @param cwd - 项目根目录
|
|
33
|
+
*/
|
|
34
|
+
export function buildImportGraph(files: string[], cwd: string): ImportGraph {
|
|
35
|
+
const imports = new Map<string, Set<string>>();
|
|
36
|
+
const importedBy = new Map<string, Set<string>>();
|
|
37
|
+
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
const abs = path.resolve(cwd, file);
|
|
40
|
+
if (!fs.existsSync(abs)) continue;
|
|
41
|
+
let content: string;
|
|
42
|
+
try { content = fs.readFileSync(abs, "utf-8"); } catch { continue; }
|
|
43
|
+
|
|
44
|
+
const deps = new Set<string>();
|
|
45
|
+
for (const re of [IMPORT_RE, REQUIRE_RE]) {
|
|
46
|
+
re.lastIndex = 0;
|
|
47
|
+
for (const m of content.matchAll(re)) {
|
|
48
|
+
const resolved = resolveImport(file, m[1], cwd);
|
|
49
|
+
if (resolved) deps.add(resolved);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
imports.set(file, deps);
|
|
54
|
+
for (const dep of deps) {
|
|
55
|
+
if (!importedBy.has(dep)) importedBy.set(dep, new Set());
|
|
56
|
+
importedBy.get(dep)!.add(file);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { imports, importedBy };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 计算文件的依赖深度(被多少文件直接或间接依赖)
|
|
65
|
+
* 深度越高 = 越底层 = 应优先处理
|
|
66
|
+
*/
|
|
67
|
+
export function dependencyDepth(file: string, graph: ImportGraph): number {
|
|
68
|
+
const visited = new Set<string>();
|
|
69
|
+
const queue = [file];
|
|
70
|
+
while (queue.length > 0) {
|
|
71
|
+
const f = queue.pop()!;
|
|
72
|
+
if (visited.has(f)) continue;
|
|
73
|
+
visited.add(f);
|
|
74
|
+
const dependents = graph.importedBy.get(f);
|
|
75
|
+
if (dependents) for (const d of dependents) queue.push(d);
|
|
76
|
+
}
|
|
77
|
+
return visited.size - 1; // 不算自己
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 检查 taskA 的文件是否依赖 taskB 的文件
|
|
82
|
+
* 即 taskA 的某个文件 import 了 taskB 的某个文件
|
|
83
|
+
*/
|
|
84
|
+
export function taskDependsOn(taskAFiles: string[], taskBFiles: string[], graph: ImportGraph): boolean {
|
|
85
|
+
for (const a of taskAFiles) {
|
|
86
|
+
const deps = graph.imports.get(a);
|
|
87
|
+
if (deps) {
|
|
88
|
+
for (const b of taskBFiles) {
|
|
89
|
+
if (deps.has(b)) return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
@@ -43,7 +43,7 @@ function statusIcon(status: string): string {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function casteIcon(caste: string): string {
|
|
46
|
-
return caste === "scout" ? "🔍" : caste === "soldier" ? "🛡️" : "⚒️";
|
|
46
|
+
return caste === "scout" ? "🔍" : caste === "soldier" ? "🛡️" : caste === "drone" ? "⚙️" : "⚒️";
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// ═══ Background colony state ═══
|
|
@@ -20,8 +20,9 @@ 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, makeTaskId } from "./spawner.js";
|
|
23
|
+
import { spawnAnt, runDrone, makeTaskId } from "./spawner.js";
|
|
24
24
|
import { adapt, sampleSystem, defaultConcurrency } from "./concurrency.js";
|
|
25
|
+
import { buildImportGraph, taskDependsOn, type ImportGraph } from "./deps.js";
|
|
25
26
|
import type { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
|
26
27
|
|
|
27
28
|
export interface QueenCallbacks {
|
|
@@ -149,6 +150,7 @@ interface WaveOptions {
|
|
|
149
150
|
callbacks: QueenCallbacks;
|
|
150
151
|
authStorage?: AuthStorage;
|
|
151
152
|
modelRegistry?: ModelRegistry;
|
|
153
|
+
importGraph?: ImportGraph;
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
/**
|
|
@@ -177,7 +179,9 @@ async function runAntWave(opts: WaveOptions): Promise<"ok"> {
|
|
|
177
179
|
callbacks.onAntSpawn(ant, task);
|
|
178
180
|
|
|
179
181
|
try {
|
|
180
|
-
const result =
|
|
182
|
+
const result = caste === "drone"
|
|
183
|
+
? await runDrone(cwd, nest, task)
|
|
184
|
+
: await spawnAnt(cwd, nest, task, config, signal, callbacks.onAntStream, opts.authStorage, opts.modelRegistry);
|
|
181
185
|
callbacks.onAntDone(result.ant, task, result.output);
|
|
182
186
|
|
|
183
187
|
if (result.rateLimited) {
|
|
@@ -186,11 +190,13 @@ async function runAntWave(opts: WaveOptions): Promise<"ok"> {
|
|
|
186
190
|
|
|
187
191
|
// 蚂蚁产生的子任务加入巢穴
|
|
188
192
|
for (const sub of result.newTasks) {
|
|
189
|
-
//
|
|
193
|
+
// 检查文件锁冲突和依赖冲突
|
|
190
194
|
const allTasks = nest.getAllTasks();
|
|
191
195
|
const conflicting = allTasks.find(t =>
|
|
192
|
-
t.status === "active" &&
|
|
193
|
-
|
|
196
|
+
t.status === "active" && (
|
|
197
|
+
t.files.some(f => sub.files.includes(f)) ||
|
|
198
|
+
(opts.importGraph && taskDependsOn(sub.files, t.files, opts.importGraph))
|
|
199
|
+
)
|
|
194
200
|
);
|
|
195
201
|
const child = childTaskFromParsed(task.id, sub);
|
|
196
202
|
if (conflicting) {
|
|
@@ -228,11 +234,15 @@ async function runAntWave(opts: WaveOptions): Promise<"ok"> {
|
|
|
228
234
|
await new Promise(r => setTimeout(r, backoffMs));
|
|
229
235
|
}
|
|
230
236
|
|
|
231
|
-
// 解除 blocked
|
|
237
|
+
// 解除 blocked 任务(如果锁定文件和依赖文件都已释放)
|
|
232
238
|
const activeTasks = state.tasks.filter(t => t.status === "active");
|
|
233
239
|
const activeFiles = new Set(activeTasks.flatMap(t => t.files));
|
|
234
240
|
for (const t of state.tasks.filter(t => t.status === "blocked" && t.caste === caste)) {
|
|
235
|
-
|
|
241
|
+
const fileConflict = t.files.some(f => activeFiles.has(f));
|
|
242
|
+
const depConflict = opts.importGraph && activeTasks.some(at =>
|
|
243
|
+
taskDependsOn(t.files, at.files, opts.importGraph!)
|
|
244
|
+
);
|
|
245
|
+
if (!fileConflict && !depConflict) {
|
|
236
246
|
nest.updateTaskStatus(t.id, "pending");
|
|
237
247
|
}
|
|
238
248
|
}
|
|
@@ -322,7 +332,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
322
332
|
|
|
323
333
|
nest.init(initialState);
|
|
324
334
|
const { signal, callbacks } = opts;
|
|
325
|
-
const waveBase: Omit<WaveOptions, "caste"> = {
|
|
335
|
+
const waveBase: Omit<WaveOptions, "caste"> & { importGraph?: ImportGraph } = {
|
|
326
336
|
nest, cwd: opts.cwd, signal, callbacks,
|
|
327
337
|
currentModel: opts.currentModel,
|
|
328
338
|
modelOverrides: opts.modelOverrides,
|
|
@@ -344,7 +354,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
344
354
|
callbacks.onPhase("scouting", "Dispatching scout ant to explore codebase...");
|
|
345
355
|
await runAntWave({ ...waveBase, caste: "scout" });
|
|
346
356
|
|
|
347
|
-
let workerTasks = nest.getAllTasks().filter(t => t.caste === "worker" && t.status === "pending");
|
|
357
|
+
let workerTasks = nest.getAllTasks().filter(t => (t.caste === "worker" || t.caste === "drone") && t.status === "pending");
|
|
348
358
|
|
|
349
359
|
// 只在完全没有 worker 任务时才重试一次
|
|
350
360
|
if (workerTasks.length === 0) {
|
|
@@ -372,7 +382,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
372
382
|
nest.writeTask(relayTask);
|
|
373
383
|
callbacks.onPhase("scouting", "Scout relay: generating worker tasks...");
|
|
374
384
|
await runAntWave({ ...waveBase, caste: "scout" });
|
|
375
|
-
workerTasks = nest.getAllTasks().filter(t => t.caste === "worker" && t.status === "pending");
|
|
385
|
+
workerTasks = nest.getAllTasks().filter(t => (t.caste === "worker" || t.caste === "drone") && t.status === "pending");
|
|
376
386
|
}
|
|
377
387
|
|
|
378
388
|
if (workerTasks.length === 0) {
|
|
@@ -384,12 +394,34 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
384
394
|
|
|
385
395
|
// ═══ Phase 2: 工作 ═══
|
|
386
396
|
nest.updateState({ status: "working" });
|
|
397
|
+
|
|
398
|
+
// 构建 import graph 用于依赖感知调度
|
|
399
|
+
let importGraph: ImportGraph | undefined;
|
|
400
|
+
try {
|
|
401
|
+
const allFiles = nest.getAllTasks().flatMap(t => t.files).filter(f => /\.[tj]sx?$/.test(f));
|
|
402
|
+
if (allFiles.length > 0) {
|
|
403
|
+
importGraph = buildImportGraph([...new Set(allFiles)], opts.cwd);
|
|
404
|
+
waveBase.importGraph = importGraph;
|
|
405
|
+
}
|
|
406
|
+
} catch { /* graph build failed, proceed without */ }
|
|
407
|
+
|
|
408
|
+
// 先执行 drone 任务(零 LLM 成本)
|
|
409
|
+
const droneTasks = nest.getAllTasks().filter(t => t.caste === "drone" && t.status === "pending");
|
|
410
|
+
if (droneTasks.length > 0) {
|
|
411
|
+
callbacks.onPhase("working", `${droneTasks.length} drone tasks. Executing rules...`);
|
|
412
|
+
await runAntWave({ ...waveBase, caste: "drone" });
|
|
413
|
+
}
|
|
414
|
+
|
|
387
415
|
callbacks.onPhase("working", `${workerTasks.length} tasks discovered. Dispatching worker ants...`);
|
|
388
416
|
await runAntWave({ ...waveBase, caste: "worker" });
|
|
389
417
|
|
|
390
418
|
// 处理工蚁产生的子任务(可能有多轮)
|
|
391
419
|
let rounds = 0;
|
|
392
420
|
while (rounds < 3) {
|
|
421
|
+
// 先跑 drone 子任务
|
|
422
|
+
const pendingDrones = nest.getAllTasks().filter(t => t.caste === "drone" && t.status === "pending");
|
|
423
|
+
if (pendingDrones.length > 0) await runAntWave({ ...waveBase, caste: "drone" });
|
|
424
|
+
|
|
393
425
|
const remaining = nest.getAllTasks().filter(t =>
|
|
394
426
|
t.caste === "worker" && (t.status === "pending" || t.status === "blocked")
|
|
395
427
|
);
|
|
@@ -83,6 +83,8 @@ For each task the colony should do next:
|
|
|
83
83
|
- priority: <1-5, 1=highest>
|
|
84
84
|
- context: <relevant code snippets that the worker will need, with file:line references>
|
|
85
85
|
|
|
86
|
+
Use caste "drone" instead of "worker" for simple tasks that can be done with a single bash command (file copy, find-replace, formatting, running tests). Drone description should be the exact bash command to execute.
|
|
87
|
+
|
|
86
88
|
## Warnings
|
|
87
89
|
Any risks, blockers, or conflicts detected.`,
|
|
88
90
|
|
|
@@ -166,7 +168,7 @@ function parseSubTasks(output: string): ParsedSubTask[] {
|
|
|
166
168
|
tasks.push({
|
|
167
169
|
title: m[1]?.trim() || "Untitled",
|
|
168
170
|
description: m[2]?.trim() || m[1]?.trim() || "",
|
|
169
|
-
files: (m[3]?.trim() || "").split(",").map(f => f.trim()).filter(Boolean),
|
|
171
|
+
files: (m[3]?.trim() || "").split(",").map((f: string) => f.trim()).filter(Boolean),
|
|
170
172
|
caste: (m[4]?.trim() as AntCaste) || "worker",
|
|
171
173
|
priority: (parseInt(m[5] || "3") as 1 | 2 | 3 | 4 | 5) || 3,
|
|
172
174
|
context,
|
|
@@ -250,6 +252,49 @@ function makeMinimalResourceLoader(systemPrompt: string): ResourceLoader {
|
|
|
250
252
|
};
|
|
251
253
|
}
|
|
252
254
|
|
|
255
|
+
/**
|
|
256
|
+
* 运行 Drone — 纯规则执行,零 LLM 成本
|
|
257
|
+
* 任务描述即为要执行的 bash 命令
|
|
258
|
+
*/
|
|
259
|
+
export async function runDrone(
|
|
260
|
+
cwd: string,
|
|
261
|
+
nest: Nest,
|
|
262
|
+
task: Task,
|
|
263
|
+
): Promise<AntResult> {
|
|
264
|
+
const antId = makeAntId("drone");
|
|
265
|
+
const ant: Ant = {
|
|
266
|
+
id: antId, caste: "drone", status: "working", taskId: task.id,
|
|
267
|
+
pid: null, model: "none",
|
|
268
|
+
usage: { input: 0, output: 0, cost: 0, turns: 1 },
|
|
269
|
+
startedAt: Date.now(), finishedAt: null,
|
|
270
|
+
};
|
|
271
|
+
nest.updateAnt(ant);
|
|
272
|
+
nest.updateTaskStatus(task.id, "active");
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const { execSync } = await import("node:child_process");
|
|
276
|
+
const cmd = task.description;
|
|
277
|
+
const output = execSync(cmd, { cwd, encoding: "utf-8", timeout: 30000, stdio: "pipe" }).trim();
|
|
278
|
+
|
|
279
|
+
ant.status = "done";
|
|
280
|
+
ant.finishedAt = Date.now();
|
|
281
|
+
nest.updateAnt(ant);
|
|
282
|
+
nest.updateTaskStatus(task.id, "done", `## Completed\n${output || "(no output)"}`);
|
|
283
|
+
nest.dropPheromone({
|
|
284
|
+
id: makePheromoneId(), type: "completion", antId, antCaste: "drone",
|
|
285
|
+
taskId: task.id, content: `Drone executed: ${cmd.slice(0, 100)}`, files: task.files, strength: 1, createdAt: Date.now(),
|
|
286
|
+
});
|
|
287
|
+
return { ant, output, newTasks: [], pheromones: [], rateLimited: false };
|
|
288
|
+
} catch (e: any) {
|
|
289
|
+
const errStr = e.stderr?.toString() || String(e);
|
|
290
|
+
ant.status = "failed";
|
|
291
|
+
ant.finishedAt = Date.now();
|
|
292
|
+
nest.updateAnt(ant);
|
|
293
|
+
nest.updateTaskStatus(task.id, "failed", undefined, errStr.slice(0, 500));
|
|
294
|
+
return { ant, output: errStr, newTasks: [], pheromones: [], rateLimited: false };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
253
298
|
/**
|
|
254
299
|
* 孵化并运行一只蚂蚁 — SDK 内嵌版
|
|
255
300
|
*/
|
|
@@ -316,7 +361,7 @@ export async function spawnAnt(
|
|
|
316
361
|
});
|
|
317
362
|
|
|
318
363
|
// 订阅实时事件
|
|
319
|
-
session.subscribe((event) => {
|
|
364
|
+
session.subscribe((event: any) => {
|
|
320
365
|
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
321
366
|
const delta = event.assistantMessageEvent.delta;
|
|
322
367
|
accumulatedText += delta;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
// ═══ 蚂蚁角色 ═══
|
|
6
|
-
export type AntCaste = "scout" | "worker" | "soldier";
|
|
6
|
+
export type AntCaste = "scout" | "worker" | "soldier" | "drone";
|
|
7
7
|
|
|
8
8
|
export interface AntConfig {
|
|
9
9
|
caste: AntCaste;
|
|
@@ -17,6 +17,7 @@ export const DEFAULT_ANT_CONFIGS: Record<AntCaste, Omit<AntConfig, "systemPrompt
|
|
|
17
17
|
scout: { caste: "scout", model: "", tools: ["read", "bash", "grep", "find", "ls"], maxTurns: 8 },
|
|
18
18
|
worker: { caste: "worker", model: "", tools: ["read", "bash", "edit", "write", "grep", "find", "ls"], maxTurns: 15 },
|
|
19
19
|
soldier: { caste: "soldier", model: "", tools: ["read", "bash", "grep", "find", "ls"], maxTurns: 8 },
|
|
20
|
+
drone: { caste: "drone", model: "", tools: ["bash"], maxTurns: 1 },
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
/** Per-caste model overrides from user config */
|