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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-pi",
3
- "version": "0.1.54",
3
+ "version": "0.1.56",
4
4
  "description": "One-click setup for pi-coding-agent. Like oh-my-zsh for pi.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 = await spawnAnt(cwd, nest, task, config, signal, callbacks.onAntStream, opts.authStorage, opts.modelRegistry);
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
- t.files.some(f => sub.files.includes(f))
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
- if (!t.files.some(f => activeFiles.has(f))) {
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 */