oh-pi 0.1.69 → 0.1.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) 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 +33 -3
  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/concurrency.ts +17 -11
  22. package/pi-package/extensions/ant-colony/deps.test.ts +62 -0
  23. package/pi-package/extensions/ant-colony/index.ts +27 -44
  24. package/pi-package/extensions/ant-colony/nest.ts +106 -43
  25. package/pi-package/extensions/ant-colony/parser.test.ts +110 -0
  26. package/pi-package/extensions/ant-colony/parser.ts +34 -12
  27. package/pi-package/extensions/ant-colony/prompts.test.ts +57 -0
  28. package/pi-package/extensions/ant-colony/queen.ts +82 -33
  29. package/pi-package/extensions/ant-colony/spawner.test.ts +44 -0
  30. package/pi-package/extensions/ant-colony/spawner.ts +24 -5
  31. package/pi-package/extensions/ant-colony/types.test.ts +36 -0
  32. package/pi-package/extensions/ant-colony/types.ts +1 -0
  33. package/pi-package/extensions/ant-colony/ui.test.ts +66 -0
  34. package/pi-package/extensions/auto-update.test.ts +15 -0
  35. package/pi-package/extensions/auto-update.ts +1 -1
  36. package/pi-package/extensions/safe-guard.test.ts +26 -0
  37. package/pi-package/extensions/safe-guard.ts +2 -2
  38. package/pi-package/extensions/smart-compact.test.ts +64 -0
  39. 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
@@ -12,6 +12,36 @@ const PROVIDER_API_URLS = {
12
12
  xai: "https://api.x.ai",
13
13
  mistral: "https://api.mistral.ai",
14
14
  };
