dsh-plugin-agentflow 0.0.0-stage → 0.2.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.
@@ -0,0 +1,131 @@
1
+ // skills.mjs —— 扫描并加载 skill。
2
+ //
3
+ // skill 就是一个目录,里面有 SKILL.md:
4
+ // ---
5
+ // name: code-review
6
+ // description: 审查 diff 的清单
7
+ // ---
8
+ // 正文……
9
+ //
10
+ // 这里只做两件事:列出有哪些 skill、把某个 skill 的正文读出来拼进 prompt。
11
+
12
+ import fs from 'node:fs/promises';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const ROOT = path.dirname(fileURLToPath(import.meta.url));
18
+
19
+ /** 默认扫描的目录:本机的 + 项目自带的 */
20
+ export function defaultSkillDirs() {
21
+ return [
22
+ path.join(ROOT, 'skills'),
23
+ path.join(os.homedir(), '.dsh', 'skills'),
24
+ ];
25
+ }
26
+
27
+ /** 极简 frontmatter 解析:认 --- 包起来的那段 key: value,支持 > / | 块标量 */
28
+ function parseFrontmatter(text) {
29
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
30
+ if (!m) return { meta: {}, body: text };
31
+ const lines = m[1].split(/\r?\n/);
32
+ const meta = {};
33
+
34
+ for (let i = 0; i < lines.length; i++) {
35
+ const kv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(lines[i]);
36
+ if (!kv) continue;
37
+ const key = kv[1];
38
+ let val = kv[2].trim();
39
+
40
+ // 块标量:description: > 后面跟一段缩进的多行文字
41
+ if (/^[>|][+-]?$/.test(val)) {
42
+ const folded = val.startsWith('>');
43
+ const buf = [];
44
+ let j = i + 1;
45
+ for (; j < lines.length; j++) {
46
+ const line = lines[j];
47
+ if (/^\s+\S/.test(line)) buf.push(line.trim());
48
+ else if (/^\s*$/.test(line)) buf.push('');
49
+ else break;
50
+ }
51
+ i = j - 1;
52
+ val = folded ? buf.join(' ').replace(/\s+/g, ' ').trim() : buf.join('\n').trim();
53
+ }
54
+
55
+ // 去掉成对的引号
56
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
57
+ val = val.slice(1, -1);
58
+ }
59
+ meta[key] = val;
60
+ }
61
+ return { meta, body: text.slice(m[0].length) };
62
+ }
63
+
64
+ /**
65
+ * 列出所有 skill。
66
+ * @returns [{ name, description, path, dir }]
67
+ */
68
+ export async function listSkills(dirs) {
69
+ const scan = dirs && dirs.length ? dirs : defaultSkillDirs();
70
+ const out = [];
71
+ const seen = new Set();
72
+
73
+ for (const dir of scan) {
74
+ let entries;
75
+ try {
76
+ entries = await fs.readdir(dir, { withFileTypes: true });
77
+ } catch {
78
+ continue; // 目录不存在就跳过,不算错
79
+ }
80
+ for (const ent of entries) {
81
+ if (!ent.isDirectory()) continue;
82
+ const skillDir = path.join(dir, ent.name);
83
+ const file = path.join(skillDir, 'SKILL.md');
84
+ let text;
85
+ try {
86
+ text = await fs.readFile(file, 'utf8');
87
+ } catch {
88
+ continue;
89
+ }
90
+ const { meta } = parseFrontmatter(text);
91
+ const name = meta.name || ent.name;
92
+ if (seen.has(name)) continue; // 同名只留第一个(项目目录优先)
93
+ seen.add(name);
94
+ out.push({
95
+ name,
96
+ description: meta.description || '',
97
+ path: file,
98
+ dir: skillDir,
99
+ });
100
+ }
101
+ }
102
+ out.sort((a, b) => a.name.localeCompare(b.name));
103
+ return out;
104
+ }
105
+
106
+ /** 读某个 skill 的正文(去掉 frontmatter),找不到返回 null */
107
+ export async function loadSkillBody(name, dirs) {
108
+ const all = await listSkills(dirs);
109
+ const hit = all.find((s) => s.name === name);
110
+ if (!hit) return null;
111
+ const text = await fs.readFile(hit.path, 'utf8');
112
+ return parseFrontmatter(text).body.trim();
113
+ }
114
+
115
+ /**
116
+ * 把节点挂载的 skills 拼成一段 system 提示。
117
+ * 没有任何 skill 时返回空串。
118
+ */
119
+ export async function composeSkillPrompt(names, dirs) {
120
+ if (!Array.isArray(names) || !names.length) return '';
121
+ const parts = [];
122
+ for (const n of names) {
123
+ const body = await loadSkillBody(n, dirs);
124
+ if (!body) {
125
+ parts.push(`【技能 ${n}】未找到,忽略。`);
126
+ continue;
127
+ }
128
+ parts.push(`【技能:${n}】\n${body}`);
129
+ }
130
+ return parts.join('\n\n');
131
+ }
@@ -0,0 +1,103 @@
1
+ // store.mjs —— 落盘底层:读 JSON、原子写、把并发写串起来。
2
+ //
3
+ // 多画布(boards.json + boards/<id>.json)的存取在 boards.mjs;
4
+ // 这里同时留着单画布接口(loadBoard/saveBoard/mutateBoard),给 run.mjs 这类旧代码用。
5
+ // 不包含业务判断(那些在 server.mjs / boards.mjs)。
6
+
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const ROOT = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ /** 数据目录。每次调用都读 env:测试可以在同一进程里切临时目录。 */
14
+ export function dataDir() {
15
+ return process.env.AGENTFLOW_DATA_DIR || path.join(ROOT, 'data');
16
+ }
17
+
18
+ // 旧代码直接 import 这个常量;进程启动后地址不变(测试传 env 起子进程,没问题)。
19
+ export const DATA_DIR = dataDir();
20
+ const BOARD_FILE = path.join(DATA_DIR, 'board.json');
21
+ const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
22
+
23
+ // 所有写操作排成一队,避免两次请求同时写同一个文件导致半截 JSON。
24
+ let writeChain = Promise.resolve();
25
+
26
+ function enqueue(fn) {
27
+ const run = writeChain.then(fn, fn);
28
+ // 吞掉错误避免链断掉;真正的错误由 run 返回给调用方
29
+ writeChain = run.catch(() => {});
30
+ return run;
31
+ }
32
+
33
+ /** 串行执行一段异步逻辑(boards.mjs 的读-改-写用它)。 */
34
+ export function exclusive(fn) {
35
+ return enqueue(fn);
36
+ }
37
+
38
+ async function ensureDirFor(file) {
39
+ await fs.mkdir(path.dirname(file), { recursive: true });
40
+ }
41
+
42
+ /** 原子写:先写 .tmp 再 rename,避免进程被杀留下半截文件。 */
43
+ async function atomicWrite(file, text, mode) {
44
+ await ensureDirFor(file);
45
+ const tmp = `${file}.${process.pid}.tmp`;
46
+ await fs.writeFile(tmp, text, 'utf8');
47
+ if (mode) await fs.chmod(tmp, mode);
48
+ await fs.rename(tmp, file);
49
+ }
50
+
51
+ /** 原子写 JSON(boards.mjs 的画布/索引文件共用这一套)。 */
52
+ export async function writeJsonAtomic(file, value, mode) {
53
+ await atomicWrite(file, JSON.stringify(value, null, 2), mode);
54
+ return value;
55
+ }
56
+
57
+ export async function readJson(file, fallback = null) {
58
+ try {
59
+ return JSON.parse(await fs.readFile(file, 'utf8'));
60
+ } catch (err) {
61
+ if (err.code === 'ENOENT') return fallback;
62
+ throw err;
63
+ }
64
+ }
65
+
66
+ // ---------- 单画布(旧接口,过渡期保留) ----------
67
+
68
+ export async function loadBoard() {
69
+ return readJson(BOARD_FILE, null);
70
+ }
71
+
72
+ export async function saveBoard(board) {
73
+ return enqueue(async () => {
74
+ await atomicWrite(BOARD_FILE, JSON.stringify(board, null, 2));
75
+ return board;
76
+ });
77
+ }
78
+
79
+ /** 读-改-写,串行执行,避免并发丢更新。 */
80
+ export async function mutateBoard(fn) {
81
+ return enqueue(async () => {
82
+ const board = (await loadBoard()) || { id: 'demo', name: '未命名', nodes: [], edges: [] };
83
+ const next = await fn(board);
84
+ await atomicWrite(BOARD_FILE, JSON.stringify(next, null, 2));
85
+ return next;
86
+ });
87
+ }
88
+
89
+ // ---------- 配置 ----------
90
+
91
+ export async function loadConfig() {
92
+ return readJson(CONFIG_FILE, null);
93
+ }
94
+
95
+ export async function saveConfig(cfg) {
96
+ return enqueue(async () => {
97
+ // 600:只有本用户可读,key 不外泄
98
+ await atomicWrite(CONFIG_FILE, JSON.stringify(cfg, null, 2), 0o600);
99
+ return cfg;
100
+ });
101
+ }
102
+
103
+ export const files = { BOARD_FILE, CONFIG_FILE };
@@ -0,0 +1,219 @@
1
+ // verify.mjs —— 节点的「结果判断」。
2
+ //
3
+ // 三种模式(用户在卡片详情里选):
4
+ // human :跑完停住,标成 review,等人点通过/打回
5
+ // jev :调 TypeSafe System One 判定(只给概率,不出文)
6
+ // self :同一个模型自评是否达到 criteria
7
+ //
8
+ // TypeSafe 在中国区返回 451(2026-10-19 实测),所以这里支持走 HTTP 代理。
9
+ // Node 自带 fetch 不认代理,为了保持零依赖,这里用裸 http/https 手写 CONNECT 隧道。
10
+
11
+ import http from 'node:http';
12
+ import https from 'node:https';
13
+ import tls from 'node:tls';
14
+
15
+ const JEV_ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
16
+ const JEV_MODEL = 'jev-latest';
17
+
18
+ /**
19
+ * 通过 HTTP 代理发一个 HTTPS POST。
20
+ * 不依赖 undici / https-proxy-agent:CONNECT 拿到裸 socket,再在它上面做 TLS。
21
+ */
22
+ function postJsonViaProxy({ proxyUrl, url, headers, body, timeoutMs = 20000 }) {
23
+ const proxy = new URL(proxyUrl);
24
+ const target = new URL(url);
25
+
26
+ return new Promise((resolve, reject) => {
27
+ let settled = false;
28
+ const done = (fn, arg) => {
29
+ if (settled) return;
30
+ settled = true;
31
+ fn(arg);
32
+ };
33
+
34
+ const timer = setTimeout(() => done(reject, new Error(`代理请求超时 ${timeoutMs}ms`)), timeoutMs);
35
+
36
+ const connectReq = http.request({
37
+ host: proxy.hostname,
38
+ port: proxy.port || 80,
39
+ method: 'CONNECT',
40
+ path: `${target.hostname}:443`,
41
+ headers: { Host: `${target.hostname}:443` },
42
+ });
43
+
44
+ connectReq.on('error', (err) => {
45
+ clearTimeout(timer);
46
+ done(reject, new Error(`代理连接失败:${err.message}`));
47
+ });
48
+
49
+ connectReq.on('connect', (res, socket) => {
50
+ if (res.statusCode !== 200) {
51
+ clearTimeout(timer);
52
+ socket.destroy();
53
+ return done(reject, new Error(`代理拒绝 CONNECT:HTTP ${res.statusCode}`));
54
+ }
55
+
56
+ // 关键:必须用一个自定义 Agent 来接管 socket。
57
+ // 之前用 `agent: false` + createConnection 是不行的 —— agent:false 会新建一个
58
+ // 默认 Agent,createConnection 永远不被调用,结果还是直连,照样 451。
59
+ const agent = new https.Agent({ keepAlive: false });
60
+ agent.createConnection = (opts, cb) => {
61
+ const tlsSocket = tls.connect(
62
+ { socket, servername: target.hostname },
63
+ () => cb(null, tlsSocket)
64
+ );
65
+ tlsSocket.once('error', (err) => cb(err));
66
+ };
67
+
68
+ const req = https.request(
69
+ {
70
+ host: target.hostname,
71
+ path: target.pathname + target.search,
72
+ method: 'POST',
73
+ headers,
74
+ agent,
75
+ },
76
+ (pres) => {
77
+ let text = '';
78
+ pres.setEncoding('utf8');
79
+ pres.on('data', (d) => (text += d));
80
+ pres.on('end', () => {
81
+ clearTimeout(timer);
82
+ agent.destroy();
83
+ done(resolve, { status: pres.statusCode, body: text });
84
+ });
85
+ }
86
+ );
87
+ req.on('error', (err) => {
88
+ clearTimeout(timer);
89
+ agent.destroy();
90
+ done(reject, err);
91
+ });
92
+ req.end(body);
93
+ });
94
+
95
+ connectReq.end();
96
+ });
97
+ }
98
+
99
+ /** 直连(没有配代理时用) */
100
+ async function postJsonDirect({ url, headers, body, timeoutMs = 20000 }) {
101
+ const res = await fetch(url, {
102
+ method: 'POST',
103
+ headers,
104
+ body,
105
+ signal: AbortSignal.timeout(timeoutMs),
106
+ });
107
+ return { status: res.status, body: await res.text() };
108
+ }
109
+
110
+ /**
111
+ * 问一次 Jev:这段内容是否达标。
112
+ * @returns { pass: boolean, score: number, raw: object }
113
+ */
114
+ export async function askJev({ apiKey, proxy, content, criteria, timeoutMs }) {
115
+ if (!apiKey) throw new Error('没有配 jev 的 apiKey');
116
+
117
+ const instructions = criteria
118
+ ? `判断下面这段内容是否达到以下标准:${criteria}`
119
+ : '判断下面这段内容是否完整、可用地完成了一个明确任务';
120
+
121
+ const payload = {
122
+ state: { content: String(content).slice(0, 12000) },
123
+ model: JEV_MODEL,
124
+ questions: {
125
+ ok: {
126
+ type: 'noul',
127
+ instructions,
128
+ },
129
+ },
130
+ };
131
+
132
+ const headers = {
133
+ Authorization: `Bearer ${apiKey}`,
134
+ 'Content-Type': 'application/json',
135
+ 'Content-Length': Buffer.byteLength(JSON.stringify(payload)),
136
+ };
137
+ const body = JSON.stringify(payload);
138
+
139
+ const run = (opts) =>
140
+ proxy
141
+ ? postJsonViaProxy({ ...opts, proxyUrl: proxy, url: JEV_ENDPOINT, headers, body })
142
+ : postJsonDirect({ ...opts, url: JEV_ENDPOINT, headers, body });
143
+
144
+ let res;
145
+ try {
146
+ res = await run({ timeoutMs });
147
+ } catch (err) {
148
+ throw new Error(`Jev 调用失败:${err.message}`);
149
+ }
150
+
151
+ if (res.status === 451) {
152
+ throw new Error('TypeSafe 返回 451(地区封锁),需要在配置里填 HTTP 代理');
153
+ }
154
+ if (res.status < 200 || res.status >= 300) {
155
+ throw new Error(`Jev HTTP ${res.status}:${String(res.body).slice(0, 200)}`);
156
+ }
157
+
158
+ let data;
159
+ try {
160
+ data = JSON.parse(res.body);
161
+ } catch {
162
+ throw new Error(`Jev 返回不是 JSON:${String(res.body).slice(0, 200)}`);
163
+ }
164
+
165
+ // 实测线上形如 { "type": "noul", "noul": 0.95 };也兼容 answers.ok.noul 包一层
166
+ const score =
167
+ Number(data?.noul) ||
168
+ Number(data?.answers?.ok?.noul) ||
169
+ Number(data?.questions?.ok?.noul) ||
170
+ 0;
171
+
172
+ return { pass: score >= 0.5, score, raw: data };
173
+ }
174
+
175
+ /**
176
+ * 模型自评:让同一个模型对照 criteria 检查自己刚写的内容。
177
+ * 复用传入的 callLLM(所以自动带上同一节点的 provider / mock 设置)。
178
+ */
179
+ export async function selfVerify(callLLM, node, content) {
180
+ const judgeNode = { ...node, skills: [], tools: [], verify: '' };
181
+ const prompt = [
182
+ '这是一个【质量检查】任务。你只能判定,绝对不能重写、续写或补充内容。',
183
+ '',
184
+ '【当初的任务要求】',
185
+ node.task || '(无)',
186
+ '',
187
+ '【完成标准】',
188
+ node.criteria || '内容完整、可用地完成了任务',
189
+ '',
190
+ '【需要判定的内容】',
191
+ '<<<',
192
+ String(content).slice(0, 12000),
193
+ '>>>',
194
+ '',
195
+ '只输出一行,不要任何其他文字、不要解释、不要重复内容:',
196
+ 'PASS|不超过30字的理由',
197
+ '或',
198
+ 'FAIL|不超过30字的理由',
199
+ ].join('\n');
200
+
201
+ const res = await callLLM(judgeNode, prompt);
202
+ const text = String(res.content || '').trim();
203
+
204
+ // 宽松取结果:优先看行首,其次找第一个独立出现的关键词
205
+ let verdictWord = '';
206
+ const head = /^(PASS|FAIL)/i.exec(text);
207
+ if (head) verdictWord = head[1].toUpperCase();
208
+ else {
209
+ const anywhere = /\b(PASS|FAIL)\b/i.exec(text.slice(0, 80));
210
+ if (anywhere) verdictWord = anywhere[1].toUpperCase();
211
+ }
212
+
213
+ const pass = verdictWord === 'PASS';
214
+ let reason = text.replace(/^(PASS|FAIL)\s*[||::]?\s*/i, '').replace(/\s+/g, ' ').trim();
215
+ if (!verdictWord) {
216
+ reason = `模型没按格式回答(${text.slice(0, 40)}…),按未通过处理`;
217
+ }
218
+ return { pass, reason: reason.slice(0, 60), raw: text };
219
+ }