mocode-ai 1.7.2 → 1.7.4

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 (43) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/config/index.js +1 -1
  4. package/dist/config/profiles.js +2 -2
  5. package/dist/flows/cli.js +168 -0
  6. package/dist/flows/flow.js +483 -0
  7. package/dist/flows/runner.js +259 -0
  8. package/dist/flows/trace.js +242 -0
  9. package/dist/i18n/index.js +28 -0
  10. package/dist/index.js +7 -0
  11. package/dist/llm/index.js +1 -1
  12. package/dist/permissions/computer-review.js +40 -0
  13. package/dist/permissions/flow-review.js +41 -0
  14. package/dist/permissions/index.js +47 -28
  15. package/dist/repl/commands/flow.js +210 -0
  16. package/dist/repl/commands/registry.js +2 -0
  17. package/dist/repl/commands/tool-group.js +9 -1
  18. package/dist/repl/commands.js +10 -0
  19. package/dist/repl/running-input.js +75 -14
  20. package/dist/repl/runtime.js +4 -1
  21. package/dist/runtime/input-injector.js +39 -26
  22. package/dist/runtime/shutdown.js +3 -1
  23. package/dist/runtime/uia-selector.js +324 -0
  24. package/dist/runtime/uia-service.js +510 -0
  25. package/dist/runtime/wait-until.js +199 -0
  26. package/dist/runtime/window-list.js +164 -0
  27. package/dist/session/gui-actions.js +47 -0
  28. package/dist/tools/builtins/computer.js +647 -10
  29. package/dist/tools/builtins/index.js +4 -0
  30. package/dist/tools/builtins/run-flow.js +123 -0
  31. package/dist/tools/constants.js +1 -0
  32. package/dist/tools/router.js +1 -1
  33. package/dist/tools/validation.js +57 -0
  34. package/dist/ui/composer.js +6 -2
  35. package/dist/ui/layout-internal/content-write.js +74 -6
  36. package/dist/ui/layout-internal/core.js +32 -8
  37. package/dist/ui/layout-internal/screen.js +4 -0
  38. package/dist/ui/layout-internal/scroll.js +5 -3
  39. package/dist/ui/layout-internal/state.js +6 -0
  40. package/dist/ui/layout-internal/statusbar.js +16 -1
  41. package/dist/ui/render.js +19 -11
  42. package/docs/usage.md +344 -344
  43. package/package.json +92 -92
