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.
- 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 +33 -3
- 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/concurrency.ts +17 -11
- package/pi-package/extensions/ant-colony/deps.test.ts +62 -0
- package/pi-package/extensions/ant-colony/index.ts +27 -44
- package/pi-package/extensions/ant-colony/nest.ts +106 -43
- package/pi-package/extensions/ant-colony/parser.test.ts +110 -0
- package/pi-package/extensions/ant-colony/parser.ts +34 -12
- package/pi-package/extensions/ant-colony/prompts.test.ts +57 -0
- package/pi-package/extensions/ant-colony/queen.ts +82 -33
- package/pi-package/extensions/ant-colony/spawner.test.ts +44 -0
- package/pi-package/extensions/ant-colony/spawner.ts +24 -5
- package/pi-package/extensions/ant-colony/types.test.ts +36 -0
- package/pi-package/extensions/ant-colony/types.ts +1 -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
|
|
@@ -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")
|
|
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
|
+
});
|
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.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": "
|
|
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
|
-
|
|
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 &&
|
|
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 (
|
|
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
|
+
});
|