oh-pi 0.1.70 → 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 (36) 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 +25 -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/queen.ts +12 -11
  27. package/pi-package/extensions/ant-colony/spawner.test.ts +44 -0
  28. package/pi-package/extensions/ant-colony/spawner.ts +2 -0
  29. package/pi-package/extensions/ant-colony/types.test.ts +36 -0
  30. package/pi-package/extensions/ant-colony/ui.test.ts +66 -0
  31. package/pi-package/extensions/auto-update.test.ts +15 -0
  32. package/pi-package/extensions/auto-update.ts +1 -1
  33. package/pi-package/extensions/safe-guard.test.ts +26 -0
  34. package/pi-package/extensions/safe-guard.ts +2 -2
  35. package/pi-package/extensions/smart-compact.test.ts +64 -0
  36. package/pi-package/extensions/smart-compact.ts +2 -2
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { t, setLocale, getLocale } from "./i18n.js";
3
+ afterEach(() => setLocale("en"));
4
+ describe("t", () => {
5
+ it("returns known en key", () => {
6
+ expect(t("welcome.title")).toContain("oh-pi");
7
+ });
8
+ it("interpolates vars", () => {
9
+ const result = t("welcome.piDetected", { version: "1.0" });
10
+ expect(result).toContain("1.0");
11
+ });
12
+ it("falls back to en when zh key missing", () => {
13
+ setLocale("zh");
14
+ const enVal = t("welcome.title");
15
+ expect(enVal).toBeTruthy();
16
+ expect(enVal).not.toBe("welcome.title");
17
+ });
18
+ it("falls back to key string when key missing in all locales", () => {
19
+ expect(t("nonexistent.key.xyz")).toBe("nonexistent.key.xyz");
20
+ });
21
+ it("setLocale/getLocale round-trip en", () => {
22
+ setLocale("en");
23
+ expect(getLocale()).toBe("en");
24
+ });
25
+ it("setLocale/getLocale round-trip zh", () => {
26
+ setLocale("zh");
27
+ expect(getLocale()).toBe("zh");
28
+ });
29
+ it("setLocale/getLocale round-trip fr", () => {
30
+ setLocale("fr");
31
+ expect(getLocale()).toBe("fr");
32
+ });
33
+ it("returns zh translation after setLocale zh", () => {
34
+ const enResult = t("welcome.title");
35
+ setLocale("zh");
36
+ const zhResult = t("welcome.title");
37
+ expect(zhResult).toContain("oh-pi");
38
+ expect(zhResult).not.toBe(enResult);
39
+ });
40
+ it("interpolates multiple vars", () => {
41
+ const result = t("welcome.envInfo", { terminal: "xterm", os: "linux", node: "v20" });
42
+ expect(result).toContain("xterm");
43
+ expect(result).toContain("linux");
44
+ });
45
+ it("returns string without vars unchanged", () => {
46
+ expect(typeof t("cancelled")).toBe("string");
47
+ });
48
+ it("does not crash with empty vars", () => {
49
+ expect(t("welcome.title", {})).toContain("oh-pi");
50
+ });
51
+ it("fr locale returns fr translation", () => {
52
+ setLocale("fr");
53
+ const result = t("welcome.title");
54
+ expect(result).toContain("oh-pi");
55
+ });
56
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { MODEL_CAPABILITIES, PROVIDERS, THEMES, EXTENSIONS, KEYBINDING_SCHEMES } from "./registry.js";
3
+ describe("MODEL_CAPABILITIES", () => {
4
+ it("has entries", () => {
5
+ expect(Object.keys(MODEL_CAPABILITIES).length).toBeGreaterThan(0);
6
+ });
7
+ it("each entry has required fields", () => {
8
+ for (const [, cap] of Object.entries(MODEL_CAPABILITIES)) {
9
+ expect(cap).toHaveProperty("contextWindow");
10
+ expect(cap).toHaveProperty("maxTokens");
11
+ expect(cap).toHaveProperty("reasoning");
12
+ expect(cap).toHaveProperty("input");
13
+ expect(cap.contextWindow).toBeGreaterThan(0);
14
+ expect(cap.maxTokens).toBeGreaterThan(0);
15
+ }
16
+ });
17
+ });
18
+ describe("PROVIDERS", () => {
19
+ const expected = ["anthropic", "openai", "google", "groq", "openrouter", "xai", "mistral"];
20
+ it("has 7 providers", () => {
21
+ expect(Object.keys(PROVIDERS)).toHaveLength(7);
22
+ });
23
+ it("contains all expected providers", () => {
24
+ for (const name of expected) {
25
+ expect(PROVIDERS).toHaveProperty(name);
26
+ }
27
+ });
28
+ it("each provider has env/label/models", () => {
29
+ for (const [, info] of Object.entries(PROVIDERS)) {
30
+ expect(info.env).toMatch(/_API_KEY$|_KEY$/);
31
+ expect(info.label).toBeTruthy();
32
+ expect(Array.isArray(info.models)).toBe(true);
33
+ }
34
+ });
35
+ });
36
+ describe("THEMES", () => {
37
+ it("is array with entries", () => {
38
+ expect(Array.isArray(THEMES)).toBe(true);
39
+ expect(THEMES.length).toBeGreaterThan(0);
40
+ });
41
+ it("each has name/label/style", () => {
42
+ for (const theme of THEMES) {
43
+ expect(theme).toHaveProperty("name");
44
+ expect(theme).toHaveProperty("label");
45
+ expect(theme).toHaveProperty("style");
46
+ }
47
+ });
48
+ });
49
+ describe("EXTENSIONS", () => {
50
+ it("is array with entries", () => {
51
+ expect(Array.isArray(EXTENSIONS)).toBe(true);
52
+ expect(EXTENSIONS.length).toBeGreaterThan(0);
53
+ });
54
+ it("each has name/label/default", () => {
55
+ for (const ext of EXTENSIONS) {
56
+ expect(ext).toHaveProperty("name");
57
+ expect(ext).toHaveProperty("label");
58
+ expect(ext).toHaveProperty("default");
59
+ }
60
+ });
61
+ });
62
+ describe("KEYBINDING_SCHEMES", () => {
63
+ it("has default/vim/emacs", () => {
64
+ expect(KEYBINDING_SCHEMES).toHaveProperty("default");
65
+ expect(KEYBINDING_SCHEMES).toHaveProperty("vim");
66
+ expect(KEYBINDING_SCHEMES).toHaveProperty("emacs");
67
+ });
68
+ });
@@ -1,5 +1,12 @@
1
1
  import type { OhPConfig } from "../types.js";
2
2
  import type { EnvInfo } from "../utils/detect.js";
3
+ /**
4
+ * 统计已有配置中指定目录下的文件数量。
5
+ * @param env - 环境信息
6
+ * @param dir - 目录名称前缀
7
+ * @returns 匹配的文件数
8
+ */
9
+ export declare function countExisting(env: EnvInfo, dir: string): number;
3
10
  /**
4
11
  * 展示配置摘要,处理已有配置的备份/覆盖,安装 pi(如需),并应用最终配置。
5
12
  * @param config - 用户选择的配置对象
@@ -8,7 +8,7 @@ import { applyConfig, installPi, backupConfig } from "../utils/install.js";
8
8
  * @param dir - 目录名称前缀
9
9
  * @returns 匹配的文件数
10
10
  */
11
- function countExisting(env, dir) {
11
+ export function countExisting(env, dir) {
12
12
  return env.existingFiles.filter(f => f.startsWith(dir + "/")).length;
13
13
  }
14
14
  /**
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { countExisting } from "./confirm-apply.js";
3
+ const mkEnv = (files) => ({ existingFiles: files });
4
+ describe("countExisting", () => {
5
+ it("counts files matching prefix", () => {
6
+ expect(countExisting(mkEnv(["extensions/a", "extensions/b", "prompts/c"]), "extensions")).toBe(2);
7
+ });
8
+ it("returns 0 when no match", () => {
9
+ expect(countExisting(mkEnv(["extensions/a"]), "themes")).toBe(0);
10
+ });
11
+ it("returns 0 for empty existingFiles", () => {
12
+ expect(countExisting(mkEnv([]), "extensions")).toBe(0);
13
+ });
14
+ it("counts different prefix", () => {
15
+ expect(countExisting(mkEnv(["extensions/a", "extensions/b", "prompts/c"]), "prompts")).toBe(1);
16
+ });
17
+ it("does not match without slash separator", () => {
18
+ expect(countExisting(mkEnv(["extensionsX"]), "extensions")).toBe(0);
19
+ });
20
+ });
@@ -1,5 +1,7 @@
1
1
  import type { ProviderConfig } from "../types.js";
2
2
  import type { EnvInfo } from "../utils/detect.js";
3
+ /** Block internal/private IPs to prevent SSRF */
4
+ export declare function isUnsafeUrl(urlStr: string): boolean;
3
5
  /**
4
6
  * Interactively configure API providers, detecting existing keys, allowing multi-select, and supporting custom endpoints.
5
7
  * @param env - Current environment info with detected providers
@@ -13,7 +13,7 @@ const PROVIDER_API_URLS = {
13
13
  mistral: "https://api.mistral.ai",
14
14
  };
15
15
  /** Block internal/private IPs to prevent SSRF */
16
- function isUnsafeUrl(urlStr) {
16
+ export function isUnsafeUrl(urlStr) {
17
17
  try {
18
18
  const u = new URL(urlStr);
19
19
  const host = u.hostname;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isUnsafeUrl } from "./provider-setup.js";
3
+ describe("isUnsafeUrl", () => {
4
+ it("https remote is safe", () => {
5
+ expect(isUnsafeUrl("https://api.example.com")).toBe(false);
6
+ });
7
+ it("http localhost is safe", () => {
8
+ expect(isUnsafeUrl("http://localhost:11434")).toBe(false);
9
+ });
10
+ it("http 127.0.0.1 is safe", () => {
11
+ expect(isUnsafeUrl("http://127.0.0.1:8080")).toBe(false);
12
+ });
13
+ it("http [::1] is blocked (contains colon in hostname)", () => {
14
+ expect(isUnsafeUrl("http://[::1]:8080")).toBe(true);
15
+ });
16
+ it("10.x private is unsafe", () => {
17
+ expect(isUnsafeUrl("https://10.0.0.1/api")).toBe(true);
18
+ });
19
+ it("172.16.x private is unsafe", () => {
20
+ expect(isUnsafeUrl("https://172.16.0.1/api")).toBe(true);
21
+ });
22
+ it("192.168.x private is unsafe", () => {
23
+ expect(isUnsafeUrl("https://192.168.1.1/api")).toBe(true);
24
+ });
25
+ it("http remote is unsafe", () => {
26
+ expect(isUnsafeUrl("http://api.example.com")).toBe(true);
27
+ });
28
+ it("0.0.0.0 is unsafe", () => {
29
+ expect(isUnsafeUrl("https://0.0.0.0")).toBe(true);
30
+ });
31
+ it("169.254.x link-local is unsafe", () => {
32
+ expect(isUnsafeUrl("https://169.254.1.1")).toBe(true);
33
+ });
34
+ it("invalid url is unsafe", () => {
35
+ expect(isUnsafeUrl("not-a-url")).toBe(true);
36
+ });
37
+ it("empty string is unsafe", () => {
38
+ expect(isUnsafeUrl("")).toBe(true);
39
+ });
40
+ });
@@ -4,3 +4,9 @@ import type { EnvInfo } from "../utils/detect.js";
4
4
  * @param {EnvInfo} env - 当前检测到的环境信息
5
5
  */
6
6
  export declare function welcome(env: EnvInfo): void;
7
+ /**
8
+ * 按顶层目录分类统计文件数量,返回格式化字符串。
9
+ * @param {string[]} files - 文件相对路径列表
10
+ * @returns {string} 分类统计字符串,如 "extensions (3) prompts (5)"
11
+ */
12
+ export declare function categorize(files: string[]): string;
@@ -28,7 +28,7 @@ export function welcome(env) {
28
28
  * @param {string[]} files - 文件相对路径列表
29
29
  * @returns {string} 分类统计字符串,如 "extensions (3) prompts (5)"
30
30
  */
31
- function categorize(files) {
31
+ export function categorize(files) {
32
32
  const cats = {};
33
33
  for (const f of files) {
34
34
  const cat = f.includes("/") ? f.split("/")[0] : f;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { categorize } from "./welcome.js";
3
+ describe("categorize", () => {
4
+ it("groups by directory", () => {
5
+ const result = categorize(["ext/a.ts", "ext/b.ts", "prompts/c.md"]);
6
+ expect(result).toContain("ext (2)");
7
+ expect(result).toContain("prompts (1)");
8
+ });
9
+ it("empty array returns empty string", () => {
10
+ expect(categorize([])).toBe("");
11
+ });
12
+ it("files without directory use filename", () => {
13
+ const result = categorize(["a.ts", "b.ts"]);
14
+ expect(result).toContain("a.ts (1)");
15
+ expect(result).toContain("b.ts (1)");
16
+ });
17
+ it("single file", () => {
18
+ expect(categorize(["foo/bar.ts"])).toBe("foo (1)");
19
+ });
20
+ it("mixed files with and without directory", () => {
21
+ const result = categorize(["a.ts", "dir/b.ts"]);
22
+ expect(result).toContain("a.ts (1)");
23
+ expect(result).toContain("dir (1)");
24
+ });
25
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { resources } from "./resources.js";
3
+ describe("resources", () => {
4
+ it("agent returns correct path", () => {
5
+ const p = resources.agent("foo");
6
+ expect(p).toContain("agents/foo.md");
7
+ expect(p.startsWith("/")).toBe(true);
8
+ });
9
+ it("extension returns correct path", () => {
10
+ const p = resources.extension("bar");
11
+ expect(p).toContain("extensions/bar");
12
+ expect(p.startsWith("/")).toBe(true);
13
+ });
14
+ it("extensionFile returns correct path", () => {
15
+ const p = resources.extensionFile("baz");
16
+ expect(p).toContain("extensions/baz.ts");
17
+ expect(p.startsWith("/")).toBe(true);
18
+ });
19
+ it("prompt returns correct path", () => {
20
+ const p = resources.prompt("test");
21
+ expect(p).toContain("prompts/test.md");
22
+ expect(p.startsWith("/")).toBe(true);
23
+ });
24
+ it("skill returns correct path", () => {
25
+ const p = resources.skill("sk");
26
+ expect(p).toContain("skills/sk");
27
+ expect(p.startsWith("/")).toBe(true);
28
+ });
29
+ it("skillsDir returns correct path", () => {
30
+ const p = resources.skillsDir();
31
+ expect(p).toContain("skills");
32
+ expect(p.startsWith("/")).toBe(true);
33
+ });
34
+ it("theme returns correct path", () => {
35
+ const p = resources.theme("dark");
36
+ expect(p).toContain("themes/dark.json");
37
+ expect(p.startsWith("/")).toBe(true);
38
+ });
39
+ });
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "oh-pi",
3
- "version": "0.1.70",
3
+ "version": "0.1.71",
4
4
  "description": "One-click setup for pi-coding-agent. Like oh-my-zsh for pi.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "oh-pi": "./dist/bin/oh-pi.js"
7
+ "oh-pi": "dist/bin/oh-pi.js"
8
8
  },
9
9
  "main": "./dist/index.js",
10
10
  "files": [
@@ -16,6 +16,7 @@
16
16
  "build": "tsc",
17
17
  "dev": "tsc --watch",
18
18
  "start": "node dist/bin/oh-pi.js",
19
+ "test": "vitest run",
19
20
  "prepublishOnly": "npm run build"
20
21
  },
21
22
  "keywords": [
@@ -46,7 +47,8 @@
46
47
  },
47
48
  "devDependencies": {
48
49
  "@types/node": "^22.0.0",
49
- "typescript": "^5.7.0"
50
+ "typescript": "^5.7.0",
51
+ "vitest": "^3.0.0"
50
52
  },
51
53
  "engines": {
52
54
  "node": ">=20.0.0"
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { defaultConcurrency, adapt } from "./concurrency.js";
3
+ import type { ConcurrencyConfig, ConcurrencySample } from "./types.js";
4
+
5
+ const mkSample = (o: Partial<ConcurrencySample> = {}): ConcurrencySample => ({
6
+ timestamp: Date.now(), concurrency: 2, cpuLoad: 0.3, memFree: 4e9, throughput: 1, ...o,
7
+ });
8
+
9
+ describe("defaultConcurrency", () => {
10
+ it("returns valid config", () => {
11
+ const c = defaultConcurrency();
12
+ expect(c.current).toBe(2);
13
+ expect(c.min).toBe(1);
14
+ expect(c.max).toBeGreaterThanOrEqual(1);
15
+ expect(c.max).toBeLessThanOrEqual(8);
16
+ expect(c.history).toEqual([]);
17
+ });
18
+ });
19
+
20
+ describe("adapt", () => {
21
+ it("drops to min when no pending tasks", () => {
22
+ const cfg: ConcurrencyConfig = { current: 4, min: 1, max: 8, optimal: 3, history: [mkSample(), mkSample()] };
23
+ expect(adapt(cfg, 0).current).toBe(1);
24
+ });
25
+
26
+ it("cold start gives half max", () => {
27
+ const cfg: ConcurrencyConfig = { current: 2, min: 1, max: 8, optimal: 3, history: [mkSample()] };
28
+ expect(adapt(cfg, 10).current).toBe(4);
29
+ });
30
+
31
+ it("reduces when CPU > 85%", () => {
32
+ const s = mkSample({ cpuLoad: 0.9 });
33
+ const cfg: ConcurrencyConfig = { current: 4, min: 1, max: 8, optimal: 3, history: [s, s, s] };
34
+ expect(adapt(cfg, 10).current).toBeLessThan(4);
35
+ });
36
+
37
+ it("reduces when memory low", () => {
38
+ const s = mkSample({ memFree: 100 * 1024 * 1024 });
39
+ const cfg: ConcurrencyConfig = { current: 4, min: 1, max: 8, optimal: 3, history: [s, s] };
40
+ expect(adapt(cfg, 10).current).toBeLessThan(4);
41
+ });
42
+
43
+ it("increases during exploration when throughput rising", () => {
44
+ const s1 = mkSample({ throughput: 1 });
45
+ const s2 = mkSample({ throughput: 2 });
46
+ const cfg: ConcurrencyConfig = { current: 3, min: 1, max: 8, optimal: 3, history: [s1, s2] };
47
+ expect(adapt(cfg, 10).current).toBeGreaterThanOrEqual(3);
48
+ });
49
+
50
+ it("does not exceed max", () => {
51
+ const s1 = mkSample({ throughput: 1 });
52
+ const s2 = mkSample({ throughput: 5 });
53
+ const cfg: ConcurrencyConfig = { current: 8, min: 1, max: 8, optimal: 3, history: [s1, s2] };
54
+ expect(adapt(cfg, 100).current).toBeLessThanOrEqual(8);
55
+ });
56
+
57
+ it("does not exceed pending task count", () => {
58
+ const s1 = mkSample({ throughput: 1 });
59
+ const s2 = mkSample({ throughput: 5 });
60
+ const cfg: ConcurrencyConfig = { current: 3, min: 1, max: 8, optimal: 3, history: [s1, s2] };
61
+ expect(adapt(cfg, 2).current).toBeLessThanOrEqual(2);
62
+ });
63
+
64
+ it("respects rate limit cooldown", () => {
65
+ const s1 = mkSample({ throughput: 1 });
66
+ const s2 = mkSample({ throughput: 2 });
67
+ const cfg: ConcurrencyConfig = { current: 3, min: 1, max: 8, optimal: 3, history: [s1, s2], lastRateLimitAt: Date.now() };
68
+ expect(adapt(cfg, 10).current).toBeLessThanOrEqual(3);
69
+ });
70
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildImportGraph, dependencyDepth, taskDependsOn } from "./deps.js";
3
+ import type { ImportGraph } from "./deps.js";
4
+
5
+ describe("buildImportGraph", () => {
6
+ it("returns empty graph for empty files", () => {
7
+ const graph = buildImportGraph([], "/tmp");
8
+ expect(graph.imports.size).toBe(0);
9
+ expect(graph.importedBy.size).toBe(0);
10
+ });
11
+
12
+ it("returns empty graph for nonexistent files", () => {
13
+ const graph = buildImportGraph(["nonexistent.ts"], "/tmp");
14
+ expect(graph.imports.size).toBe(0);
15
+ });
16
+ });
17
+
18
+ describe("dependencyDepth", () => {
19
+ it("returns 0 for file with no dependents", () => {
20
+ const graph: ImportGraph = { imports: new Map(), importedBy: new Map() };
21
+ expect(dependencyDepth("a.ts", graph)).toBe(0);
22
+ });
23
+
24
+ it("counts direct dependents", () => {
25
+ const graph: ImportGraph = {
26
+ imports: new Map([["b.ts", new Set(["a.ts"])]]),
27
+ importedBy: new Map([["a.ts", new Set(["b.ts"])]]),
28
+ };
29
+ expect(dependencyDepth("a.ts", graph)).toBe(1);
30
+ });
31
+
32
+ it("counts transitive dependents", () => {
33
+ const graph: ImportGraph = {
34
+ imports: new Map([["b.ts", new Set(["a.ts"])], ["c.ts", new Set(["b.ts"])]]),
35
+ importedBy: new Map([["a.ts", new Set(["b.ts"])], ["b.ts", new Set(["c.ts"])]]),
36
+ };
37
+ expect(dependencyDepth("a.ts", graph)).toBe(2);
38
+ });
39
+ });
40
+
41
+ describe("taskDependsOn", () => {
42
+ it("returns true when taskA imports taskB file", () => {
43
+ const graph: ImportGraph = {
44
+ imports: new Map([["a.ts", new Set(["b.ts"])]]),
45
+ importedBy: new Map([["b.ts", new Set(["a.ts"])]]),
46
+ };
47
+ expect(taskDependsOn(["a.ts"], ["b.ts"], graph)).toBe(true);
48
+ });
49
+
50
+ it("returns false when no dependency", () => {
51
+ const graph: ImportGraph = {
52
+ imports: new Map([["a.ts", new Set(["c.ts"])]]),
53
+ importedBy: new Map(),
54
+ };
55
+ expect(taskDependsOn(["a.ts"], ["b.ts"], graph)).toBe(false);
56
+ });
57
+
58
+ it("returns false for empty file lists", () => {
59
+ const graph: ImportGraph = { imports: new Map(), importedBy: new Map() };
60
+ expect(taskDependsOn([], [], graph)).toBe(false);
61
+ });
62
+ });
@@ -51,6 +51,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
51
51
 
52
52
  // 当前运行中的后台蚁群(同时只允许一个)
53
53
  let activeColony: BackgroundColony | null = null;
54
+ let uiListenersRegistered = false;
54
55
 
55
56
  // ─── Status 渲染 ───
56
57
 
@@ -64,6 +65,9 @@ export default function antColonyExtension(pi: ExtensionAPI) {
64
65
 
65
66
  // 监听事件来更新 UI(确保在有 ctx 的上下文中)
66
67
  pi.on("session_start", async (_event, ctx) => {
68
+ if (uiListenersRegistered) return;
69
+ uiListenersRegistered = true;
70
+
67
71
  pi.events.on("ant-colony:render", () => {
68
72
  if (!activeColony) return;
69
73
  const { state } = activeColony;
@@ -24,6 +24,7 @@ export class Nest {
24
24
  private stateCache: ColonyState | null = null;
25
25
  private gcCounter: number = 0;
26
26
  private pheromoneByFile: Map<string, Pheromone[]> = new Map();
27
+ private pheromoneIndexDirty: boolean = true;
27
28
 
28
29
  constructor(private cwd: string, private colonyId: string) {
29
30
  this.dir = path.join(cwd, ".ant-colony", colonyId);
@@ -53,6 +54,14 @@ export class Nest {
53
54
  return base;
54
55
  }
55
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
+
56
65
  updateState(patch: Partial<Pick<ColonyState, "status" | "concurrency" | "metrics" | "ants" | "finishedAt">>): void {
57
66
  this.withStateLock(() => {
58
67
  if (!this.stateCache) {
@@ -158,43 +167,11 @@ export class Nest {
158
167
  }
159
168
  }
160
169
 
161
- /** 获取下一个可领取的任务(按优先级 + 信息素强度 - repellent负信息素排序,ε-greedy 随机觅食) */
162
- nextPendingTask(caste: "scout" | "worker" | "soldier"): Task | null {
163
- const tasks = this.getAllTasks()
164
- .filter(t => t.status === "pending" && t.caste === caste);
165
- if (tasks.length === 0) return null;
166
-
167
- // ε-greedy:10% 概率随机选任务,避免蚂蚁全挤同一条路
168
- if (tasks.length > 1 && Math.random() < 0.1) {
169
- return tasks[Math.floor(Math.random() * tasks.length)];
170
- }
171
-
172
- // 信息素加权:用索引查询而非全量扫描
173
- this.getAllPheromones(); // 确保索引已建立
174
- const scored = tasks.map(t => {
175
- let pScore = 0;
176
- const seen = new Set<Pheromone>();
177
- for (const f of t.files) {
178
- const related = this.pheromoneByFile.get(f);
179
- if (!related) continue;
180
- for (const p of related) {
181
- if (seen.has(p) || p.strength <= 0.1) continue;
182
- seen.add(p);
183
- if (p.type === "discovery" || p.type === "completion") pScore += p.strength;
184
- else if (p.type === "repellent") pScore -= p.strength * 3;
185
- else if (p.type === "warning") pScore -= p.strength;
186
- }
187
- }
188
- return { task: t, score: (6 - t.priority) + pScore };
189
- });
190
- scored.sort((a, b) => b.score - a.score);
191
- return scored[0]?.task ?? null;
192
- }
193
-
194
170
  // ═══ Pheromones ═══
195
171
 
196
172
  dropPheromone(p: Pheromone): void {
197
173
  fs.appendFileSync(this.pheromoneFile, JSON.stringify(p) + "\n");
174
+ this.pheromoneIndexDirty = true;
198
175
  }
199
176
 
200
177
  getAllPheromones(): Pheromone[] {
@@ -223,15 +200,19 @@ export class Nest {
223
200
  return p.strength > 0.05;
224
201
  });
225
202
  const hadGarbage = this.pheromoneCache.length < beforeLen;
226
-
227
- // 重建文件索引
228
- this.pheromoneByFile.clear();
229
- for (const p of this.pheromoneCache) {
230
- for (const f of p.files) {
231
- let arr = this.pheromoneByFile.get(f);
232
- if (!arr) { arr = []; this.pheromoneByFile.set(f, arr); }
233
- arr.push(p);
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
+ }
234
214
  }
215
+ this.pheromoneIndexDirty = false;
235
216
  }
236
217
 
237
218
  // GC:每 10 次调用,若有弱条目被过滤则重写文件
@@ -298,7 +279,7 @@ export class Nest {
298
279
 
299
280
  private withStateLock<T>(fn: () => T): T {
300
281
  const MAX_WAIT = 3000;
301
- const SPIN_MS = 1;
282
+ const SPIN_MS = 5;
302
283
  const start = Date.now();
303
284
  while (true) {
304
285
  try {
@@ -319,8 +300,8 @@ export class Nest {
319
300
  // 进程存活且锁未过期,放弃等待
320
301
  throw new Error(`[Nest] withStateLock timeout after ${MAX_WAIT}ms`);
321
302
  }
322
- // 简单 busy-wait,避免 SharedArrayBuffer 依赖
323
- 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;
324
305
  while (Date.now() < until) { /* spin */ }
325
306
  }
326
307
  }
@@ -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
+ });
@@ -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";
@@ -120,8 +120,8 @@ function makeReviewTask(completedTasks: Task[]): Task {
120
120
  }
121
121
 
122
122
  function updateMetrics(nest: Nest): ColonyMetrics {
123
- const tasks = nest.getAllTasks();
124
- const state = nest.getState();
123
+ const state = nest.getStateLight();
124
+ const tasks = state.tasks;
125
125
  const now = Date.now();
126
126
  const elapsed = (now - state.metrics.startTime) / 60000; // minutes
127
127
 
@@ -172,7 +172,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
172
172
 
173
173
  const runOne = async (): Promise<"done" | "empty" | "rate_limited" | "budget"> => {
174
174
  // Budget 刹车:预算用完就不出发(drone 免费,不检查)
175
- const state = nest.getState();
175
+ const state = nest.getStateLight();
176
176
  if (state.maxCost != null && caste !== "drone") {
177
177
  const spent = state.ants.reduce((s, a) => s + a.usage.cost, 0);
178
178
  if (spent >= state.maxCost) return "budget";
@@ -211,7 +211,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
211
211
  }
212
212
 
213
213
  // 成本预警:超 80% 预算时发信号
214
- const curState = nest.getState();
214
+ const curState = nest.getStateLight();
215
215
  if (curState.maxCost != null) {
216
216
  const spent = curState.ants.reduce((s, a) => s + a.usage.cost, 0);
217
217
  if (spent >= curState.maxCost * 0.8) {
@@ -282,7 +282,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
282
282
  // 调度循环:持续派蚂蚁直到没有待处理任务
283
283
  let lastSampleTime = 0;
284
284
  while (!signal?.aborted) {
285
- const state = nest.getState();
285
+ const state = nest.getStateLight();
286
286
  const pending = state.tasks.filter(t => t.status === "pending" && t.caste === caste);
287
287
  if (pending.length === 0) break;
288
288
 
@@ -347,7 +347,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
347
347
  // 429 处理:降低并发 + 渐进退避(2s → 5s → 10s,上限 10s)+ 记录时间戳
348
348
  if (results.includes("rate_limited")) {
349
349
  consecutiveRateLimits++;
350
- const cur = nest.getState().concurrency;
350
+ const cur = nest.getStateLight().concurrency;
351
351
  const reduced = Math.max(cur.min, cur.current - 1); // 每次只减 1,不砍半
352
352
  nest.updateState({ concurrency: { ...cur, current: reduced, lastRateLimitAt: Date.now() } });
353
353
  backoffMs = Math.min(consecutiveRateLimits * 2000, 10000);
@@ -366,6 +366,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
366
366
  if (!opts.goal || !opts.goal.trim()) {
367
367
  throw new Error("Colony goal is empty or undefined. Please provide a clear goal.");
368
368
  }
369
+ resetAntCounter();
369
370
  const colonyId = makeColonyId();
370
371
  const nest = new Nest(opts.cwd, colonyId);
371
372
 
@@ -405,7 +406,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
405
406
  };
406
407
 
407
408
  const emitSignal = (phase: ColonyState["status"], message: string) => {
408
- const state = nest.getState();
409
+ const state = nest.getStateLight();
409
410
  const m = state.metrics;
410
411
  const active = state.ants.filter(a => a.status === "working").length;
411
412
  const progress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
@@ -516,8 +517,8 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
516
517
  const discoveries = nest.getAllPheromones().filter(p => p.type === "discovery");
517
518
  const allDone = nest.getAllTasks().filter(t => t.status === "done");
518
519
  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)) {
520
+ const spent = nest.getStateLight().ants.reduce((s, a) => s + a.usage.cost, 0);
521
+ if (spent < (nest.getStateLight().maxCost ?? Infinity)) {
521
522
  callbacks.onPhase?.("scouting", "Re-exploring based on new discoveries...");
522
523
  emitSignal("scouting", "Re-exploring...");
523
524
  await runAntWave({ ...waveBase, caste: "scout" });
@@ -606,7 +607,7 @@ export async function resumeColony(opts: QueenOptions): Promise<ColonyState> {
606
607
  const { signal, callbacks } = opts;
607
608
 
608
609
  const emitSignal = (phase: ColonyState["status"], message: string) => {
609
- const state = nest.getState();
610
+ const state = nest.getStateLight();
610
611
  const m = state.metrics;
611
612
  const active = state.ants.filter(a => a.status === "working").length;
612
613
  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
  }
@@ -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
+ });
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatDuration, formatCost, formatTokens, statusIcon, casteIcon, buildReport } from "./ui.js";
3
+ import type { ColonyState } from "./types.js";
4
+
5
+ describe("formatDuration", () => {
6
+ it("0ms", () => expect(formatDuration(0)).toBe("0s"));
7
+ it("5000ms", () => expect(formatDuration(5000)).toBe("5s"));
8
+ it("59000ms", () => expect(formatDuration(59000)).toBe("59s"));
9
+ it("60000ms", () => expect(formatDuration(60000)).toBe("1m0s"));
10
+ it("90000ms", () => expect(formatDuration(90000)).toBe("1m30s"));
11
+ });
12
+
13
+ describe("formatCost", () => {
14
+ it("0.001", () => expect(formatCost(0.001)).toBe("$0.0010"));
15
+ it("0.009", () => expect(formatCost(0.009)).toBe("$0.0090"));
16
+ it("0.01", () => expect(formatCost(0.01)).toBe("$0.01"));
17
+ it("1.5", () => expect(formatCost(1.5)).toBe("$1.50"));
18
+ });
19
+
20
+ describe("formatTokens", () => {
21
+ it("500", () => expect(formatTokens(500)).toBe("500"));
22
+ it("999", () => expect(formatTokens(999)).toBe("999"));
23
+ it("1500", () => expect(formatTokens(1500)).toBe("1.5k"));
24
+ it("1500000", () => expect(formatTokens(1500000)).toBe("1.5M"));
25
+ });
26
+
27
+ describe("statusIcon", () => {
28
+ it("scouting", () => expect(statusIcon("scouting")).toBe("🔍"));
29
+ it("working", () => expect(statusIcon("working")).toBe("⚒️"));
30
+ it("reviewing", () => expect(statusIcon("reviewing")).toBe("🛡️"));
31
+ it("done", () => expect(statusIcon("done")).toBe("✅"));
32
+ it("failed", () => expect(statusIcon("failed")).toBe("❌"));
33
+ it("budget_exceeded", () => expect(statusIcon("budget_exceeded")).toBe("💰"));
34
+ it("unknown", () => expect(statusIcon("xyz")).toBe("🐜"));
35
+ });
36
+
37
+ describe("casteIcon", () => {
38
+ it("scout", () => expect(casteIcon("scout")).toBe("🔍"));
39
+ it("soldier", () => expect(casteIcon("soldier")).toBe("🛡️"));
40
+ it("drone", () => expect(casteIcon("drone")).toBe("⚙️"));
41
+ it("worker", () => expect(casteIcon("worker")).toBe("⚒️"));
42
+ it("unknown", () => expect(casteIcon("xyz")).toBe("⚒️"));
43
+ });
44
+
45
+ describe("buildReport", () => {
46
+ it("builds report with goal, status, cost, tasks", () => {
47
+ const state: ColonyState = {
48
+ id: "c-1", goal: "Test goal", status: "done",
49
+ tasks: [
50
+ { id: "t1", parentId: null, title: "Task A", description: "", caste: "worker", status: "done", priority: 3, files: [], claimedBy: null, result: null, error: null, spawnedTasks: [], createdAt: 0, startedAt: 0, finishedAt: 1000 },
51
+ { id: "t2", parentId: null, title: "Task B", description: "", caste: "worker", status: "failed", priority: 3, files: [], claimedBy: null, result: null, error: "some error", spawnedTasks: [], createdAt: 0, startedAt: 0, finishedAt: 1000 },
52
+ ],
53
+ ants: [], pheromones: [],
54
+ concurrency: { current: 2, min: 1, max: 4, optimal: 3, history: [] },
55
+ metrics: { tasksTotal: 2, tasksDone: 1, tasksFailed: 1, antsSpawned: 2, totalCost: 0.05, totalTokens: 1000, startTime: 0, throughputHistory: [] },
56
+ maxCost: null, modelOverrides: {}, createdAt: 0, finishedAt: 5000,
57
+ };
58
+ const report = buildReport(state);
59
+ expect(report).toContain("Test goal");
60
+ expect(report).toContain("✅");
61
+ expect(report).toContain("$0.05");
62
+ expect(report).toContain("Task A");
63
+ expect(report).toContain("Task B");
64
+ expect(report).toContain("some error");
65
+ });
66
+ });
@@ -0,0 +1,15 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ vi.mock("@mariozechner/pi-coding-agent", () => ({}));
4
+
5
+ import { isNewer } from "./auto-update";
6
+
7
+ describe("isNewer", () => {
8
+ it("patch newer", () => expect(isNewer("1.0.1", "1.0.0")).toBe(true));
9
+ it("minor newer", () => expect(isNewer("1.1.0", "1.0.0")).toBe(true));
10
+ it("major newer", () => expect(isNewer("2.0.0", "1.9.9")).toBe(true));
11
+ it("equal", () => expect(isNewer("1.0.0", "1.0.0")).toBe(false));
12
+ it("older", () => expect(isNewer("1.0.0", "1.0.1")).toBe(false));
13
+ it("0.1.70 > 0.1.69", () => expect(isNewer("0.1.70", "0.1.69")).toBe(true));
14
+ it("0.1.69 < 0.1.70", () => expect(isNewer("0.1.69", "0.1.70")).toBe(false));
15
+ });
@@ -39,7 +39,7 @@ function getCurrentVersion(): string | null {
39
39
  } catch { return null; }
40
40
  }
41
41
 
42
- function isNewer(latest: string, current: string): boolean {
42
+ export function isNewer(latest: string, current: string): boolean {
43
43
  const a = latest.split(".").map(Number);
44
44
  const b = current.split(".").map(Number);
45
45
  for (let i = 0; i < 3; i++) {
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { DANGEROUS_PATTERNS, PROTECTED_PATHS } from "./safe-guard";
3
+
4
+ describe("DANGEROUS_PATTERNS", () => {
5
+ const matches = (cmd: string) => DANGEROUS_PATTERNS.some((p) => p.test(cmd));
6
+
7
+ it("matches rm -rf /", () => expect(matches("rm -rf /")).toBe(true));
8
+ it("matches rm -f file.txt", () => expect(matches("rm -f file.txt")).toBe(true));
9
+ it("matches rm --force file", () => expect(matches("rm --force file")).toBe(true));
10
+ it("matches sudo rm file", () => expect(matches("sudo rm file")).toBe(true));
11
+ it("matches DROP TABLE users", () => expect(matches("DROP TABLE users")).toBe(true));
12
+ it("matches TRUNCATE TABLE foo", () => expect(matches("TRUNCATE TABLE foo")).toBe(true));
13
+ it("matches DELETE FROM users", () => expect(matches("DELETE FROM users")).toBe(true));
14
+ it("matches chmod 777 /tmp", () => expect(matches("chmod 777 /tmp")).toBe(true));
15
+ it("matches mkfs /dev/sda", () => expect(matches("mkfs /dev/sda")).toBe(true));
16
+ it("matches dd if=/dev/zero", () => expect(matches("dd if=/dev/zero")).toBe(true));
17
+ it("does not match ls -la", () => expect(matches("ls -la")).toBe(false));
18
+ it("does not match echo hello", () => expect(matches("echo hello")).toBe(false));
19
+ });
20
+
21
+ describe("PROTECTED_PATHS", () => {
22
+ it("contains all expected paths", () => {
23
+ expect(PROTECTED_PATHS).toEqual([".env", ".git/", "node_modules/", ".pi/", "id_rsa", ".ssh/"]);
24
+ });
25
+ it("has length 6", () => expect(PROTECTED_PATHS).toHaveLength(6));
26
+ });
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
7
7
 
8
- const DANGEROUS_PATTERNS = [
8
+ export const DANGEROUS_PATTERNS = [
9
9
  /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|.*-rf\b|.*--force\b)/,
10
10
  /\bsudo\s+rm\b/,
11
11
  /\b(DROP|TRUNCATE|DELETE\s+FROM)\b/i,
@@ -15,7 +15,7 @@ const DANGEROUS_PATTERNS = [
15
15
  />\s*\/dev\/sd[a-z]/,
16
16
  ];
17
17
 
18
- const PROTECTED_PATHS = [".env", ".git/", "node_modules/", ".pi/", "id_rsa", ".ssh/"];
18
+ export const PROTECTED_PATHS = [".env", ".git/", "node_modules/", ".pi/", "id_rsa", ".ssh/"];
19
19
 
20
20
  export default function (pi: ExtensionAPI) {
21
21
  pi.on("tool_call", async (event, ctx) => {
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { truncateText, compactContent } from "./smart-compact";
3
+
4
+ const longMultiline = (lines: number, lineLen = 100) =>
5
+ Array.from({ length: lines }, (_, i) => "x".repeat(lineLen) + i).join("\n");
6
+
7
+ describe("truncateText", () => {
8
+ it("short text returns unchanged", () => {
9
+ expect(truncateText("hello", 8000)).toBe("hello");
10
+ });
11
+
12
+ it("few lines but long chars returns unchanged", () => {
13
+ const text = "x".repeat(9000) + "\n" + "y".repeat(9000);
14
+ expect(truncateText(text, 8000)).toBe(text);
15
+ });
16
+
17
+ it("long text with many lines gets truncated", () => {
18
+ const text = longMultiline(200);
19
+ const result = truncateText(text, 8000);
20
+ expect(result).toContain("[...truncated");
21
+ });
22
+
23
+ it("preserves head and tail", () => {
24
+ const text = longMultiline(200);
25
+ const result = truncateText(text, 8000);
26
+ expect(result.startsWith(text.slice(0, 1500))).toBe(true);
27
+ expect(result.endsWith(text.slice(-2500))).toBe(true);
28
+ });
29
+
30
+ it("custom head/tail params work", () => {
31
+ const text = longMultiline(200);
32
+ const result = truncateText(text, 100, 50, 50);
33
+ expect(result).toContain("[...truncated");
34
+ expect(result.startsWith(text.slice(0, 50))).toBe(true);
35
+ expect(result.endsWith(text.slice(-50))).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe("compactContent", () => {
40
+ it("short string returns unchanged", () => {
41
+ expect(compactContent("short")).toBe("short");
42
+ });
43
+
44
+ it("long string gets truncated", () => {
45
+ const text = longMultiline(200);
46
+ expect(compactContent(text)).toContain("[...truncated");
47
+ });
48
+
49
+ it("array text block gets truncated", () => {
50
+ const text = longMultiline(200);
51
+ const result = compactContent([{ type: "text", text }]);
52
+ expect(result[0].text).toContain("[...truncated");
53
+ });
54
+
55
+ it("array non-text block returned unchanged", () => {
56
+ const block = { type: "image", url: "x" };
57
+ expect(compactContent([block])).toEqual([block]);
58
+ });
59
+
60
+ it("non-array non-string returned as-is", () => {
61
+ expect(compactContent(42)).toBe(42);
62
+ expect(compactContent({ a: 1 })).toEqual({ a: 1 });
63
+ });
64
+ });
@@ -12,7 +12,7 @@ const MAX_USER_BLOCK_CHARS = 12000;
12
12
  const KEEP_HEAD = 1500;
13
13
  const KEEP_TAIL = 2500;
14
14
 
15
- function truncateText(text: string, max: number, head = KEEP_HEAD, tail = KEEP_TAIL): string {
15
+ export function truncateText(text: string, max: number, head = KEEP_HEAD, tail = KEEP_TAIL): string {
16
16
  if (text.length <= max) return text;
17
17
  const lines = text.split("\n");
18
18
  if (lines.length <= 10) return text; // 短文本不裁
@@ -22,7 +22,7 @@ function truncateText(text: string, max: number, head = KEEP_HEAD, tail = KEEP_T
22
22
  return `${headText}\n\n[...truncated ${removedLines} lines...]\n\n${tailText}`;
23
23
  }
24
24
 
25
- function compactContent(content: any): any {
25
+ export function compactContent(content: any): any {
26
26
  if (typeof content === "string") {
27
27
  return truncateText(content, MAX_TOOL_OUTPUT_CHARS);
28
28
  }