@@ -0,0 +1,483 @@
1
+ /**
2
+ * flow 文件:格式、校验、从 trace 导出、参数模板、存取(design-notes/computer-use-rpa.md §5.2)。
3
+ *
4
+ * flow 是 `.mocode/flows/<name>.json`,人可编辑,是回放的唯一事实源。设计要点:
5
+ * - 步骤是扁平对象:保留键(selector/target/fallback/window/allowCoordinateFallback/fragile/onError/note)
6
+ * 之外的键就是 computer 工具的参数,且只接受 trace 白名单里的参数(parseFlow 拒绝未知字段,
7
+ * 防止手改文件时塞进 ref / 任意字段);
8
+ * - 文本里的 `{{name}}` 是运行时参数;`\{{` 表示字面量 `{{`;
9
+ * - 录制时命中敏感审查的文本以 secret 参数导出,文件里没有明文,也不允许带 default;
10
+ * - 本模块只做纯逻辑 + 文件 I/O,不碰屏幕/UIA,可离线单测。
11
+ */
12
+ import crypto from 'node:crypto';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { computerTargetNeedsReview, computerTextNeedsReview } from '../permissions/computer-review.js';
16
+ import { getSandboxRoot } from '../sandbox/root.js';
17
+ import { RECORDED_ACTIONS, isParamRef, recordedArgKeys } from './trace.js';
18
+ export const FLOW_VERSION = 1;
19
+ export const FLOW_MAX_STEPS = 200;
20
+ export const FLOW_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
21
+ const PARAM_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,31}$/;
22
+ /** 影刀式失败策略的默认值:重试 1 次,仍失败则交还模型。 */
23
+ export const DEFAULT_ON_ERROR = { retry: 1, retryDelayMs: 500, then: 'handoff' };
24
+ export const FLOW_RESERVED_KEYS = new Set([
25
+ 'action',
26
+ 'selector',
27
+ 'target',
28
+ 'fallback',
29
+ 'window',
30
+ 'allowCoordinateFallback',
31
+ 'fragile',
32
+ 'onError',
33
+ 'note',
34
+ ]);
35
+ // ── 模板 ────────────────────────────────────────────────────────────────
36
+ const TEMPLATE_RE = /\\\{\{|\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
37
+ /** 字面量里的 `{{` 导出时转义,避免被当成参数。 */
38
+ export function escapeTemplate(s) {
39
+ return s.replace(/\{\{/g, '\\{{');
40
+ }
41
+ export function templateRefs(s) {
42
+ const out = [];
43
+ for (const m of s.matchAll(TEMPLATE_RE))
44
+ if (m[1])
45
+ out.push(m[1]);
46
+ return out;
47
+ }
48
+ /** 渲染模板;缺值抛错(调用方应先 resolveParams)。 */
49
+ export function renderTemplate(s, values) {
50
+ return s.replace(TEMPLATE_RE, (_m, name) => {
51
+ if (!name)
52
+ return '{{';
53
+ const v = values[name];
54
+ if (v === undefined)
55
+ throw new Error(`missing value for flow parameter "${name}"`);
56
+ return v;
57
+ });
58
+ }
59
+ function mapStrings(v, fn) {
60
+ if (typeof v === 'string')
61
+ return fn(v);
62
+ if (Array.isArray(v))
63
+ return v.map((x) => mapStrings(x, fn));
64
+ if (v && typeof v === 'object') {
65
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, mapStrings(x, fn)]));
66
+ }
67
+ return v;
68
+ }
69
+ function collectStrings(v, out) {
70
+ if (typeof v === 'string')
71
+ out.push(v);
72
+ else if (Array.isArray(v))
73
+ for (const x of v)
74
+ collectStrings(x, out);
75
+ else if (v && typeof v === 'object')
76
+ for (const x of Object.values(v))
77
+ collectStrings(x, out);
78
+ }
79
+ /** 步骤里(仅 computer 参数部分)引用的参数名。 */
80
+ export function stepTemplateRefs(step) {
81
+ const strings = [];
82
+ for (const [k, v] of Object.entries(step))
83
+ if (!FLOW_RESERVED_KEYS.has(k))
84
+ collectStrings(v, strings);
85
+ return strings.flatMap(templateRefs);
86
+ }
87
+ /** 把参数值代入步骤的 computer 参数;保留键(selector 等)原样。 */
88
+ export function renderStep(step, values) {
89
+ const out = { ...step };
90
+ for (const [k, v] of Object.entries(step)) {
91
+ if (!FLOW_RESERVED_KEYS.has(k))
92
+ out[k] = mapStrings(v, (s) => renderTemplate(s, values));
93
+ }
94
+ return out;
95
+ }
96
+ function isRecord(v) {
97
+ return !!v && typeof v === 'object' && !Array.isArray(v);
98
+ }
99
+ const isInt = (v, min, max) => typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max;
100
+ function validRegex(src) {
101
+ try {
102
+ new RegExp(src);
103
+ return true;
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ }
109
+ function checkStep(raw, i, params) {
110
+ const at = `steps[${i}]`;
111
+ if (!isRecord(raw))
112
+ return `${at} must be an object`;
113
+ const action = raw.action;
114
+ if (typeof action !== 'string' || !RECORDED_ACTIONS.has(action)) {
115
+ return `${at}.action must be one of: ${[...RECORDED_ACTIONS].join(', ')}`;
116
+ }
117
+ const allowed = new Set([...FLOW_RESERVED_KEYS, ...recordedArgKeys(action)]);
118
+ for (const k of Object.keys(raw)) {
119
+ if (!allowed.has(k))
120
+ return `${at} has unknown field "${k}" for action "${action}"`;
121
+ }
122
+ if (raw.note !== undefined && typeof raw.note !== 'string')
123
+ return `${at}.note must be a string`;
124
+ for (const flag of ['allowCoordinateFallback', 'fragile']) {
125
+ if (raw[flag] !== undefined && typeof raw[flag] !== 'boolean')
126
+ return `${at}.${flag} must be a boolean`;
127
+ }
128
+ if (raw.onError !== undefined) {
129
+ const oe = raw.onError;
130
+ if (!isRecord(oe))
131
+ return `${at}.onError must be an object`;
132
+ if (oe.retry !== undefined && !isInt(oe.retry, 0, 5))
133
+ return `${at}.onError.retry must be an integer in 0-5`;
134
+ if (oe.retryDelayMs !== undefined && !isInt(oe.retryDelayMs, 0, 10000)) {
135
+ return `${at}.onError.retryDelayMs must be an integer in 0-10000`;
136
+ }
137
+ if (oe.then !== undefined && oe.then !== 'abort' && oe.then !== 'skip' && oe.then !== 'handoff') {
138
+ return `${at}.onError.then must be abort|skip|handoff`;
139
+ }
140
+ }
141
+ if (raw.window !== undefined) {
142
+ const w = raw.window;
143
+ if (!isRecord(w))
144
+ return `${at}.window must be an object`;
145
+ if (w.processName !== undefined && typeof w.processName !== 'string')
146
+ return `${at}.window.processName must be a string`;
147
+ if (w.titleRegex !== undefined && (typeof w.titleRegex !== 'string' || !validRegex(w.titleRegex))) {
148
+ return `${at}.window.titleRegex must be a valid regular expression`;
149
+ }
150
+ }
151
+ if (raw.selector !== undefined) {
152
+ const s = raw.selector;
153
+ if (!isRecord(s) || !isRecord(s.window) || typeof s.window.processName !== 'string') {
154
+ return `${at}.selector must be { window: { processName }, path: [...] }`;
155
+ }
156
+ if (!Array.isArray(s.path) || s.path.length === 0 || !s.path.every(isRecord)) {
157
+ return `${at}.selector.path must be a non-empty array of steps`;
158
+ }
159
+ }
160
+ if (raw.target !== undefined) {
161
+ const t = raw.target;
162
+ if (!isRecord(t) || typeof t.role !== 'string' || typeof t.name !== 'string') {
163
+ return `${at}.target must be { role, name }`;
164
+ }
165
+ }
166
+ if (raw.fallback !== undefined) {
167
+ const f = raw.fallback;
168
+ const c = isRecord(f) ? f.coordinate : undefined;
169
+ if (!Array.isArray(c) || c.length !== 2 || !c.every((n) => isInt(n, 0, 1000))) {
170
+ return `${at}.fallback.coordinate must be [x, y] integers in 0-1000`;
171
+ }
172
+ }
173
+ if ((action === 'click_element' || action === 'set_value') &&
174
+ raw.selector === undefined &&
175
+ raw.fallback === undefined) {
176
+ return `${at} (${action}) needs a selector (or a fallback coordinate)`;
177
+ }
178
+ if (action === 'focus_window') {
179
+ const w = raw.window;
180
+ if (!isRecord(w) || (typeof w.processName !== 'string' && typeof w.titleRegex !== 'string')) {
181
+ return `${at} (focus_window) needs window.processName or window.titleRegex`;
182
+ }
183
+ }
184
+ const step = raw;
185
+ for (const ref of stepTemplateRefs(step)) {
186
+ if (!params[ref])
187
+ return `${at} references undefined parameter "{{${ref}}}"; declare it under "params"`;
188
+ }
189
+ return null;
190
+ }
191
+ export function parseFlow(raw) {
192
+ const fail = (error) => ({ ok: false, error });
193
+ if (!isRecord(raw))
194
+ return fail('flow must be a JSON object');
195
+ if (raw.version !== FLOW_VERSION)
196
+ return fail(`flow.version must be ${FLOW_VERSION}`);
197
+ if (typeof raw.name !== 'string' || !FLOW_NAME_RE.test(raw.name)) {
198
+ return fail('flow.name must match [A-Za-z0-9][A-Za-z0-9_-]{0,63}');
199
+ }
200
+ if (raw.description !== undefined && typeof raw.description !== 'string')
201
+ return fail('flow.description must be a string');
202
+ const params = {};
203
+ if (raw.params !== undefined) {
204
+ if (!isRecord(raw.params))
205
+ return fail('flow.params must be an object');
206
+ for (const [name, p] of Object.entries(raw.params)) {
207
+ if (!PARAM_NAME_RE.test(name))
208
+ return fail(`param name "${name}" must match [A-Za-z][A-Za-z0-9_]{0,31}`);
209
+ if (!isRecord(p))
210
+ return fail(`params.${name} must be an object`);
211
+ if (p.description !== undefined && typeof p.description !== 'string')
212
+ return fail(`params.${name}.description must be a string`);
213
+ if (p.secret !== undefined && typeof p.secret !== 'boolean')
214
+ return fail(`params.${name}.secret must be a boolean`);
215
+ if (p.default !== undefined && typeof p.default !== 'string')
216
+ return fail(`params.${name}.default must be a string`);
217
+ if (p.secret === true && p.default !== undefined)
218
+ return fail(`secret param "${name}" must not have a default`);
219
+ params[name] = {
220
+ ...(p.description !== undefined ? { description: p.description } : {}),
221
+ ...(p.secret === true ? { secret: true } : {}),
222
+ ...(p.default !== undefined ? { default: p.default } : {}),
223
+ };
224
+ }
225
+ }
226
+ if (!Array.isArray(raw.steps) || raw.steps.length === 0)
227
+ return fail('flow.steps must be a non-empty array');
228
+ if (raw.steps.length > FLOW_MAX_STEPS)
229
+ return fail(`flow has ${raw.steps.length} steps; the limit is ${FLOW_MAX_STEPS}`);
230
+ for (let i = 0; i < raw.steps.length; i++) {
231
+ const err = checkStep(raw.steps[i], i, params);
232
+ if (err)
233
+ return fail(err);
234
+ }
235
+ return {
236
+ ok: true,
237
+ flow: {
238
+ version: FLOW_VERSION,
239
+ name: raw.name,
240
+ ...(raw.description !== undefined ? { description: raw.description } : {}),
241
+ params,
242
+ steps: raw.steps,
243
+ },
244
+ };
245
+ }
246
+ /** 合并调用方给的参数与 default;未知参数名与缺失必填都报错(拼写错误不静默)。 */
247
+ export function resolveParams(flow, given) {
248
+ const values = {};
249
+ const input = given ?? {};
250
+ for (const [k, v] of Object.entries(input)) {
251
+ if (!flow.params[k]) {
252
+ const known = Object.keys(flow.params);
253
+ return {
254
+ ok: false,
255
+ error: `unknown parameter "${k}"; this flow declares: ${known.length ? known.join(', ') : '(none)'}`,
256
+ };
257
+ }
258
+ if (typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') {
259
+ return { ok: false, error: `parameter "${k}" must be a string` };
260
+ }
261
+ values[k] = String(v);
262
+ }
263
+ const missing = [];
264
+ for (const [k, p] of Object.entries(flow.params)) {
265
+ if (values[k] === undefined) {
266
+ if (p.default !== undefined)
267
+ values[k] = p.default;
268
+ else
269
+ missing.push(k);
270
+ }
271
+ }
272
+ if (missing.length)
273
+ return { ok: false, error: `missing required parameter(s): ${missing.join(', ')}` };
274
+ return { ok: true, values };
275
+ }
276
+ // ── 敏感审查与摘要 ───────────────────────────────────────────────────────
277
+ const TEXT_ACTIONS = new Set(['type', 'key', 'set_value']);
278
+ /**
279
+ * 这次运行是否必须人工确认(forceOnce 语义)及原因。回放时单步不再弹窗,所以敏感判定必须在启动时
280
+ * 一次做完:敏感文本(含参数代入后的值)、secret 参数、命中删除/发送/支付类关键词的目标元素。
281
+ */
282
+ export function flowReviewReasons(flow, values) {
283
+ const reasons = [];
284
+ flow.steps.forEach((step, idx) => {
285
+ const n = idx + 1;
286
+ const secrets = [...new Set(stepTemplateRefs(step))].filter((r) => flow.params[r]?.secret);
287
+ if (secrets.length)
288
+ reasons.push(`step ${n} uses secret parameter ${secrets.join(', ')}`);
289
+ if (TEXT_ACTIONS.has(step.action) && typeof step.text === 'string') {
290
+ let text = step.text;
291
+ try {
292
+ text = renderTemplate(step.text, values);
293
+ }
294
+ catch {
295
+ /* 缺值由 resolveParams 报错,这里按原文判断 */
296
+ }
297
+ if (computerTextNeedsReview(text))
298
+ reasons.push(`step ${n} types sensitive text`);
299
+ }
300
+ const names = [step.target?.name, ...(step.selector?.path ?? []).map((p) => p.name)].filter((x) => typeof x === 'string' && x.length > 0);
301
+ const hit = names.find((nm) => computerTargetNeedsReview(nm));
302
+ if (hit)
303
+ reasons.push(`step ${n} targets a sensitive element ${JSON.stringify(hit.slice(0, 40))}`);
304
+ });
305
+ return reasons;
306
+ }
307
+ const clip = (s, max) => (s.length > max ? `${s.slice(0, max)}…` : s);
308
+ /** 一行人类可读步骤摘要。文本按模板原样展示(secret 只会显示 `{{text_7}}`,不含取值)。 */
309
+ export function describeStep(step, index) {
310
+ const parts = [`${index}. ${step.action}`];
311
+ if (step.target)
312
+ parts.push(`${step.target.role} ${JSON.stringify(clip(step.target.name, 40))}`);
313
+ if (step.window) {
314
+ parts.push([step.window.processName, step.window.titleRegex ? `/${step.window.titleRegex}/` : undefined]
315
+ .filter(Boolean)
316
+ .join(' '));
317
+ }
318
+ if (typeof step.text === 'string')
319
+ parts.push(JSON.stringify(clip(step.text.replace(/\s+/g, ' '), 60)));
320
+ if (Array.isArray(step.coordinate))
321
+ parts.push(`@(${step.coordinate.join(', ')})`);
322
+ if (isRecord(step.condition) && typeof step.condition.kind === 'string') {
323
+ const sel = typeof step.condition.selector_text === 'string' ? ` ${step.condition.selector_text}` : '';
324
+ parts.push(`${step.condition.kind}${sel}`);
325
+ }
326
+ if (step.action === 'wait' && typeof step.duration_ms === 'number')
327
+ parts.push(`${step.duration_ms}ms`);
328
+ const flags = [];
329
+ if (step.fragile)
330
+ flags.push('coordinate-based');
331
+ if (step.allowCoordinateFallback)
332
+ flags.push('coordinate fallback allowed');
333
+ if (step.onError?.then)
334
+ flags.push(`on error: ${step.onError.then}`);
335
+ return flags.length ? `${parts.join(' ')} (${flags.join(', ')})` : parts.join(' ');
336
+ }
337
+ export function summarizeFlow(flow) {
338
+ const params = Object.entries(flow.params).map(([k, p]) => `${k}${p.secret ? ' (secret)' : p.default !== undefined ? ` (default ${JSON.stringify(clip(p.default, 30))})` : ' (required)'}`);
339
+ const lines = [
340
+ `Flow "${flow.name}"${flow.description ? ` — ${flow.description}` : ''}: ${flow.steps.length} steps`,
341
+ `Parameters: ${params.length ? params.join(', ') : '(none)'}`,
342
+ ...flow.steps.map((s, i) => describeStep(s, i + 1)),
343
+ ];
344
+ return lines.join('\n');
345
+ }
346
+ const WAIT_ACTIONS = new Set(['wait', 'wait_until']);
347
+ /** 界面变化后自动补的稳定等待:超时只跳过,不让回放因为"一直在动的界面"中止。 */
348
+ const AUTO_WAIT = {
349
+ action: 'wait_until',
350
+ condition: { kind: 'stable' },
351
+ timeout_ms: 5000,
352
+ onError: { retry: 0, then: 'skip' },
353
+ note: 'auto-inserted after a step that changed the screen',
354
+ };
355
+ export function exportFlowFromTrace(entries, opts) {
356
+ if (!FLOW_NAME_RE.test(opts.name)) {
357
+ return { ok: false, error: 'flow name must match [A-Za-z0-9][A-Za-z0-9_-]{0,63}' };
358
+ }
359
+ const picked = [...entries]
360
+ .filter((e) => (opts.from === undefined || e.seq >= opts.from) && (opts.to === undefined || e.seq <= opts.to))
361
+ .sort((a, b) => a.seq - b.seq);
362
+ if (picked.length === 0) {
363
+ return {
364
+ ok: false,
365
+ error: 'no recorded steps in this session (or in the --from/--to range); run some computer actions first',
366
+ };
367
+ }
368
+ const params = {};
369
+ const steps = [];
370
+ picked.forEach((e, idx) => {
371
+ const step = { action: e.action };
372
+ for (const [k, v] of Object.entries(e.args)) {
373
+ if (isParamRef(v)) {
374
+ step[k] = `{{${v.$param}}}`;
375
+ params[v.$param] = {
376
+ description: `sensitive text recorded at step ${e.seq}; supply it when running the flow`,
377
+ secret: true,
378
+ };
379
+ }
380
+ else {
381
+ step[k] = mapStrings(v, escapeTemplate);
382
+ }
383
+ }
384
+ if (e.selector)
385
+ step.selector = e.selector;
386
+ if (e.target)
387
+ step.target = e.target;
388
+ if (e.fallback)
389
+ step.fallback = e.fallback;
390
+ if (e.window)
391
+ step.window = e.window;
392
+ if (e.fragile)
393
+ step.fragile = true;
394
+ steps.push(step);
395
+ const next = picked[idx + 1];
396
+ if (e.changed && next && !WAIT_ACTIONS.has(next.action) && !WAIT_ACTIONS.has(e.action)) {
397
+ steps.push({ ...AUTO_WAIT });
398
+ }
399
+ });
400
+ return parseFlow({
401
+ version: FLOW_VERSION,
402
+ name: opts.name,
403
+ ...(opts.description ? { description: opts.description } : {}),
404
+ params,
405
+ steps,
406
+ });
407
+ }
408
+ // ── 存取 ────────────────────────────────────────────────────────────────
409
+ export function flowsDir() {
410
+ return path.join(getSandboxRoot() ?? process.cwd(), '.mocode', 'flows');
411
+ }
412
+ function flowPath(name) {
413
+ return FLOW_NAME_RE.test(name) ? path.join(flowsDir(), `${name}.json`) : null;
414
+ }
415
+ /** 原子写(tmp + rename)。已存在默认拒绝覆盖。 */
416
+ export function saveFlow(flow, opts = {}) {
417
+ const file = flowPath(flow.name);
418
+ if (!file)
419
+ return { ok: false, error: 'invalid flow name' };
420
+ if (!opts.overwrite && fs.existsSync(file)) {
421
+ return { ok: false, error: `flow "${flow.name}" already exists; choose another name or pass --force to overwrite` };
422
+ }
423
+ try {
424
+ fs.mkdirSync(path.dirname(file), { recursive: true });
425
+ const tmp = `${file}.${process.pid}.tmp`;
426
+ fs.writeFileSync(tmp, `${JSON.stringify(flow, null, 2)}\n`, 'utf8');
427
+ fs.renameSync(tmp, file);
428
+ return { ok: true, path: file };
429
+ }
430
+ catch (error) {
431
+ return { ok: false, error: `could not write flow: ${error instanceof Error ? error.message : String(error)}` };
432
+ }
433
+ }
434
+ /** 读取并校验;hash 是文件原文的 sha256,供权限指纹绑定"这一版 flow"。 */
435
+ export function loadFlow(name) {
436
+ const file = flowPath(name);
437
+ if (!file)
438
+ return { ok: false, error: 'invalid flow name' };
439
+ let raw;
440
+ try {
441
+ raw = fs.readFileSync(file, 'utf8');
442
+ }
443
+ catch {
444
+ return { ok: false, error: `flow "${name}" not found (looked in ${flowsDir()})` };
445
+ }
446
+ let json;
447
+ try {
448
+ json = JSON.parse(raw);
449
+ }
450
+ catch (error) {
451
+ return {
452
+ ok: false,
453
+ error: `flow "${name}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
454
+ };
455
+ }
456
+ const parsed = parseFlow(json);
457
+ if (!parsed.ok)
458
+ return { ok: false, error: `flow "${name}" is invalid: ${parsed.error}` };
459
+ if (parsed.flow.name !== name) {
460
+ return { ok: false, error: `flow file "${name}.json" declares name "${parsed.flow.name}"; they must match` };
461
+ }
462
+ return { ok: true, flow: parsed.flow, hash: crypto.createHash('sha256').update(raw).digest('hex'), path: file };
463
+ }
464
+ /** 列出可用 flow;无法解析的文件跳过(用 /flow show 看具体错误)。 */
465
+ export function listFlows() {
466
+ let files;
467
+ try {
468
+ files = fs.readdirSync(flowsDir());
469
+ }
470
+ catch {
471
+ return [];
472
+ }
473
+ const out = [];
474
+ for (const f of files) {
475
+ if (!f.endsWith('.json'))
476
+ continue;
477
+ const loaded = loadFlow(f.slice(0, -'.json'.length));
478
+ if (loaded.ok) {
479
+ out.push({ name: loaded.flow.name, description: loaded.flow.description, steps: loaded.flow.steps.length });
480
+ }
481
+ }
482
+ return out.sort((a, b) => a.name.localeCompare(b.name));
483
+ }