mocode-ai 1.7.2 → 1.7.5
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 +3 -3
- package/README.zh-CN.md +3 -3
- package/dist/config/index.js +1 -1
- package/dist/config/profiles.js +2 -2
- package/dist/flows/cli.js +168 -0
- package/dist/flows/flow.js +483 -0
- package/dist/flows/runner.js +259 -0
- package/dist/flows/trace.js +242 -0
- package/dist/i18n/index.js +28 -0
- package/dist/index.js +7 -0
- package/dist/llm/index.js +1 -1
- package/dist/permissions/computer-review.js +40 -0
- package/dist/permissions/flow-review.js +41 -0
- package/dist/permissions/index.js +47 -28
- package/dist/repl/commands/flow.js +210 -0
- package/dist/repl/commands/registry.js +2 -0
- package/dist/repl/commands/tool-group.js +9 -1
- package/dist/repl/commands.js +10 -0
- package/dist/repl/running-input.js +75 -14
- package/dist/repl/runtime.js +4 -1
- package/dist/runtime/input-injector.js +39 -26
- package/dist/runtime/shutdown.js +3 -1
- package/dist/runtime/uia-selector.js +324 -0
- package/dist/runtime/uia-service.js +510 -0
- package/dist/runtime/wait-until.js +199 -0
- package/dist/runtime/window-list.js +164 -0
- package/dist/session/compact.js +1 -1
- package/dist/session/gui-actions.js +47 -0
- package/dist/tools/builtins/computer.js +647 -10
- package/dist/tools/builtins/index.js +4 -0
- package/dist/tools/builtins/read-file.js +54 -3
- package/dist/tools/builtins/run-flow.js +123 -0
- package/dist/tools/constants.js +8 -2
- package/dist/tools/router.js +1 -1
- package/dist/tools/validation.js +57 -0
- package/dist/ui/composer.js +6 -2
- package/dist/ui/layout-internal/content-write.js +74 -6
- package/dist/ui/layout-internal/core.js +32 -8
- package/dist/ui/layout-internal/screen.js +4 -0
- package/dist/ui/layout-internal/scroll.js +5 -3
- package/dist/ui/layout-internal/state.js +6 -0
- package/dist/ui/layout-internal/statusbar.js +16 -1
- package/dist/ui/render.js +19 -11
- package/docs/usage.md +344 -344
- package/package.json +92 -92
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { DEFAULT_ON_ERROR, FLOW_RESERVED_KEYS, describeStep, renderStep, resolveParams, } from './flow.js';
|
|
2
|
+
/** 低于此长度的 secret 不打码(1-2 个字符会把无关文本也替换花)。 */
|
|
3
|
+
const REDACT_MIN_LEN = 3;
|
|
4
|
+
const ERROR_CLIP = 400;
|
|
5
|
+
/** 步骤里的 computer 参数(去掉 flow 保留键)。 */
|
|
6
|
+
function computerArgs(step) {
|
|
7
|
+
const out = { action: step.action };
|
|
8
|
+
for (const [k, v] of Object.entries(step))
|
|
9
|
+
if (!FLOW_RESERVED_KEYS.has(k))
|
|
10
|
+
out[k] = v;
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
/** selector 失败且显式允许时,把元素步骤改写成按录制坐标执行的动作序列。 */
|
|
14
|
+
function coordinateFallbackActions(step) {
|
|
15
|
+
const coordinate = step.fallback?.coordinate;
|
|
16
|
+
if (!coordinate)
|
|
17
|
+
return [];
|
|
18
|
+
if (step.action === 'click_element') {
|
|
19
|
+
const button = step.button;
|
|
20
|
+
const count = step.click_count;
|
|
21
|
+
let action = 'left_click';
|
|
22
|
+
if (button === 'right')
|
|
23
|
+
action = 'right_click';
|
|
24
|
+
else if (button === 'middle')
|
|
25
|
+
action = 'middle_click';
|
|
26
|
+
else if (count === 2)
|
|
27
|
+
action = 'double_click';
|
|
28
|
+
else if (count === 3)
|
|
29
|
+
action = 'triple_click';
|
|
30
|
+
return [{ action, coordinate }];
|
|
31
|
+
}
|
|
32
|
+
if (step.action === 'set_value') {
|
|
33
|
+
return [
|
|
34
|
+
{ action: 'left_click', coordinate },
|
|
35
|
+
{ action: 'key', text: 'ctrl+a' },
|
|
36
|
+
{ action: 'type', text: typeof step.text === 'string' ? step.text : '' },
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
function isElementStep(step) {
|
|
42
|
+
return step.action === 'click_element' || step.action === 'set_value';
|
|
43
|
+
}
|
|
44
|
+
async function attemptStep(step, deps, windowTitleRegex) {
|
|
45
|
+
try {
|
|
46
|
+
let argSets;
|
|
47
|
+
let note;
|
|
48
|
+
if (isElementStep(step)) {
|
|
49
|
+
const selector = step.selector && windowTitleRegex !== undefined
|
|
50
|
+
? { ...step.selector, window: { ...step.selector.window, titleRegex: windowTitleRegex } }
|
|
51
|
+
: step.selector;
|
|
52
|
+
const located = selector
|
|
53
|
+
? await deps.resolveRef(selector, deps.signal)
|
|
54
|
+
: { ok: false, error: 'this step has no selector' };
|
|
55
|
+
if (located.ok) {
|
|
56
|
+
argSets = [{ ...computerArgs(step), ref: located.ref }];
|
|
57
|
+
}
|
|
58
|
+
else if (step.allowCoordinateFallback === true && step.fallback) {
|
|
59
|
+
argSets = coordinateFallbackActions(step);
|
|
60
|
+
note = `selector failed (${located.error}); used the recorded coordinates`;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const hint = step.fallback
|
|
64
|
+
? ' (a recorded coordinate exists, but allowCoordinateFallback is not enabled for this step)'
|
|
65
|
+
: '';
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: `could not locate the element: ${located.error}${hint}`,
|
|
69
|
+
aborted: false,
|
|
70
|
+
retryable: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (step.action === 'focus_window') {
|
|
75
|
+
const win = step.window && windowTitleRegex !== undefined ? { ...step.window, titleRegex: windowTitleRegex } : step.window;
|
|
76
|
+
if (win?.processName) {
|
|
77
|
+
const resolved = await deps.resolveWindow(win, deps.signal);
|
|
78
|
+
if (!resolved.ok)
|
|
79
|
+
return { ok: false, error: resolved.error, aborted: false, retryable: true };
|
|
80
|
+
argSets = [{ ...computerArgs(step), ...resolved.args }];
|
|
81
|
+
}
|
|
82
|
+
else if (win?.titleRegex) {
|
|
83
|
+
argSets = [{ ...computerArgs(step), title_regex: win.titleRegex }];
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
return { ok: false, error: 'focus_window step has no window information', aborted: false, retryable: false };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
argSets = [computerArgs(step)];
|
|
91
|
+
}
|
|
92
|
+
let last;
|
|
93
|
+
for (const args of argSets) {
|
|
94
|
+
last = await deps.execute(args, deps.signal);
|
|
95
|
+
if (last.status !== 'success')
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
if (!last)
|
|
99
|
+
return { ok: false, error: 'nothing to execute for this step', aborted: false, retryable: false };
|
|
100
|
+
if (last.status === 'success')
|
|
101
|
+
return { ok: true, outcome: last, note };
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: last.output,
|
|
105
|
+
aborted: last.status === 'aborted',
|
|
106
|
+
retryable: last.code !== 'TIMEOUT' && last.status !== 'denied' && last.status !== 'aborted',
|
|
107
|
+
outcome: last,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
const aborted = deps.signal?.aborted === true;
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: error instanceof Error ? error.message : String(error),
|
|
115
|
+
aborted,
|
|
116
|
+
retryable: !aborted,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export async function runFlow(flow, given, deps, options = {}) {
|
|
121
|
+
const resolved = resolveParams(flow, given);
|
|
122
|
+
if (!resolved.ok)
|
|
123
|
+
return { status: 'rejected', steps: [], completed: 0, skipped: 0, error: resolved.error };
|
|
124
|
+
const values = resolved.values;
|
|
125
|
+
const secretValues = Object.entries(flow.params)
|
|
126
|
+
.filter(([, p]) => p.secret)
|
|
127
|
+
.map(([name]) => values[name])
|
|
128
|
+
.filter((v) => typeof v === 'string' && v.length >= REDACT_MIN_LEN);
|
|
129
|
+
const redact = (text) => {
|
|
130
|
+
let out = text;
|
|
131
|
+
for (const s of secretValues)
|
|
132
|
+
out = out.split(s).join('***');
|
|
133
|
+
return out.length > ERROR_CLIP ? `${out.slice(0, ERROR_CLIP)}…` : out;
|
|
134
|
+
};
|
|
135
|
+
const steps = [];
|
|
136
|
+
let completed = 0;
|
|
137
|
+
let skipped = 0;
|
|
138
|
+
let attachments;
|
|
139
|
+
const finish = (status, failedStep, error) => ({
|
|
140
|
+
status,
|
|
141
|
+
steps,
|
|
142
|
+
completed,
|
|
143
|
+
skipped,
|
|
144
|
+
...(failedStep ? { failedStep } : {}),
|
|
145
|
+
...(error ? { error } : {}),
|
|
146
|
+
...(attachments ? { attachments } : {}),
|
|
147
|
+
});
|
|
148
|
+
for (let i = 0; i < flow.steps.length; i++) {
|
|
149
|
+
const index = i + 1;
|
|
150
|
+
if (deps.signal?.aborted)
|
|
151
|
+
return finish('aborted');
|
|
152
|
+
const original = flow.steps[i];
|
|
153
|
+
let step;
|
|
154
|
+
try {
|
|
155
|
+
step = renderStep(original, values);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
return finish('rejected', undefined, error instanceof Error ? error.message : String(error));
|
|
159
|
+
}
|
|
160
|
+
const policy = { ...DEFAULT_ON_ERROR, ...original.onError };
|
|
161
|
+
const maxAttempts = policy.retry + 1;
|
|
162
|
+
let attempts = 0;
|
|
163
|
+
let success;
|
|
164
|
+
let failure;
|
|
165
|
+
while (attempts < maxAttempts) {
|
|
166
|
+
attempts += 1;
|
|
167
|
+
const r = await attemptStep(step, deps, options.windowTitleRegex);
|
|
168
|
+
if (r.ok) {
|
|
169
|
+
success = r;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
failure = r;
|
|
173
|
+
if (r.outcome?.modelAttachments?.length)
|
|
174
|
+
attachments = r.outcome.modelAttachments;
|
|
175
|
+
if (r.aborted)
|
|
176
|
+
return finish('aborted');
|
|
177
|
+
if (!r.retryable || attempts >= maxAttempts)
|
|
178
|
+
break;
|
|
179
|
+
try {
|
|
180
|
+
await deps.sleep(policy.retryDelayMs, deps.signal);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return finish('aborted');
|
|
184
|
+
}
|
|
185
|
+
if (deps.signal?.aborted)
|
|
186
|
+
return finish('aborted');
|
|
187
|
+
}
|
|
188
|
+
if (success) {
|
|
189
|
+
completed += 1;
|
|
190
|
+
if (success.outcome.modelAttachments?.length)
|
|
191
|
+
attachments = success.outcome.modelAttachments;
|
|
192
|
+
steps.push({
|
|
193
|
+
index,
|
|
194
|
+
action: original.action,
|
|
195
|
+
status: 'ok',
|
|
196
|
+
attempts,
|
|
197
|
+
...(success.note ? { detail: redact(success.note) } : {}),
|
|
198
|
+
});
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const error = redact(failure?.error ?? 'unknown error');
|
|
202
|
+
if (policy.then === 'skip') {
|
|
203
|
+
skipped += 1;
|
|
204
|
+
steps.push({ index, action: original.action, status: 'skipped', attempts, detail: error });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
steps.push({ index, action: original.action, status: 'failed', attempts, detail: error });
|
|
208
|
+
return finish(policy.then === 'abort' ? 'failed' : 'handoff', {
|
|
209
|
+
index,
|
|
210
|
+
description: describeStep(original, index),
|
|
211
|
+
error,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return finish('completed');
|
|
215
|
+
}
|
|
216
|
+
/** 面向模型/用户的结果文本。 */
|
|
217
|
+
export function formatFlowRunResult(flow, r) {
|
|
218
|
+
const total = flow.steps.length;
|
|
219
|
+
const skippedLines = r.steps
|
|
220
|
+
.filter((s) => s.status === 'skipped')
|
|
221
|
+
.map((s) => ` - step ${s.index} (${s.action}) skipped: ${s.detail ?? 'failed'}`);
|
|
222
|
+
const noteLines = r.steps
|
|
223
|
+
.filter((s) => s.status === 'ok' && s.detail)
|
|
224
|
+
.map((s) => ` - step ${s.index} (${s.action}): ${s.detail}`);
|
|
225
|
+
switch (r.status) {
|
|
226
|
+
case 'rejected':
|
|
227
|
+
return `Flow "${flow.name}" was not run: ${r.error ?? 'invalid parameters'}`;
|
|
228
|
+
case 'completed': {
|
|
229
|
+
const lines = [
|
|
230
|
+
`Flow "${flow.name}" completed: ${r.completed}/${total} steps ran${r.skipped ? `, ${r.skipped} skipped` : ''}. ` +
|
|
231
|
+
'The attached screenshot (if any) is the final screen; verify the result.',
|
|
232
|
+
...skippedLines,
|
|
233
|
+
...noteLines,
|
|
234
|
+
];
|
|
235
|
+
return lines.join('\n');
|
|
236
|
+
}
|
|
237
|
+
case 'aborted':
|
|
238
|
+
return `Flow "${flow.name}" was interrupted after ${r.completed} of ${total} steps.`;
|
|
239
|
+
case 'failed':
|
|
240
|
+
case 'handoff': {
|
|
241
|
+
const f = r.failedStep;
|
|
242
|
+
const head = r.status === 'handoff'
|
|
243
|
+
? `Flow "${flow.name}" stopped at step ${f?.index}/${total} and is handed back to you.`
|
|
244
|
+
: `Flow "${flow.name}" failed at step ${f?.index}/${total} (onError: abort); no further steps were run.`;
|
|
245
|
+
const lines = [
|
|
246
|
+
head,
|
|
247
|
+
`Step: ${f?.description ?? '(unknown)'}`,
|
|
248
|
+
`Error: ${f?.error ?? 'unknown'}`,
|
|
249
|
+
`Completed before the failure: ${r.completed} step(s)${r.skipped ? `, ${r.skipped} skipped` : ''}.`,
|
|
250
|
+
...skippedLines,
|
|
251
|
+
...noteLines,
|
|
252
|
+
];
|
|
253
|
+
if (r.status === 'handoff') {
|
|
254
|
+
lines.push('The attached screenshot (if any) shows the current screen. Continue the remaining steps manually with computer actions (inspect first), or fix the flow file and run it again.');
|
|
255
|
+
}
|
|
256
|
+
return lines.join('\n');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* computer 动作的结构化录制(design-notes/computer-use-rpa.md §5.1)。
|
|
3
|
+
*
|
|
4
|
+
* 与 session/gui-actions.ts 的区别:台账是给模型看的有损文本(24 条滚动);trace 是给 flow 导出用的
|
|
5
|
+
* 结构化 JSONL(`gui-trace.jsonl`,同会话目录),元素动作落盘的是 selector 而不是会过期的 ref。
|
|
6
|
+
*
|
|
7
|
+
* 契约:
|
|
8
|
+
* - 纯函数 buildTraceEntry 不碰盘、不碰屏幕,可用 fixture 单测;
|
|
9
|
+
* - 观察类动作(screenshot/zoom/inspect/list_windows/cursor_position)不入 trace;
|
|
10
|
+
* - 命中 computerTextNeedsReview 的文本**不落明文**,记为 `{ $param: "text_<seq>" }`,回放时由调用方提供;
|
|
11
|
+
* - 写入失败静默:trace 是可观测性,绝不能影响 computer 动作结果。
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { computerTextNeedsReview } from '../permissions/computer-review.js';
|
|
16
|
+
import { getSandboxRoot } from '../sandbox/root.js';
|
|
17
|
+
import { getCurrentSessionId } from '../session/state.js';
|
|
18
|
+
import { buildSelector, rectToNorm, } from '../runtime/uia-selector.js';
|
|
19
|
+
export const TRACE_FILENAME = 'gui-trace.jsonl';
|
|
20
|
+
export function isParamRef(v) {
|
|
21
|
+
return !!v && typeof v === 'object' && typeof v.$param === 'string';
|
|
22
|
+
}
|
|
23
|
+
const KEEP_ARGS = {
|
|
24
|
+
mouse_move: ['coordinate'],
|
|
25
|
+
left_click: ['coordinate'],
|
|
26
|
+
right_click: ['coordinate'],
|
|
27
|
+
middle_click: ['coordinate'],
|
|
28
|
+
double_click: ['coordinate'],
|
|
29
|
+
triple_click: ['coordinate'],
|
|
30
|
+
left_mouse_down: ['coordinate'],
|
|
31
|
+
left_mouse_up: ['coordinate'],
|
|
32
|
+
left_click_drag: ['coordinate', 'coordinate_to'],
|
|
33
|
+
scroll: ['coordinate', 'scroll_direction', 'scroll_amount'],
|
|
34
|
+
type: ['text', 'method'],
|
|
35
|
+
key: ['text'],
|
|
36
|
+
wait: ['duration_ms'],
|
|
37
|
+
wait_until: ['condition', 'timeout_ms'],
|
|
38
|
+
click_element: ['button', 'click_count', 'via'],
|
|
39
|
+
set_value: ['text', 'via'],
|
|
40
|
+
focus_window: ['move_to_primary'],
|
|
41
|
+
};
|
|
42
|
+
/** 会入 trace 的动作集合。 */
|
|
43
|
+
export const RECORDED_ACTIONS = new Set(Object.keys(KEEP_ARGS));
|
|
44
|
+
export function isRecordedAction(action) {
|
|
45
|
+
return RECORDED_ACTIONS.has(action);
|
|
46
|
+
}
|
|
47
|
+
const COORDINATE_ACTIONS = new Set([
|
|
48
|
+
'mouse_move',
|
|
49
|
+
'left_click',
|
|
50
|
+
'right_click',
|
|
51
|
+
'middle_click',
|
|
52
|
+
'double_click',
|
|
53
|
+
'triple_click',
|
|
54
|
+
'left_mouse_down',
|
|
55
|
+
'left_mouse_up',
|
|
56
|
+
'left_click_drag',
|
|
57
|
+
'scroll',
|
|
58
|
+
]);
|
|
59
|
+
const TEXT_ACTIONS = new Set(['type', 'key', 'set_value']);
|
|
60
|
+
/**
|
|
61
|
+
* 把一次(即将执行的)computer 调用转成 trace 条目。返回 null = 无法形成可回放的步骤(调用方不记录)。
|
|
62
|
+
* 必须在执行**前**调用:此时 elementSnapshot / windowSnapshot / ref 登记表还是模型当时所见。
|
|
63
|
+
*/
|
|
64
|
+
export function buildTraceEntry(args, ctx) {
|
|
65
|
+
const action = typeof args.action === 'string' ? args.action : '';
|
|
66
|
+
const keep = KEEP_ARGS[action];
|
|
67
|
+
if (!keep)
|
|
68
|
+
return null;
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const k of keep)
|
|
71
|
+
if (args[k] !== undefined)
|
|
72
|
+
out[k] = args[k];
|
|
73
|
+
const entry = {
|
|
74
|
+
seq: ctx.seq,
|
|
75
|
+
ts: ctx.ts ?? new Date().toISOString(),
|
|
76
|
+
action,
|
|
77
|
+
args: out,
|
|
78
|
+
};
|
|
79
|
+
// 文本:敏感内容不落明文。
|
|
80
|
+
if (TEXT_ACTIONS.has(action) && typeof out.text === 'string' && computerTextNeedsReview(out.text)) {
|
|
81
|
+
out.text = { $param: `text_${ctx.seq}` };
|
|
82
|
+
}
|
|
83
|
+
if (COORDINATE_ACTIONS.has(action))
|
|
84
|
+
entry.fragile = true;
|
|
85
|
+
if (action === 'click_element' || action === 'set_value') {
|
|
86
|
+
const el = ctx.element;
|
|
87
|
+
const node = el?.tree.nodes[el.nodeId];
|
|
88
|
+
if (!el || !node)
|
|
89
|
+
return null;
|
|
90
|
+
entry.target = { role: node.role, name: node.name };
|
|
91
|
+
const selector = buildSelector(el.tree, el.nodeId);
|
|
92
|
+
if (selector)
|
|
93
|
+
entry.selector = selector;
|
|
94
|
+
else
|
|
95
|
+
entry.fragile = true;
|
|
96
|
+
if (ctx.geometry) {
|
|
97
|
+
const [x, y, w, h] = rectToNorm(node.rect, ctx.geometry);
|
|
98
|
+
entry.fallback = { coordinate: [Math.round(x + w / 2), Math.round(y + h / 2)] };
|
|
99
|
+
}
|
|
100
|
+
if (!entry.selector && !entry.fallback)
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
if (action === 'focus_window') {
|
|
104
|
+
const titleRegex = typeof args.title_regex === 'string' ? args.title_regex : undefined;
|
|
105
|
+
if (!ctx.windowProcess && !titleRegex)
|
|
106
|
+
return null;
|
|
107
|
+
entry.window = {
|
|
108
|
+
...(ctx.windowProcess ? { processName: ctx.windowProcess } : {}),
|
|
109
|
+
...(titleRegex ? { titleRegex } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (action === 'wait_until') {
|
|
113
|
+
const cond = args.condition;
|
|
114
|
+
if (!cond || typeof cond !== 'object')
|
|
115
|
+
return null;
|
|
116
|
+
const c = { ...cond };
|
|
117
|
+
if (typeof c.ref === 'string') {
|
|
118
|
+
const known = ctx.lookupRef?.(c.ref);
|
|
119
|
+
if (!known)
|
|
120
|
+
return null;
|
|
121
|
+
delete c.ref;
|
|
122
|
+
c.selector_text = `${known.role}:${JSON.stringify(known.name)}`;
|
|
123
|
+
}
|
|
124
|
+
out.condition = c;
|
|
125
|
+
}
|
|
126
|
+
return entry;
|
|
127
|
+
}
|
|
128
|
+
/** 本步是否改变了界面:wait/wait_until 的"重截屏"只是观察,不算。 */
|
|
129
|
+
export function outputIndicatesChange(action, output) {
|
|
130
|
+
if (action === 'wait' || action === 'wait_until')
|
|
131
|
+
return false;
|
|
132
|
+
return output.includes('Screen re-captured');
|
|
133
|
+
}
|
|
134
|
+
// ── 存储 ────────────────────────────────────────────────────────────────
|
|
135
|
+
export function traceEnabled() {
|
|
136
|
+
const v = (process.env.MOCODE_FLOW_TRACE ?? '').trim().toLowerCase();
|
|
137
|
+
return v !== 'false' && v !== '0' && v !== 'off';
|
|
138
|
+
}
|
|
139
|
+
export function getTraceFilePath(sessionId = getCurrentSessionId()) {
|
|
140
|
+
if (!sessionId)
|
|
141
|
+
return null;
|
|
142
|
+
const root = getSandboxRoot() ?? process.cwd();
|
|
143
|
+
return path.join(root, '.mocode', 'sessions', sessionId, TRACE_FILENAME);
|
|
144
|
+
}
|
|
145
|
+
/** 读整个 trace;文件缺失/坏行一律跳过。 */
|
|
146
|
+
export function readTrace(sessionId = getCurrentSessionId()) {
|
|
147
|
+
const file = getTraceFilePath(sessionId);
|
|
148
|
+
if (!file)
|
|
149
|
+
return [];
|
|
150
|
+
let raw;
|
|
151
|
+
try {
|
|
152
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const line of raw.split('\n')) {
|
|
159
|
+
const s = line.trim();
|
|
160
|
+
if (!s)
|
|
161
|
+
continue;
|
|
162
|
+
try {
|
|
163
|
+
const e = JSON.parse(s);
|
|
164
|
+
if (e && typeof e.seq === 'number' && typeof e.action === 'string' && e.args && typeof e.args === 'object') {
|
|
165
|
+
out.push(e);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* 坏行跳过 */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
/** 下一个序号 = 现有最大 seq + 1(无文件为 1)。 */
|
|
175
|
+
export function nextTraceSeq(sessionId = getCurrentSessionId()) {
|
|
176
|
+
let max = 0;
|
|
177
|
+
for (const e of readTrace(sessionId))
|
|
178
|
+
if (e.seq > max)
|
|
179
|
+
max = e.seq;
|
|
180
|
+
return max + 1;
|
|
181
|
+
}
|
|
182
|
+
/** trace 单文件大小上限(字节):超过即裁剪到最近 TRACE_KEEP_ENTRIES 条。flow 导出只需要最近一段操作。 */
|
|
183
|
+
export const TRACE_MAX_BYTES = 256 * 1024;
|
|
184
|
+
export const TRACE_KEEP_ENTRIES = 200;
|
|
185
|
+
/**
|
|
186
|
+
* 文件超过上限时只保留最近 `keep` 条(tmp+rename 原子写)。seq 不重编号——导出按 seq 取范围,
|
|
187
|
+
* nextTraceSeq 取现存最大值 +1,裁掉旧条目不影响后续序号单调递增。
|
|
188
|
+
* 会话整体归档/清除(session/retention.ts)时 trace 随会话目录一起删除:已固化的 flow 在 `.mocode/flows/`,不受影响。
|
|
189
|
+
*/
|
|
190
|
+
export function trimTraceFile(file, maxBytes = TRACE_MAX_BYTES, keep = TRACE_KEEP_ENTRIES) {
|
|
191
|
+
try {
|
|
192
|
+
if (fs.statSync(file).size <= maxBytes)
|
|
193
|
+
return false;
|
|
194
|
+
const lines = fs
|
|
195
|
+
.readFileSync(file, 'utf8')
|
|
196
|
+
.split('\n')
|
|
197
|
+
.filter((l) => l.trim());
|
|
198
|
+
if (lines.length <= keep)
|
|
199
|
+
return false;
|
|
200
|
+
const tmp = `${file}.tmp`;
|
|
201
|
+
fs.writeFileSync(tmp, `${lines.slice(-keep).join('\n')}\n`, 'utf8');
|
|
202
|
+
fs.renameSync(tmp, file);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** 追加一条;失败静默,返回是否写入成功。超过大小上限时顺带裁剪旧条目。 */
|
|
210
|
+
export function appendTrace(entry, sessionId = getCurrentSessionId()) {
|
|
211
|
+
const file = getTraceFilePath(sessionId);
|
|
212
|
+
if (!file)
|
|
213
|
+
return false;
|
|
214
|
+
try {
|
|
215
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
216
|
+
fs.appendFileSync(file, `${JSON.stringify(entry)}\n`, 'utf8');
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
trimTraceFile(file);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
// ── 回放期间暂停录制 ─────────────────────────────────────────────────────
|
|
225
|
+
let suspendDepth = 0;
|
|
226
|
+
export function isTraceSuspended() {
|
|
227
|
+
return suspendDepth > 0;
|
|
228
|
+
}
|
|
229
|
+
/** 回放 flow 时包住执行:回放产生的动作不应再被当作"新录制"污染 trace。 */
|
|
230
|
+
export async function withTraceSuspended(fn) {
|
|
231
|
+
suspendDepth += 1;
|
|
232
|
+
try {
|
|
233
|
+
return await fn();
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
suspendDepth -= 1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** flow 文件校验用:某动作在 trace/flow 里允许携带的 computer 参数名。 */
|
|
240
|
+
export function recordedArgKeys(action) {
|
|
241
|
+
return KEEP_ARGS[action] ?? [];
|
|
242
|
+
}
|
package/dist/i18n/index.js
CHANGED
|
@@ -34,6 +34,11 @@ const zhCN = {
|
|
|
34
34
|
'commands.cuOn': '允许按需路由 computer-control',
|
|
35
35
|
'commands.cuOff': '禁止 computer-control',
|
|
36
36
|
'commands.cuStatus': '查看 Computer Use gate',
|
|
37
|
+
'commands.flow': '桌面流程:录制的 computer 动作导出/回放(flow)',
|
|
38
|
+
'commands.flowList': '列出已保存的 flow',
|
|
39
|
+
'commands.flowShow': '查看某个 flow 的步骤与参数',
|
|
40
|
+
'commands.flowSave': '把本会话录制的动作保存为 flow',
|
|
41
|
+
'commands.flowRun': '不经模型直接回放 flow',
|
|
37
42
|
'commands.mcp': 'MCP 服务开关(重启后生效)',
|
|
38
43
|
'commands.mcpOn': '开启 MCP 服务',
|
|
39
44
|
'commands.mcpOff': '关闭 MCP 服务',
|
|
@@ -305,6 +310,15 @@ const zhCN = {
|
|
|
305
310
|
'cu.usage': '用法:/cu on|off|status',
|
|
306
311
|
'cu.visionWindow': '视觉窗口 · {frames} 条帧消息 · history 内 {images} 张图 · 约 {tokens} 图像 token · keep {keep}/batch {batch} · 已淘汰 {dropped}',
|
|
307
312
|
'cu.actionLedger': '动作台账 · 已记录 {count} 条 GUI 动作',
|
|
313
|
+
'flow.usage': '用法:/flow list · /flow show <name> · /flow save <name> [--from N] [--to M] [--force] [描述] · /flow run <name> [--window <标题正则>] [参数=值 ...]',
|
|
314
|
+
'flow.noFlows': '还没有保存的 flow(.mocode/flows/)。先用 computer 操作一遍,再 /flow save <name>。',
|
|
315
|
+
'flow.saved': '已保存 flow "{name}"({steps} 步)→ {path}',
|
|
316
|
+
'flow.savedParams': '回放时需提供参数:{params}(/flow run {name} 参数=值)',
|
|
317
|
+
'flow.fragile': '有 {count} 步是纯坐标动作,窗口位置变了就可能失效;建议改用 inspect + click_element 重新录制。',
|
|
318
|
+
'flow.gateOff': 'Computer Use 已被禁止(/cu off),无法回放 flow。先执行 /cu on。',
|
|
319
|
+
'flow.badParam': '参数格式应为 名称=值,收到:{token}',
|
|
320
|
+
'flow.cancelled': '(已取消回放)',
|
|
321
|
+
'flow.running': '正在回放 flow…',
|
|
308
322
|
'mcp.status': 'MCP 服务:{state}',
|
|
309
323
|
'mcp.stateOn': '开启',
|
|
310
324
|
'mcp.stateOff': '关闭',
|
|
@@ -679,6 +693,20 @@ const en = {
|
|
|
679
693
|
'cu.usage': 'Usage: /cu on|off|status',
|
|
680
694
|
'cu.visionWindow': 'vision window · {frames} frame msg(s) · {images} image(s) in history · ~{tokens} image tk · keep {keep}/batch {batch} · {dropped} dropped',
|
|
681
695
|
'cu.actionLedger': 'action ledger · {count} gui actions logged',
|
|
696
|
+
'flow.usage': 'Usage: /flow list · /flow show <name> · /flow save <name> [--from N] [--to M] [--force] [description] · /flow run <name> [--window <title-regex>] [param=value ...]',
|
|
697
|
+
'flow.noFlows': 'No saved flows yet (.mocode/flows/). Drive the desktop with computer first, then /flow save <name>.',
|
|
698
|
+
'flow.saved': 'Saved flow "{name}" ({steps} steps) → {path}',
|
|
699
|
+
'flow.savedParams': 'Parameters required at replay: {params} (/flow run {name} param=value)',
|
|
700
|
+
'flow.fragile': '{count} step(s) are pure-coordinate actions and may break if the window moves; consider re-recording with inspect + click_element.',
|
|
701
|
+
'flow.gateOff': 'Computer Use is blocked (/cu off), so flows cannot be replayed. Run /cu on first.',
|
|
702
|
+
'flow.badParam': 'Parameters must look like name=value, got: {token}',
|
|
703
|
+
'flow.cancelled': '(replay cancelled)',
|
|
704
|
+
'flow.running': 'Replaying flow…',
|
|
705
|
+
'commands.flow': 'Record and replay desktop flows',
|
|
706
|
+
'commands.flowList': 'List saved flows',
|
|
707
|
+
'commands.flowShow': "Show a flow's steps",
|
|
708
|
+
'commands.flowSave': "Save this session's computer actions as a flow",
|
|
709
|
+
'commands.flowRun': 'Replay a flow without the model',
|
|
682
710
|
'mcp.status': 'MCP services: {state}',
|
|
683
711
|
'mcp.stateOn': 'enabled',
|
|
684
712
|
'mcp.stateOff': 'disabled',
|
package/dist/index.js
CHANGED
|
@@ -124,6 +124,13 @@ async function main() {
|
|
|
124
124
|
await runConfigWizard();
|
|
125
125
|
process.exit(0);
|
|
126
126
|
}
|
|
127
|
+
// 桌面流程:mocode flow list | show <name> | run <name> ...(不经模型、不进 REPL;design-notes/computer-use-rpa.md §5.4)。
|
|
128
|
+
if (args[0] === 'flow') {
|
|
129
|
+
const { runFlowCli } = await import('./flows/cli.js');
|
|
130
|
+
const code = await runFlowCli(args.slice(1));
|
|
131
|
+
await shutdownRuntime();
|
|
132
|
+
process.exit(code);
|
|
133
|
+
}
|
|
127
134
|
// Job runner 内部模式:detached 子进程入口。
|
|
128
135
|
const runnerIdx = args.indexOf('--job-runner');
|
|
129
136
|
if (runnerIdx !== -1) {
|
package/dist/llm/index.js
CHANGED
|
@@ -173,7 +173,7 @@ const STREAM_BREAK_CODES = new Set([
|
|
|
173
173
|
'ETIMEDOUT',
|
|
174
174
|
'ERR_SOCKET_CONNECTION_TIMEOUT',
|
|
175
175
|
]);
|
|
176
|
-
const STREAM_BREAK_MESSAGE = /premature close|other side closed|socket hang up|\bterminated\b|stream (?:closed|ended) (?:prematurely|unexpectedly)|econnreset|econnaborted/i;
|
|
176
|
+
const STREAM_BREAK_MESSAGE = /premature close|other side closed|socket hang up|\bterminated\b|stream (?:closed|ended) (?:prematurely|unexpectedly)|\bunexpected eof\b|econnreset|econnaborted/i;
|
|
177
177
|
/**
|
|
178
178
|
* 「连接层」可重试 errno。与 STREAM_BREAK_CODES 的区别:这些在**建连/发请求**阶段就失败
|
|
179
179
|
* (DNS 解析、拒绝连接、路由不可达、连接超时),压根没有响应流可言,但处置一样 —— 重试。
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* computer 敏感内容审查的纯谓词(叶子模块,无任何运行时依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 从 permissions/index.ts 拆出:flows/(trace、flow)要在录制/导出/回放前复用同一套判定,
|
|
5
|
+
* 而 permissions/index.ts 反过来要读 flow 文件算 run_flow 的审查原因——谓词放叶子文件避免 import 环。
|
|
6
|
+
* permissions/index.ts 会原样 re-export,既有调用方与测试不受影响。
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* computer 工具的 type/key 文本内容审查:
|
|
10
|
+
* 命中 URL / 密码形态 / 支付关键词时,无论是否已授权都强制 once 级确认——
|
|
11
|
+
* 这类文本是「把内容敲进任意应用」的载体,不能因 session/项目授权而放行后续所有输入。
|
|
12
|
+
* 纯函数,独立可单测。
|
|
13
|
+
*/
|
|
14
|
+
export function computerTextNeedsReview(text) {
|
|
15
|
+
const s = text.toLowerCase();
|
|
16
|
+
if (/https?:\/\/|www\.[a-z0-9-]+\.[a-z]{2,}/i.test(s))
|
|
17
|
+
return true;
|
|
18
|
+
if (/\b(pass(word)?|passwd|pwd|secret|token|api[_-]?key|credential)\b\s*[:=]/i.test(s))
|
|
19
|
+
return true;
|
|
20
|
+
if (/\b(card\s*number|cvv|cvc|expiry)\b/i.test(s))
|
|
21
|
+
return true;
|
|
22
|
+
// CJK 关键词没有 \b 词边界概念(\b 只在 \w 与非 \w 之间成立,中文不是 \w),用普通子串匹配。
|
|
23
|
+
if (/信用卡|卡号|密码|支付|付款|验证码/.test(s))
|
|
24
|
+
return true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* computer click_element 目标元素名审查(design-notes/computer-use-rpa.md §2.6):
|
|
29
|
+
* 名称命中删除/发送/提交/支付类关键词时强制 once 级确认——这类按钮一点即产生外部副作用,
|
|
30
|
+
* 不能因为「本会话允许 click_element」而静默放行。纯函数,独立可单测。
|
|
31
|
+
*/
|
|
32
|
+
export function computerTargetNeedsReview(name) {
|
|
33
|
+
const s = name.toLowerCase();
|
|
34
|
+
if (/\b(delete|remove|send|submit|pay|purchase|buy|checkout|confirm|transfer|uninstall|format|erase)\b/i.test(s)) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (/删除|移除|发送|提交|支付|付款|购买|下单|确认|转账|卸载|格式化|清空/.test(s))
|
|
38
|
+
return true;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run_flow 的权限审查输入:把"这次回放到底会做什么"在启动前一次算清(design-notes/computer-use-rpa.md §5.3)。
|
|
3
|
+
*
|
|
4
|
+
* 回放内部的单步不再走 checkPermission,所以整条 flow 的敏感判定必须在这里完成:
|
|
5
|
+
* - 指纹绑定 flow 文件 hash:flow 被改过(哪怕同名)旧授权立即失效;
|
|
6
|
+
* - reasons 非空 → 调用方强制 once 级确认(secret 参数、敏感文本、删除/发送/支付类目标)。
|
|
7
|
+
*/
|
|
8
|
+
import { flowReviewReasons, loadFlow, resolveParams, summarizeFlow } from '../flows/flow.js';
|
|
9
|
+
const clip = (s, max) => (s.length > max ? `${s.slice(0, max)}…` : s);
|
|
10
|
+
export function reviewFlowRun(args) {
|
|
11
|
+
const name = typeof args.name === 'string' ? args.name.trim() : '';
|
|
12
|
+
const loaded = loadFlow(name);
|
|
13
|
+
if (!loaded.ok) {
|
|
14
|
+
return { name, reasons: [], summary: `run_flow ${JSON.stringify(name)}: ${loaded.error}` };
|
|
15
|
+
}
|
|
16
|
+
const { flow } = loaded;
|
|
17
|
+
const given = args.params && typeof args.params === 'object' && !Array.isArray(args.params)
|
|
18
|
+
? args.params
|
|
19
|
+
: undefined;
|
|
20
|
+
const resolved = resolveParams(flow, given);
|
|
21
|
+
// 参数不合法时回放会直接 rejected;审查按 default 兜底,保证仍能判定 secret / 敏感目标。
|
|
22
|
+
const values = resolved.ok
|
|
23
|
+
? resolved.values
|
|
24
|
+
: Object.fromEntries(Object.entries(flow.params).flatMap(([k, p]) => (p.default !== undefined ? [[k, p.default]] : [])));
|
|
25
|
+
const reasons = flowReviewReasons(flow, values);
|
|
26
|
+
const paramLines = Object.entries(flow.params).map(([k, p]) => {
|
|
27
|
+
if (p.secret)
|
|
28
|
+
return ` ${k} = ***`;
|
|
29
|
+
const v = values[k];
|
|
30
|
+
return ` ${k} = ${v === undefined ? '(missing)' : JSON.stringify(clip(v, 60))}`;
|
|
31
|
+
});
|
|
32
|
+
const windowOverride = typeof args.window_title_regex === 'string' && args.window_title_regex.trim() !== ''
|
|
33
|
+
? [`Window override: title matches /${clip(args.window_title_regex, 80)}/i for every window/element step`]
|
|
34
|
+
: [];
|
|
35
|
+
const summary = [
|
|
36
|
+
summarizeFlow(flow),
|
|
37
|
+
...windowOverride,
|
|
38
|
+
...(paramLines.length ? ['Parameter values:', ...paramLines] : []),
|
|
39
|
+
].join('\n');
|
|
40
|
+
return { name, hash: loaded.hash, reasons, summary };
|
|
41
|
+
}
|