@co0ontty/wand 4.82.0 → 4.83.1
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/README.md +47 -9
- package/dist/agent-dispatch.d.ts +3 -2
- package/dist/agent-dispatch.js +19 -3
- package/dist/ai-team-delivery-types.d.ts +40 -0
- package/dist/ai-team-delivery-types.js +1 -0
- package/dist/ai-team-delivery.d.ts +13 -0
- package/dist/ai-team-delivery.js +89 -0
- package/dist/ai-team-employee-binding.d.ts +16 -0
- package/dist/ai-team-employee-binding.js +77 -0
- package/dist/ai-team-prompts.js +5 -0
- package/dist/ai-team-runner.d.ts +9 -2
- package/dist/ai-team-runner.js +52 -7
- package/dist/ai-team-types.d.ts +17 -3
- package/dist/ai-team-types.js +43 -3
- package/dist/build-info.json +3 -3
- package/dist/cli.js +36 -0
- package/dist/config.d.ts +18 -10
- package/dist/config.js +63 -105
- package/dist/decision-client.d.ts +4 -0
- package/dist/decision-client.js +91 -0
- package/dist/decision-runner.d.ts +9 -0
- package/dist/decision-runner.js +36 -0
- package/dist/decision-service.d.ts +46 -0
- package/dist/decision-service.js +230 -0
- package/dist/decision-skill.d.ts +6 -0
- package/dist/decision-skill.js +70 -0
- package/dist/decision-tool.d.ts +21 -0
- package/dist/decision-tool.js +346 -0
- package/dist/decision-types.d.ts +36 -0
- package/dist/decision-types.js +123 -0
- package/dist/decision-worker.py +86 -0
- package/dist/default-employee.d.ts +4 -1
- package/dist/default-employee.js +11 -1
- package/dist/env-utils.js +3 -0
- package/dist/git-quick-commit.js +7 -1
- package/dist/message-truncator.d.ts +1 -1
- package/dist/message-truncator.js +29 -22
- package/dist/models.d.ts +1 -0
- package/dist/models.js +31 -4
- package/dist/process-manager.d.ts +39 -1
- package/dist/process-manager.js +112 -8
- package/dist/provider-catalog.d.ts +47 -0
- package/dist/provider-catalog.js +138 -0
- package/dist/provider-cli-updater.d.ts +1 -1
- package/dist/pty-foreground.d.ts +37 -0
- package/dist/pty-foreground.js +56 -0
- package/dist/pty-turn-activity.d.ts +6 -5
- package/dist/pty-turn-activity.js +6 -5
- package/dist/retention.d.ts +29 -0
- package/dist/retention.js +142 -0
- package/dist/server-ai-team-routes.d.ts +2 -1
- package/dist/server-ai-team-routes.js +27 -9
- package/dist/server-decision-routes.d.ts +10 -0
- package/dist/server-decision-routes.js +52 -0
- package/dist/server-employee-routes.js +9 -1
- package/dist/server-session-routes.d.ts +2 -2
- package/dist/server-session-routes.js +97 -1
- package/dist/server-settings-routes.js +10 -36
- package/dist/server-update-routes.js +2 -1
- package/dist/server-workspace-routes.js +8 -0
- package/dist/server.js +37 -5
- package/dist/session-completion-state.d.ts +26 -0
- package/dist/session-completion-state.js +30 -0
- package/dist/session-completion.d.ts +12 -0
- package/dist/session-completion.js +62 -0
- package/dist/session-provider.d.ts +1 -15
- package/dist/session-provider.js +4 -54
- package/dist/session-registry.d.ts +5 -0
- package/dist/session-registry.js +19 -1
- package/dist/session-transport.js +2 -0
- package/dist/storage.d.ts +12 -0
- package/dist/storage.js +119 -16
- package/dist/structured-client-protocol.js +36 -1
- package/dist/structured-provider-common.js +3 -3
- package/dist/structured-session-manager.d.ts +13 -1
- package/dist/structured-session-manager.js +196 -27
- package/dist/system-employee.js +1 -0
- package/dist/task-types.d.ts +4 -2
- package/dist/tool-preview.d.ts +8 -0
- package/dist/tool-preview.js +158 -0
- package/dist/types.d.ts +35 -2
- package/dist/web-ui/content/ai-teams.js +13 -13
- package/dist/web-ui/content/scripts.js +66 -64
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/content/tailwind.css +3 -0
- package/dist/web-ui/embedded-assets.js +3 -3
- package/dist/web-ui/provider-identity.d.ts +7 -13
- package/dist/web-ui/provider-identity.js +8 -71
- package/package.json +3 -2
- package/skills/wand-decision/SKILL.md +61 -0
- package/skills/wand-decision/scripts/decide.mjs +16 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { request as httpRequest } from "node:http";
|
|
3
|
+
import { request as httpsRequest } from "node:https";
|
|
4
|
+
import { DECISION_MAX_BYTES, DecisionError, parseDecisionRequest, parseDecisionResult } from "./decision-types.js";
|
|
5
|
+
/** No admin/password lookup, redirects, or implicit remote fallback. Credentials stay in headers. */
|
|
6
|
+
export async function callDecisionService(request, env = process.env) {
|
|
7
|
+
const endpoint = env.WAND_DECISION_URL;
|
|
8
|
+
const token = env.WAND_DECISION_TOKEN;
|
|
9
|
+
if (!endpoint || !token || !/^wd_[A-Za-z0-9_-]{43}$/.test(token)) {
|
|
10
|
+
throw new DecisionError("UNBOUND", "请在启用了本地决策的 Wand 结构化会话中调用;不会自动读取管理员凭据。");
|
|
11
|
+
}
|
|
12
|
+
let url;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(endpoint);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new DecisionError("UNBOUND", "无效决策服务地址。");
|
|
18
|
+
}
|
|
19
|
+
const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
|
|
20
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== "/"
|
|
21
|
+
|| (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))) {
|
|
22
|
+
throw new DecisionError("UNBOUND", "远程决策服务必须使用HTTPS,地址只能包含origin。");
|
|
23
|
+
}
|
|
24
|
+
url.pathname = request ? "/api/decisions/evaluate" : "/api/decisions/status";
|
|
25
|
+
const data = request ? JSON.stringify(request) : "";
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const send = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
28
|
+
const req = send(url, {
|
|
29
|
+
method: request ? "POST" : "GET",
|
|
30
|
+
...(url.protocol === "https:" && env.WAND_DECISION_CA ? { ca: readFileSync(env.WAND_DECISION_CA) } : {}),
|
|
31
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json",
|
|
32
|
+
...(data ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } : {}) },
|
|
33
|
+
}, (res) => {
|
|
34
|
+
const chunks = [];
|
|
35
|
+
let bytes = 0;
|
|
36
|
+
res.on("data", (chunk) => {
|
|
37
|
+
bytes += chunk.length;
|
|
38
|
+
if (bytes > 128 * 1024)
|
|
39
|
+
req.destroy(new Error("oversize"));
|
|
40
|
+
else
|
|
41
|
+
chunks.push(chunk);
|
|
42
|
+
});
|
|
43
|
+
res.on("error", () => reject(new DecisionError("CONNECTION_FAILED", "决策服务响应中断。", 502)));
|
|
44
|
+
res.on("end", () => {
|
|
45
|
+
try {
|
|
46
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
47
|
+
if ((res.statusCode ?? 500) < 200 || (res.statusCode ?? 500) >= 300) {
|
|
48
|
+
// Only bounded machine codes, never echo a remote response containing secrets.
|
|
49
|
+
const code = typeof value?.code === "string" && /^[A-Z_]{1,40}$/.test(value.code) ? value.code : "HTTP_ERROR";
|
|
50
|
+
reject(new DecisionError(code, `决策调用失败 (${code}, HTTP ${res.statusCode})。`, res.statusCode));
|
|
51
|
+
}
|
|
52
|
+
else
|
|
53
|
+
resolve(request ? parseDecisionResult(value, request) : value);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
reject(new DecisionError("INVALID_RESULT", "决策服务返回了无效结果。", 502));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
const deadline = setTimeout(() => req.destroy(new Error("deadline")), 50_000);
|
|
61
|
+
req.once("close", () => clearTimeout(deadline));
|
|
62
|
+
req.once("error", () => reject(new DecisionError("CONNECTION_FAILED", "连接决策服务失败或超时。", 502)));
|
|
63
|
+
req.end(data);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export async function runDecisionCli(args) {
|
|
67
|
+
if (args.length !== 1 || !["--stdin", "--status"].includes(args[0])) {
|
|
68
|
+
throw new DecisionError("USAGE", "用法:wand decide --stdin(JSON标准输入)或 --status。");
|
|
69
|
+
}
|
|
70
|
+
let request = null;
|
|
71
|
+
if (args[0] === "--stdin") {
|
|
72
|
+
const chunks = [];
|
|
73
|
+
let bytes = 0;
|
|
74
|
+
for await (const chunk of process.stdin) {
|
|
75
|
+
const buffer = Buffer.from(chunk);
|
|
76
|
+
bytes += buffer.length;
|
|
77
|
+
if (bytes > DECISION_MAX_BYTES)
|
|
78
|
+
throw new DecisionError("REQUEST_TOO_LARGE", "请求超过32KiB。", 413);
|
|
79
|
+
chunks.push(buffer);
|
|
80
|
+
}
|
|
81
|
+
let value;
|
|
82
|
+
try {
|
|
83
|
+
value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new DecisionError("INVALID_REQUEST", "标准输入必须是有效JSON。");
|
|
87
|
+
}
|
|
88
|
+
request = parseDecisionRequest(value);
|
|
89
|
+
}
|
|
90
|
+
process.stdout.write(`${JSON.stringify(await callDecisionService(request))}\n`);
|
|
91
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { WandStorage } from "./storage.js";
|
|
2
|
+
import type { StructuredRunnerAdapter } from "./structured-runner.js";
|
|
3
|
+
export interface DecisionRuntimeAccess {
|
|
4
|
+
url: string;
|
|
5
|
+
caPath?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const DECISION_ENV_KEYS: readonly ["WAND_DECISION_URL", "WAND_DECISION_TOKEN", "WAND_DECISION_CA", "WAND_DECISION_NODE", "WAND_DECISION_CLI"];
|
|
8
|
+
/** Adds only a revocable inference capability. Never edits base prompts, DTOs, or tool permissions. */
|
|
9
|
+
export declare function withDecisionAccess(runner: StructuredRunnerAdapter, storage: WandStorage, runtime: () => DecisionRuntimeAccess | null): StructuredRunnerAdapter;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
export const DECISION_ENV_KEYS = ["WAND_DECISION_URL", "WAND_DECISION_TOKEN", "WAND_DECISION_CA", "WAND_DECISION_NODE", "WAND_DECISION_CLI"];
|
|
4
|
+
/** Adds only a revocable inference capability. Never edits base prompts, DTOs, or tool permissions. */
|
|
5
|
+
export function withDecisionAccess(runner, storage, runtime) {
|
|
6
|
+
return { start(context, observer) {
|
|
7
|
+
const env = { ...context.env };
|
|
8
|
+
for (const key of DECISION_ENV_KEYS)
|
|
9
|
+
delete env[key];
|
|
10
|
+
const access = runtime();
|
|
11
|
+
if (!access)
|
|
12
|
+
return runner.start({ ...context, env }, observer);
|
|
13
|
+
const token = storage.issueDecisionAccess(context.session.id);
|
|
14
|
+
const cleanup = () => { try {
|
|
15
|
+
storage.revokeDecisionAccess(token);
|
|
16
|
+
}
|
|
17
|
+
catch { /* storage may be closing */ } };
|
|
18
|
+
const js = fileURLToPath(new URL("./cli.js", import.meta.url));
|
|
19
|
+
// Source-mode tests/development can still call the CLI via Node's tsx loader in the skill client.
|
|
20
|
+
const cli = existsSync(js) ? js : js.replace(/\.js$/, ".ts");
|
|
21
|
+
try {
|
|
22
|
+
const execution = runner.start({ ...context, env: { ...env,
|
|
23
|
+
WAND_DECISION_URL: access.url, WAND_DECISION_TOKEN: token,
|
|
24
|
+
WAND_DECISION_NODE: process.execPath, WAND_DECISION_CLI: cli,
|
|
25
|
+
...(access.caPath ? { WAND_DECISION_CA: access.caPath } : {}),
|
|
26
|
+
}, session: { ...context.session, runtimeSystemPrompt: [context.session.runtimeSystemPrompt,
|
|
27
|
+
"可选本地判断工具:wand-decision skill。适用于有界选择、评分、是非判断,不替代主模型或操作授权。仅在需要且有命令权限时使用;不要打印或转存调用环境中的凭据。",
|
|
28
|
+
].filter(Boolean).join("\n\n") } }, observer);
|
|
29
|
+
return { ...execution, completion: execution.completion.finally(cleanup), interrupt: () => { cleanup(); execution.interrupt(); } };
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
cleanup();
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
} };
|
|
36
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type DecisionResult, type LocalDecisionConfig } from "./decision-types.js";
|
|
2
|
+
interface DecisionServiceOptions {
|
|
3
|
+
workerPath?: string;
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
idleMs?: number;
|
|
6
|
+
supported?: () => boolean;
|
|
7
|
+
}
|
|
8
|
+
/** One bounded worker per server. It owns no sessions, permissions, tasks, or model downloads. */
|
|
9
|
+
export declare class DecisionService {
|
|
10
|
+
private readonly config;
|
|
11
|
+
private readonly options;
|
|
12
|
+
private child;
|
|
13
|
+
private stopping;
|
|
14
|
+
private ready;
|
|
15
|
+
private stdout;
|
|
16
|
+
private queue;
|
|
17
|
+
private active;
|
|
18
|
+
private sequence;
|
|
19
|
+
private disposed;
|
|
20
|
+
private idleTimer;
|
|
21
|
+
private cooldownUntil;
|
|
22
|
+
private readonly rates;
|
|
23
|
+
private completed;
|
|
24
|
+
private failed;
|
|
25
|
+
constructor(config: LocalDecisionConfig | undefined, options?: DecisionServiceOptions);
|
|
26
|
+
status(): {
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
supported: boolean;
|
|
29
|
+
configured: boolean;
|
|
30
|
+
state: string;
|
|
31
|
+
queued: number;
|
|
32
|
+
completed: number;
|
|
33
|
+
failed: number;
|
|
34
|
+
experimental: true;
|
|
35
|
+
};
|
|
36
|
+
evaluate(value: unknown, caller: string, signal?: AbortSignal): Promise<DecisionResult>;
|
|
37
|
+
dispose(): void;
|
|
38
|
+
private finish;
|
|
39
|
+
private clearIdle;
|
|
40
|
+
private stopWorker;
|
|
41
|
+
private failWorker;
|
|
42
|
+
private pump;
|
|
43
|
+
private startWorker;
|
|
44
|
+
private receive;
|
|
45
|
+
}
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { buildChildEnv } from "./env-utils.js";
|
|
6
|
+
import { DecisionError, parseDecisionRequest, parseDecisionResult } from "./decision-types.js";
|
|
7
|
+
/** One bounded worker per server. It owns no sessions, permissions, tasks, or model downloads. */
|
|
8
|
+
export class DecisionService {
|
|
9
|
+
config;
|
|
10
|
+
options;
|
|
11
|
+
child = null;
|
|
12
|
+
stopping = null;
|
|
13
|
+
ready = false;
|
|
14
|
+
stdout = "";
|
|
15
|
+
queue = [];
|
|
16
|
+
active = null;
|
|
17
|
+
sequence = 0;
|
|
18
|
+
disposed = false;
|
|
19
|
+
idleTimer = null;
|
|
20
|
+
cooldownUntil = 0;
|
|
21
|
+
rates = new Map();
|
|
22
|
+
completed = 0;
|
|
23
|
+
failed = 0;
|
|
24
|
+
constructor(config, options = {}) {
|
|
25
|
+
this.config = config;
|
|
26
|
+
this.options = options;
|
|
27
|
+
}
|
|
28
|
+
status() {
|
|
29
|
+
const supported = (this.options.supported ?? (() => process.platform === "darwin" && process.arch === "arm64"))();
|
|
30
|
+
const configured = !!this.config && isAbsolute(this.config.pythonPath) && isAbsolute(this.config.modelPath)
|
|
31
|
+
&& existsSync(this.config.pythonPath) && existsSync(this.config.modelPath);
|
|
32
|
+
return { enabled: this.config?.enabled === true, supported, configured,
|
|
33
|
+
state: this.disposed ? "closed" : this.stopping ? "stopping" : this.child ? this.ready ? "ready" : "loading" : "stopped",
|
|
34
|
+
queued: this.queue.length + Number(!!this.active), completed: this.completed, failed: this.failed, experimental: true };
|
|
35
|
+
}
|
|
36
|
+
evaluate(value, caller, signal) {
|
|
37
|
+
const request = parseDecisionRequest(value);
|
|
38
|
+
const status = this.status();
|
|
39
|
+
if (this.disposed || !status.enabled || !status.supported || !status.configured) {
|
|
40
|
+
throw new DecisionError("UNAVAILABLE", "本地决策未启用、平台不支持或运行环境未安装。", 503);
|
|
41
|
+
}
|
|
42
|
+
if (signal?.aborted)
|
|
43
|
+
throw new DecisionError("CANCELLED", "决策请求已取消。", 499);
|
|
44
|
+
if (Date.now() < this.cooldownUntil)
|
|
45
|
+
throw new DecisionError("COOLDOWN", "决策引擎暂不可用,请稍后重试。", 503);
|
|
46
|
+
if (this.queue.length + Number(!!this.active) >= 8)
|
|
47
|
+
throw new DecisionError("BUSY", "决策队列已满。", 503);
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
for (const [key, rate] of this.rates)
|
|
50
|
+
if (now - rate.start >= 60_000)
|
|
51
|
+
this.rates.delete(key);
|
|
52
|
+
const rate = this.rates.get(caller) ?? { start: now, count: 0 };
|
|
53
|
+
if (rate.count >= 60 || (!this.rates.has(caller) && this.rates.size >= 4096)) {
|
|
54
|
+
throw new DecisionError("RATE_LIMIT", "决策调用过于频繁。", 429);
|
|
55
|
+
}
|
|
56
|
+
rate.count++;
|
|
57
|
+
this.rates.set(caller, rate);
|
|
58
|
+
this.clearIdle();
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const id = ++this.sequence;
|
|
61
|
+
const cancel = (error) => {
|
|
62
|
+
const queued = this.queue.find((job) => job.id === id);
|
|
63
|
+
if (queued) {
|
|
64
|
+
this.queue = this.queue.filter((job) => job.id !== id);
|
|
65
|
+
this.finish(queued, error);
|
|
66
|
+
// The first request can time out while Python is still loading, before active is set.
|
|
67
|
+
if (!this.active && !this.queue.length)
|
|
68
|
+
this.stopWorker();
|
|
69
|
+
}
|
|
70
|
+
else if (this.active?.id === id)
|
|
71
|
+
this.failWorker(error);
|
|
72
|
+
};
|
|
73
|
+
const abort = () => cancel(new DecisionError("CANCELLED", "决策请求已取消。", 499));
|
|
74
|
+
const timer = setTimeout(() => cancel(new DecisionError("TIMEOUT", "决策请求超时。", 504)), this.options.timeoutMs ?? 45_000);
|
|
75
|
+
const job = { id, request, resolve, reject, timer, removeAbort: () => signal?.removeEventListener("abort", abort) };
|
|
76
|
+
this.queue.push(job);
|
|
77
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
78
|
+
if (signal?.aborted) {
|
|
79
|
+
abort();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
this.pump();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
dispose() {
|
|
86
|
+
if (this.disposed)
|
|
87
|
+
return;
|
|
88
|
+
this.disposed = true;
|
|
89
|
+
this.failWorker(new DecisionError("UNAVAILABLE", "决策服务已关闭。", 503));
|
|
90
|
+
this.rates.clear();
|
|
91
|
+
}
|
|
92
|
+
finish(job, error, value) {
|
|
93
|
+
clearTimeout(job.timer);
|
|
94
|
+
job.removeAbort();
|
|
95
|
+
if (error) {
|
|
96
|
+
this.failed++;
|
|
97
|
+
job.reject(error);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
this.completed++;
|
|
101
|
+
job.resolve(value);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
clearIdle() {
|
|
105
|
+
if (this.idleTimer)
|
|
106
|
+
clearTimeout(this.idleTimer);
|
|
107
|
+
this.idleTimer = null;
|
|
108
|
+
}
|
|
109
|
+
stopWorker() {
|
|
110
|
+
this.clearIdle();
|
|
111
|
+
const child = this.child;
|
|
112
|
+
this.child = null;
|
|
113
|
+
this.ready = false;
|
|
114
|
+
this.stdout = "";
|
|
115
|
+
if (child) {
|
|
116
|
+
this.stopping = child;
|
|
117
|
+
child.stdin.destroy();
|
|
118
|
+
child.kill("SIGTERM");
|
|
119
|
+
const timer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null)
|
|
120
|
+
child.kill("SIGKILL"); }, 1000);
|
|
121
|
+
timer.unref();
|
|
122
|
+
child.once("close", () => {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
if (this.stopping === child)
|
|
125
|
+
this.stopping = null;
|
|
126
|
+
this.pump();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
failWorker(error) {
|
|
131
|
+
const jobs = [...(this.active ? [this.active] : []), ...this.queue];
|
|
132
|
+
this.active = null;
|
|
133
|
+
this.queue = [];
|
|
134
|
+
this.stopWorker();
|
|
135
|
+
this.cooldownUntil = Date.now() + 1000;
|
|
136
|
+
for (const job of jobs)
|
|
137
|
+
this.finish(job, error);
|
|
138
|
+
}
|
|
139
|
+
pump() {
|
|
140
|
+
if (this.disposed || this.active || this.stopping)
|
|
141
|
+
return;
|
|
142
|
+
if (!this.queue.length) {
|
|
143
|
+
if (this.child && !this.idleTimer) {
|
|
144
|
+
this.idleTimer = setTimeout(() => this.stopWorker(), this.options.idleMs ?? 5 * 60_000);
|
|
145
|
+
this.idleTimer.unref();
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (!this.child) {
|
|
150
|
+
this.startWorker();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (!this.ready)
|
|
154
|
+
return;
|
|
155
|
+
const job = this.queue.shift();
|
|
156
|
+
this.active = job;
|
|
157
|
+
this.child.stdin.write(`${JSON.stringify({ id: job.id, request: job.request })}\n`, (error) => {
|
|
158
|
+
if (error && this.active === job)
|
|
159
|
+
this.failWorker(new DecisionError("WORKER_FAILED", "决策引擎通信失败。", 502));
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
startWorker() {
|
|
163
|
+
const child = spawn(this.config.pythonPath, [this.options.workerPath ?? fileURLToPath(new URL("./decision-worker.py", import.meta.url)), this.config.modelPath], {
|
|
164
|
+
env: buildChildEnv(false, { HF_HUB_OFFLINE: "1", HF_HUB_DISABLE_TELEMETRY: "1", HF_HUB_DISABLE_IMPLICIT_TOKEN: "1", PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1" }),
|
|
165
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
166
|
+
});
|
|
167
|
+
this.child = child;
|
|
168
|
+
// Never reflect stderr (it can include a local path or untrusted input) into HTTP or logs.
|
|
169
|
+
child.stderr.resume();
|
|
170
|
+
child.stdin.on("error", () => { if (this.child === child)
|
|
171
|
+
this.failWorker(new DecisionError("WORKER_FAILED", "决策引擎通信失败。", 502)); });
|
|
172
|
+
child.on("error", () => { if (this.child === child)
|
|
173
|
+
this.failWorker(new DecisionError("WORKER_FAILED", "无法启动决策引擎。", 503)); });
|
|
174
|
+
child.on("exit", () => { if (this.child === child)
|
|
175
|
+
this.failWorker(new DecisionError("WORKER_FAILED", "决策引擎已退出。", 502)); });
|
|
176
|
+
child.stdout.setEncoding("utf8");
|
|
177
|
+
child.stdout.on("data", (chunk) => {
|
|
178
|
+
if (this.child !== child)
|
|
179
|
+
return;
|
|
180
|
+
this.stdout += chunk;
|
|
181
|
+
if (Buffer.byteLength(this.stdout) > 128 * 1024) {
|
|
182
|
+
this.failWorker(new DecisionError("INVALID_RESULT", "决策引擎输出过大。", 502));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
while (this.stdout.includes("\n") && this.child === child) {
|
|
186
|
+
const end = this.stdout.indexOf("\n");
|
|
187
|
+
const line = this.stdout.slice(0, end);
|
|
188
|
+
this.stdout = this.stdout.slice(end + 1);
|
|
189
|
+
try {
|
|
190
|
+
this.receive(JSON.parse(line));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
this.failWorker(new DecisionError("INVALID_RESULT", "决策引擎协议错误。", 502));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
receive(message) {
|
|
199
|
+
if (!this.ready) {
|
|
200
|
+
if (message.type !== "ready" || message.protocol !== 1)
|
|
201
|
+
throw new Error("Unexpected ready");
|
|
202
|
+
this.ready = true;
|
|
203
|
+
this.pump();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const job = this.active;
|
|
207
|
+
if (!job || message.id !== job.id)
|
|
208
|
+
throw new Error("Unexpected result");
|
|
209
|
+
const errors = {
|
|
210
|
+
CONTEXT_LIMIT: "超过模型1024 token上下文,请缩短输入;未返回截断后的判断。",
|
|
211
|
+
OPTIONS_COLLAPSED: "选项超出token预算、无法区分,请减少或缩短选项。",
|
|
212
|
+
QUESTION_LIMIT: "问题指令或选项超过模型前缀token预算,请缩短;不会静默截断。",
|
|
213
|
+
INVALID_REQUEST: "决策引擎无法处理该问题格式。",
|
|
214
|
+
};
|
|
215
|
+
if (typeof message.error === "string") {
|
|
216
|
+
if (!Object.hasOwn(errors, message.error)) {
|
|
217
|
+
this.failWorker(new DecisionError("INFERENCE_FAILED", "本地推理失败。", 502));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.active = null;
|
|
221
|
+
this.finish(job, new DecisionError(message.error, errors[message.error], 422));
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
const result = parseDecisionResult(message.result, job.request);
|
|
225
|
+
this.active = null;
|
|
226
|
+
this.finish(job, undefined, result);
|
|
227
|
+
}
|
|
228
|
+
this.pump();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type SessionProvider } from "./provider-catalog.js";
|
|
2
|
+
/** One managed source, three discovery locations; never edits a provider's settings or permissions. */
|
|
3
|
+
export declare function installDecisionSkill(configDir: string, providers?: readonly SessionProvider[], home?: string): Promise<{
|
|
4
|
+
installed: string[];
|
|
5
|
+
providers: readonly SessionProvider[];
|
|
6
|
+
}>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, mkdir, lstat, readlink, writeFile, symlink, realpath } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { SESSION_PROVIDERS } from "./provider-catalog.js";
|
|
7
|
+
const FILES = ["SKILL.md", "scripts/decide.mjs"];
|
|
8
|
+
const sha = (data) => createHash("sha256").update(data).digest("hex");
|
|
9
|
+
/** One managed source, three discovery locations; never edits a provider's settings or permissions. */
|
|
10
|
+
export async function installDecisionSkill(configDir, providers = SESSION_PROVIDERS, home = homedir()) {
|
|
11
|
+
if (!providers.length || providers.some((provider) => !SESSION_PROVIDERS.includes(provider)))
|
|
12
|
+
throw new Error("未知CLI技能目标。");
|
|
13
|
+
const canonical = path.join(configDir, "skills", "wand-decision");
|
|
14
|
+
const source = fileURLToPath(new URL("../skills/wand-decision/", import.meta.url));
|
|
15
|
+
const targets = new Set();
|
|
16
|
+
for (const provider of providers) {
|
|
17
|
+
const directory = provider === "claude" ? ".claude" : provider === "qoder" ? ".qoder" : ".agents";
|
|
18
|
+
targets.add(path.join(home, directory, "skills", "wand-decision"));
|
|
19
|
+
}
|
|
20
|
+
// Preflight every target before touching anything; existing user content is never replaced.
|
|
21
|
+
for (const target of targets) {
|
|
22
|
+
try {
|
|
23
|
+
const info = await lstat(target);
|
|
24
|
+
if (!info.isSymbolicLink() || path.resolve(path.dirname(target), await readlink(target)) !== path.resolve(canonical)) {
|
|
25
|
+
throw new Error(`已有同名技能,保留原内容,请先手动处理:${target}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code !== "ENOENT")
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
let previous = {};
|
|
34
|
+
try {
|
|
35
|
+
previous = JSON.parse(await readFile(path.join(canonical, ".wand-managed.json"), "utf8"));
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code !== "ENOENT")
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
const contents = await Promise.all(FILES.map(async (file) => ({ file, content: await readFile(path.join(source, file), "utf8") })));
|
|
42
|
+
for (const { file, content } of contents) {
|
|
43
|
+
try {
|
|
44
|
+
const old = await readFile(path.join(canonical, file), "utf8");
|
|
45
|
+
if (old !== content && sha(old) !== previous[file])
|
|
46
|
+
throw new Error(`技能已有用户修改,拒绝覆盖:${file}`);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code !== "ENOENT")
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await mkdir(path.join(canonical, "scripts"), { recursive: true });
|
|
54
|
+
for (const { file, content } of contents)
|
|
55
|
+
await writeFile(path.join(canonical, file), content, { mode: 0o644 });
|
|
56
|
+
await writeFile(path.join(canonical, ".wand-managed.json"), JSON.stringify(Object.fromEntries(contents.map(({ file, content }) => [file, sha(content)]))), { mode: 0o600 });
|
|
57
|
+
for (const target of targets) {
|
|
58
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
59
|
+
try {
|
|
60
|
+
await symlink(canonical, target, "dir");
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error.code !== "EEXIST")
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
if (await realpath(target) !== await realpath(canonical))
|
|
67
|
+
throw new Error("技能链接目标校验失败。");
|
|
68
|
+
}
|
|
69
|
+
return { installed: [...targets], providers };
|
|
70
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DecisionCardSummary } from "./types.js";
|
|
2
|
+
/** Presentation-only recognition of actual local-decision invocations, never an execution policy. */
|
|
3
|
+
interface DecisionToolLike {
|
|
4
|
+
type?: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
input?: Record<string, unknown>;
|
|
7
|
+
semantic?: {
|
|
8
|
+
kind?: string;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export declare function isDecisionToolCall(block: DecisionToolLike): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* 卡头副标题:结论优先(窄屏先被省略的是被判定内容,不是答案),再题数、再要点的有界拼接;
|
|
14
|
+
* 两边都缺数据时退化为模式提示,客户端因此不需要自带一套决策文案。
|
|
15
|
+
* 状态词(运行中/完成/失败)不在这里,由客户端按自己的状态源渲染。
|
|
16
|
+
*/
|
|
17
|
+
export declare function decisionCardSummary(input: Record<string, unknown> | undefined, result: {
|
|
18
|
+
content?: unknown;
|
|
19
|
+
is_error?: boolean;
|
|
20
|
+
} | null | undefined): DecisionCardSummary;
|
|
21
|
+
export {};
|