15
+ /** Block internal/private IPs to prevent SSRF */
16
+ export function isUnsafeUrl(urlStr) {
17
+ try {
18
+ const u = new URL(urlStr);
19
+ const host = u.hostname;
20
+ // Allow localhost for local dev servers (Ollama, vLLM, etc.)
21
+ if (host === "localhost" || host === "127.0.0.1" || host === "::1")
22
+ return false;
23
+ // Block private IP ranges
24
+ if (/^10\./.test(host))
25
+ return true;
26
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(host))
27
+ return true;
28
+ if (/^192\.168\./.test(host))
29
+ return true;
30
+ if (/^0\./.test(host) || host === "0.0.0.0")
31
+ return true;
32
+ if (host.includes(":") || host.startsWith("["))
33
+ return true;
34
+ if (/^169\.254\./.test(host))
35
+ return true;
36
+ // Block non-https for remote hosts
37
+ if (u.protocol !== "https:")
38
+ return true;
39
+ return false;
40
+ }
41
+ catch {
42
+ return true;
43
+ }
44
+ }
15
45
  /**
16
46
  * 动态获取模型列表,依次尝试 Anthropic、Google、OpenAI 兼容 API 风格。
17
47
  * @param provider - 提供商名称
@@ -85,7 +115,7 @@ async function fetchModels(provider, baseUrl, apiKey) {
85
115
  api: "openai-completions",
86
116
  models: data.map((m) => ({
87
117
  id: m.id,
88
- reasoning: m.thinking_enabled ?? m.id.includes("o3") ?? false,
118
+ reasoning: m.thinking_enabled ?? m.id.includes("o3"),
89
119
  input: ["text", "image"],
90
120
  contextWindow: m.context_window ?? m.max_tokens ?? 128000,
91
121
  maxTokens: m.max_output ?? 16384,
@@ -158,7 +188,7 @@ export async function setupProviders(env) {
158
188
  const url = await p.text({
159
189
  message: t("provider.baseUrl", { label: info.label }),
160
190
  placeholder: "https://proxy.example.com",
161
- validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : undefined,
191
+ validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : isUnsafeUrl(v) ? "URL must use HTTPS for remote hosts (private IPs blocked)" : undefined,
162
192
  });
163
193
  if (p.isCancel(url)) {
164
194
  p.cancel(t("cancelled"));
@@ -203,7 +233,7 @@ async function setupCustomProvider() {
203
233
  const baseUrl = await p.text({
204
234
  message: t("provider.baseUrlCustom"),
205
235
  placeholder: t("provider.baseUrlCustomPlaceholder"),
206
- validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : undefined,
236
+ validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : isUnsafeUrl(v) ? "URL must use HTTPS for remote hosts (private IPs blocked)" : undefined,
207
237
  });
208
238
  if (p.isCancel(baseUrl)) {
209
239
  p.cancel(t("cancelled"));
@@ -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.69",
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
+ });
@@ -76,18 +76,27 @@ export function adapt(config: ConcurrencyConfig, pendingTasks: number): Concurre
76
76
  const latest = samples[samples.length - 1];
77
77
  const prev = samples[samples.length - 2];
78
78
 
79
- // 硬约束:系统过载立即减少
80
- if (latest.cpuLoad > 0.85 || latest.memFree < 500 * 1024 * 1024) {
79
+ // CPU 滑动窗口:最近 3 次采样平均值
80
+ const recentCpuSamples = samples.slice(-3);
81
+ const avgCpu = recentCpuSamples.reduce((s, x) => s + x.cpuLoad, 0) / recentCpuSamples.length;
82
+
83
+ // 429 冷却:30s 内不允许增加并发
84
+ const inRateLimitCooldown = config.lastRateLimitAt != null && Date.now() - config.lastRateLimitAt < 30000;
85
+
86
+ // 硬约束:系统过载立即减少(滞回带:>85% 减,60%-85% 保持)
87
+ if (avgCpu > 0.85 || latest.memFree < 500 * 1024 * 1024) {
81
88
  next.current = Math.max(config.min, config.current - 1);
82
89
  return next;
83
90
  }
84
91
 
92
+ // 滞回带:CPU 在 60%-85% 之间保持不变
93
+ const canIncrease = avgCpu < 0.6 && !inRateLimitCooldown;
94
+
85
95
  // 探索期:样本不足,逐步提升
86
96
  if (samples.length < 10) {
87
- if (latest.throughput >= prev.throughput) {
88
- // 吞吐量还在涨,继续探索
97
+ if (latest.throughput >= prev.throughput && canIncrease) {
89
98
  next.current = Math.min(config.current + 1, taskCap);
90
- } else {
99
+ } else if (latest.throughput < prev.throughput) {
91
100
  // 吞吐量下降,找到拐点
92
101
  next.optimal = prev.concurrency;
93
102
  next.current = prev.concurrency;
@@ -99,20 +108,17 @@ export function adapt(config: ConcurrencyConfig, pendingTasks: number): Concurre
99
108
  const recentThroughput = samples.slice(-5).reduce((s, x) => s + x.throughput, 0) / 5;
100
109
  const olderThroughput = samples.slice(-10, -5).reduce((s, x) => s + x.throughput, 0) / 5;
101
110
 
102
- if (recentThroughput > olderThroughput * 1.1 && latest.cpuLoad < 0.7) {
103
- // 吞吐量上升且 CPU 有余量,尝试+1
111
+ if (recentThroughput > olderThroughput * 1.1 && canIncrease) {
104
112
  next.current = Math.min(config.current + 1, taskCap);
105
113
  if (recentThroughput > olderThroughput * 1.2) {
106
- next.optimal = next.current; // 更新最优值
114
+ next.optimal = next.current;
107
115
  }
108
116
  } else if (recentThroughput < olderThroughput * 0.8) {
109
- // 吞吐量下降,回退
110
117
  next.current = Math.max(config.min, config.optimal);
111
118
  }
112
- // 否则保持不变
113
119
 
114
120
  // 429 recovery: restore to optimal when CPU is underutilized (e.g. after backoff)
115
- if (latest.cpuLoad < 0.5 && next.current < config.optimal) {
121
+ if (avgCpu < 0.5 && next.current < config.optimal && !inRateLimitCooldown) {
116
122
  next.current = config.optimal;
117
123
  }
118
124
 
@@ -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
+ });