oh-pi 0.1.70 → 0.1.72
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 +1 -1
- 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/deps.test.ts +62 -0
- package/pi-package/extensions/ant-colony/index.ts +4 -0
- package/pi-package/extensions/ant-colony/nest.ts +37 -44
- package/pi-package/extensions/ant-colony/parser.test.ts +110 -0
- package/pi-package/extensions/ant-colony/prompts.test.ts +57 -0
- package/pi-package/extensions/ant-colony/prompts.ts +7 -1
- package/pi-package/extensions/ant-colony/queen.ts +159 -21
- package/pi-package/extensions/ant-colony/spawner.test.ts +44 -0
- package/pi-package/extensions/ant-colony/spawner.ts +16 -1
- package/pi-package/extensions/ant-colony/types.test.ts +36 -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
|
@@ -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
|
+
});
|
package/dist/tui/welcome.d.ts
CHANGED
|
@@ -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;
|
package/dist/tui/welcome.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.1.72",
|
|
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": "
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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 次调用,若有弱条目被过滤则重写文件
|
|
@@ -247,6 +228,18 @@ export class Nest {
|
|
|
247
228
|
return this.pheromoneCache;
|
|
248
229
|
}
|
|
249
230
|
|
|
231
|
+
/** 统计指定文件相关的 warning/repellent 信息素数量 */
|
|
232
|
+
countWarnings(files: string[]): number {
|
|
233
|
+
this.getAllPheromones();
|
|
234
|
+
let count = 0;
|
|
235
|
+
for (const f of files) {
|
|
236
|
+
for (const p of this.pheromoneByFile.get(f) ?? []) {
|
|
237
|
+
if (p.type === "warning" || p.type === "repellent") count++;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return count;
|
|
241
|
+
}
|
|
242
|
+
|
|
250
243
|
/** 读取与特定文件相关的信息素摘要 */
|
|
251
244
|
getPheromoneContext(files: string[], limit = 20): string {
|
|
252
245
|
const relevant = this.getAllPheromones()
|
|
@@ -298,7 +291,7 @@ export class Nest {
|
|
|
298
291
|
|
|
299
292
|
private withStateLock<T>(fn: () => T): T {
|
|
300
293
|
const MAX_WAIT = 3000;
|
|
301
|
-
const SPIN_MS =
|
|
294
|
+
const SPIN_MS = 5;
|
|
302
295
|
const start = Date.now();
|
|
303
296
|
while (true) {
|
|
304
297
|
try {
|
|
@@ -319,8 +312,8 @@ export class Nest {
|
|
|
319
312
|
// 进程存活且锁未过期,放弃等待
|
|
320
313
|
throw new Error(`[Nest] withStateLock timeout after ${MAX_WAIT}ms`);
|
|
321
314
|
}
|
|
322
|
-
// 简单 busy-wait,避免 SharedArrayBuffer 依赖
|
|
323
|
-
const until = Date.now() + SPIN_MS + Math.random() * SPIN_MS;
|
|
315
|
+
// 简单 busy-wait + jitter,避免 SharedArrayBuffer 依赖
|
|
316
|
+
const until = Date.now() + SPIN_MS + Math.random() * SPIN_MS * 2;
|
|
324
317
|
while (Date.now() < until) { /* spin */ }
|
|
325
318
|
}
|
|
326
319
|
}
|