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.
Files changed (45) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  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/compact.js +1 -1
  28. package/dist/session/gui-actions.js +47 -0
  29. package/dist/tools/builtins/computer.js +647 -10
  30. package/dist/tools/builtins/index.js +4 -0
  31. package/dist/tools/builtins/read-file.js +54 -3
  32. package/dist/tools/builtins/run-flow.js +123 -0
  33. package/dist/tools/constants.js +8 -2
  34. package/dist/tools/router.js +1 -1
  35. package/dist/tools/validation.js +57 -0
  36. package/dist/ui/composer.js +6 -2
  37. package/dist/ui/layout-internal/content-write.js +74 -6
  38. package/dist/ui/layout-internal/core.js +32 -8
  39. package/dist/ui/layout-internal/screen.js +4 -0
  40. package/dist/ui/layout-internal/scroll.js +5 -3
  41. package/dist/ui/layout-internal/state.js +6 -0
  42. package/dist/ui/layout-internal/statusbar.js +16 -1
  43. package/dist/ui/render.js +19 -11
  44. package/docs/usage.md +344 -344
  45. package/package.json +92 -92
@@ -0,0 +1,199 @@
1
+ /**
2
+ * computer.wait_until 的纯逻辑层(design-notes/computer-use-rpa.md §3.1)。
3
+ *
4
+ * 条件解析、轮询状态机、type 输入方式选择都在这里;抓屏与元素存在性检查通过 WaitDeps 注入,
5
+ * 因此可以用合成 PngImage 序列 + 假时钟离线单测,不碰屏幕/进程。
6
+ */
7
+ import { diffRatio } from './screen-pipeline.js';
8
+ export const WAIT_UNTIL_DEFAULT_TIMEOUT_MS = 10000;
9
+ export const WAIT_UNTIL_MIN_TIMEOUT_MS = 500;
10
+ export const WAIT_UNTIL_MAX_TIMEOUT_MS = 30000;
11
+ export const WAIT_INTERVAL_MIN_MS = 200;
12
+ export const WAIT_INTERVAL_MAX_MS = 2000;
13
+ export const WAIT_INTERVAL_DEFAULT_MS = 400;
14
+ export const STABLE_FRAMES_MIN = 2;
15
+ export const STABLE_FRAMES_MAX = 5;
16
+ const KINDS = ['stable', 'changed', 'element_present', 'element_absent'];
17
+ /**
18
+ * 解析 selector_text 简写:`Button:"确定"` 或仅 `"确定"`(不限 role)。
19
+ * 引号内按 JSON 字符串转义解析(可写 \" ),失败则取原文。
20
+ */
21
+ export function parseSelectorText(text) {
22
+ const m = /^\s*(?:([A-Za-z][A-Za-z0-9]*)\s*:\s*)?("(?:[^"\\]|\\.)*")\s*$/s.exec(text);
23
+ if (!m)
24
+ return null;
25
+ let name;
26
+ try {
27
+ name = JSON.parse(m[2]);
28
+ }
29
+ catch {
30
+ name = m[2].slice(1, -1);
31
+ }
32
+ if (!name)
33
+ return null;
34
+ const step = { name };
35
+ if (m[1])
36
+ step.role = m[1];
37
+ return step;
38
+ }
39
+ function inRange(v, min, max) {
40
+ return typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max;
41
+ }
42
+ /** 校验并规整 wait_until 的 condition 参数;defaultThreshold 为 computer.ts 的画面差分阈值。 */
43
+ export function parseWaitCondition(raw, defaultThreshold) {
44
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
45
+ return { ok: false, error: `action "wait_until" requires condition: { kind: ${KINDS.join('|')}, ... }` };
46
+ }
47
+ const c = raw;
48
+ const kind = c.kind;
49
+ if (typeof kind !== 'string' || !KINDS.includes(kind)) {
50
+ return { ok: false, error: `condition.kind must be one of: ${KINDS.join(', ')}` };
51
+ }
52
+ let intervalMs = WAIT_INTERVAL_DEFAULT_MS;
53
+ if (c.interval_ms !== undefined) {
54
+ if (!inRange(c.interval_ms, WAIT_INTERVAL_MIN_MS, WAIT_INTERVAL_MAX_MS)) {
55
+ return {
56
+ ok: false,
57
+ error: `condition.interval_ms must be an integer in ${WAIT_INTERVAL_MIN_MS}-${WAIT_INTERVAL_MAX_MS}`,
58
+ };
59
+ }
60
+ intervalMs = c.interval_ms;
61
+ }
62
+ let threshold = defaultThreshold;
63
+ if (c.threshold !== undefined) {
64
+ if (typeof c.threshold !== 'number' || !Number.isFinite(c.threshold) || c.threshold < 0 || c.threshold > 1) {
65
+ return { ok: false, error: 'condition.threshold must be a number in 0-1' };
66
+ }
67
+ threshold = c.threshold;
68
+ }
69
+ if (kind === 'stable') {
70
+ let frames = STABLE_FRAMES_MIN;
71
+ if (c.frames !== undefined) {
72
+ if (!inRange(c.frames, STABLE_FRAMES_MIN, STABLE_FRAMES_MAX)) {
73
+ return { ok: false, error: `condition.frames must be an integer in ${STABLE_FRAMES_MIN}-${STABLE_FRAMES_MAX}` };
74
+ }
75
+ frames = c.frames;
76
+ }
77
+ return { ok: true, condition: { kind, frames, intervalMs, threshold } };
78
+ }
79
+ if (kind === 'changed')
80
+ return { ok: true, condition: { kind, intervalMs, threshold } };
81
+ // element_present / element_absent:ref 与 selector_text 二选一。
82
+ const hasRef = c.ref !== undefined;
83
+ const hasText = c.selector_text !== undefined;
84
+ if (hasRef === hasText) {
85
+ return { ok: false, error: `condition kind "${kind}" requires exactly one of ref or selector_text` };
86
+ }
87
+ if (hasRef) {
88
+ if (typeof c.ref !== 'string' || !/^e\d+$/.test(c.ref)) {
89
+ return { ok: false, error: 'condition.ref must look like "e12" (from the latest inspect)' };
90
+ }
91
+ return {
92
+ ok: true,
93
+ condition: { kind: kind, intervalMs, target: { ref: c.ref } },
94
+ };
95
+ }
96
+ const text = typeof c.selector_text === 'string' ? c.selector_text : '';
97
+ const step = parseSelectorText(text);
98
+ if (!step) {
99
+ return { ok: false, error: 'condition.selector_text must look like Button:"OK" or "OK" (quoted element name)' };
100
+ }
101
+ return {
102
+ ok: true,
103
+ condition: { kind: kind, intervalMs, target: { step, text: text.trim() } },
104
+ };
105
+ }
106
+ /** 给台账/输出用的简短条件描述。 */
107
+ export function describeCondition(c) {
108
+ if (c.kind === 'stable' || c.kind === 'changed')
109
+ return c.kind;
110
+ const t = 'ref' in c.target ? c.target.ref : c.target.text;
111
+ return `${c.kind} ${t}`;
112
+ }
113
+ function defaultSleep(ms, signal) {
114
+ return new Promise((resolve) => {
115
+ if (signal?.aborted)
116
+ return resolve();
117
+ const done = () => {
118
+ clearTimeout(timer);
119
+ signal?.removeEventListener('abort', done);
120
+ resolve();
121
+ };
122
+ const timer = setTimeout(done, ms);
123
+ signal?.addEventListener('abort', done, { once: true });
124
+ });
125
+ }
126
+ /** 尺寸不同意味着显示配置变了——视为"变化"最安全(diff=1),而不是抛错中断等待。 */
127
+ function safeDiff(a, b) {
128
+ try {
129
+ return diffRatio(a, b);
130
+ }
131
+ catch {
132
+ return 1;
133
+ }
134
+ }
135
+ /**
136
+ * 轮询直到条件满足 / 超时 / abort。
137
+ * stable:连续 `frames` 次相邻两帧 diff ≤ threshold;changed:相对基准 diff > threshold(互补判定)。
138
+ */
139
+ export async function waitUntil(cond, timeoutMs, deps) {
140
+ const now = deps.now ?? Date.now;
141
+ const sleep = deps.sleep ?? defaultSleep;
142
+ const start = now();
143
+ let polls = 0;
144
+ let lastDiff;
145
+ let prev = null;
146
+ let base = cond.kind === 'changed' ? (deps.baseline ?? null) : null;
147
+ let stableRun = 0;
148
+ const finish = (outcome) => ({
149
+ outcome,
150
+ elapsedMs: now() - start,
151
+ polls,
152
+ lastDiff,
153
+ });
154
+ for (;;) {
155
+ if (deps.signal?.aborted)
156
+ return finish('aborted');
157
+ polls++;
158
+ let met = false;
159
+ if (cond.kind === 'stable') {
160
+ const frame = await deps.captureFrame();
161
+ if (prev) {
162
+ lastDiff = safeDiff(prev, frame);
163
+ stableRun = lastDiff <= cond.threshold ? stableRun + 1 : 0;
164
+ met = stableRun >= cond.frames;
165
+ }
166
+ prev = frame;
167
+ }
168
+ else if (cond.kind === 'changed') {
169
+ const frame = await deps.captureFrame();
170
+ if (base) {
171
+ lastDiff = safeDiff(base, frame);
172
+ met = lastDiff > cond.threshold;
173
+ }
174
+ else {
175
+ base = frame;
176
+ }
177
+ }
178
+ else {
179
+ const exists = await deps.elementExists(cond.target);
180
+ met = cond.kind === 'element_present' ? exists : !exists;
181
+ }
182
+ if (deps.signal?.aborted)
183
+ return finish('aborted');
184
+ if (met)
185
+ return finish('met');
186
+ const elapsed = now() - start;
187
+ if (elapsed >= timeoutMs)
188
+ return finish('timeout');
189
+ await sleep(Math.min(cond.intervalMs, timeoutMs - elapsed), deps.signal);
190
+ }
191
+ }
192
+ /** 超过此长度的文本逐字 SendInput 太慢且易被 IME/自动补全打断,auto 时改走剪贴板。 */
193
+ export const PASTE_AUTO_MIN_LENGTH = 200;
194
+ /** method 缺省(auto)时:长文本或含换行用 paste,否则 keys;显式 method 原样返回。 */
195
+ export function pickTypeMethod(text, method) {
196
+ if (method === 'keys' || method === 'paste')
197
+ return method;
198
+ return text.length > PASTE_AUTO_MIN_LENGTH || /[\r\n]/.test(text) ? 'paste' : 'keys';
199
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * computer.list_windows / focus_window 的纯逻辑层(design-notes/computer-use-rpa.md §4)。
3
+ *
4
+ * 窗口枚举的原始数据来自 uia-service 的 `windows` op;过滤、编号(w1..wN)、norm1000 换算、
5
+ * 按 ref / title_regex 选窗口都在这里,可用 fixture 离线单测,不碰 Win32。
6
+ */
7
+ import { rectToNorm } from './uia-selector.js';
8
+ /** list_windows 输出前缀;session/gui-actions.ts 靠它识别台账条目,改动须同步。 */
9
+ export const LIST_WINDOWS_OUTPUT_PREFIX = 'Windows (';
10
+ /** 最多展示的窗口数:桌面上几十个窗口再多对模型只是噪声。 */
11
+ export const LIST_WINDOWS_MAX = 40;
12
+ function toInt(v) {
13
+ return typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : 0;
14
+ }
15
+ /** 解析 PowerShell 响应里的 windows 数组;字段缺失一律取保守默认值。 */
16
+ export function parseRawWindows(resp) {
17
+ const arr = Array.isArray(resp.windows) ? resp.windows : resp.windows ? [resp.windows] : [];
18
+ const out = [];
19
+ for (const entry of arr) {
20
+ if (!entry || typeof entry !== 'object')
21
+ continue;
22
+ const w = entry;
23
+ const hwnd = toInt(w.hwnd);
24
+ if (!hwnd)
25
+ continue;
26
+ const r = (w.rect && typeof w.rect === 'object' ? w.rect : {});
27
+ out.push({
28
+ hwnd,
29
+ title: typeof w.title === 'string' ? w.title : '',
30
+ processName: typeof w.processName === 'string' ? w.processName : '',
31
+ pid: toInt(w.pid),
32
+ rect: { x: toInt(r.x), y: toInt(r.y), w: Math.max(0, toInt(r.w)), h: Math.max(0, toInt(r.h)) },
33
+ minimized: w.minimized === true,
34
+ foreground: w.foreground === true,
35
+ toolWindow: w.toolWindow === true,
36
+ cloaked: w.cloaked === true,
37
+ });
38
+ }
39
+ return out;
40
+ }
41
+ function onPrimaryScreen(rect, g) {
42
+ const cx = rect.x + rect.w / 2;
43
+ const cy = rect.y + rect.h / 2;
44
+ return cx >= g.originX && cx < g.originX + g.physW && cy >= g.originY && cy < g.originY + g.physH;
45
+ }
46
+ function clip(s, max) {
47
+ const flat = s.replace(/\s+/g, ' ').trim();
48
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
49
+ }
50
+ /**
51
+ * 过滤(空标题 / 工具窗口 / cloaked)→ 编号 → norm1000 换算 → 文本。
52
+ * 保持原始 z-order(前 → 后),最多 LIST_WINDOWS_MAX 个。
53
+ */
54
+ export function formatWindowList(raw, geometry) {
55
+ const visible = raw.filter((w) => w.title.trim().length > 0 && !w.toolWindow && !w.cloaked);
56
+ const truncated = visible.length > LIST_WINDOWS_MAX;
57
+ const items = visible.slice(0, LIST_WINDOWS_MAX).map((w, i) => {
58
+ const onPrimary = !w.minimized && onPrimaryScreen(w.rect, geometry);
59
+ return {
60
+ ref: `w${i + 1}`,
61
+ hwnd: w.hwnd,
62
+ title: w.title,
63
+ processName: w.processName,
64
+ pid: w.pid,
65
+ rect: w.rect,
66
+ minimized: w.minimized,
67
+ foreground: w.foreground,
68
+ onPrimary,
69
+ norm: onPrimary ? rectToNorm(w.rect, geometry) : null,
70
+ };
71
+ });
72
+ const lines = [
73
+ `${LIST_WINDOWS_OUTPUT_PREFIX}${items.length}${truncated ? `, first ${LIST_WINDOWS_MAX} shown` : ''}, front to back). ` +
74
+ 'Rects are normalized 0-1000 [x, y, w, h] over the primary screen. ' +
75
+ 'Use focus_window with window: "wN" to bring one to the front; refs expire on the next list_windows.',
76
+ ];
77
+ if (items.length === 0)
78
+ lines.push('(no top-level windows with a title)');
79
+ for (const it of items) {
80
+ const flags = [];
81
+ if (it.foreground)
82
+ flags.push('foreground');
83
+ if (it.minimized)
84
+ flags.push('minimized');
85
+ else if (!it.onPrimary)
86
+ flags.push('on secondary display');
87
+ const proc = it.processName ? `${it.processName}.exe` : 'unknown';
88
+ const rect = it.norm ? ` rect=[${it.norm.join(', ')}]` : '';
89
+ const tag = flags.length ? ` (${flags.join(', ')})` : '';
90
+ lines.push(`${it.ref} ${proc} ${JSON.stringify(clip(it.title, 80))}${rect}${tag}`);
91
+ }
92
+ return { text: lines.join('\n'), items, truncated };
93
+ }
94
+ /**
95
+ * 按 ref(w3)或 title_regex 选窗口。regex 命中多个视为歧义,返回错误并列出候选——
96
+ * 宁可让模型再指定一次,也不替它在两个同名窗口里挑一个。
97
+ */
98
+ export function pickWindow(items, sel) {
99
+ if (sel.ref !== undefined) {
100
+ const item = items.find((i) => i.ref === sel.ref);
101
+ if (!item) {
102
+ return { ok: false, error: `window ${sel.ref} is not in the current window list; call list_windows again` };
103
+ }
104
+ return { ok: true, item };
105
+ }
106
+ if (sel.titleRegex === undefined) {
107
+ return { ok: false, error: 'a window selector (window: "wN" or title_regex) is required' };
108
+ }
109
+ let re;
110
+ try {
111
+ re = new RegExp(sel.titleRegex, 'i');
112
+ }
113
+ catch {
114
+ return { ok: false, error: `title_regex is not a valid regular expression: ${sel.titleRegex}` };
115
+ }
116
+ const hits = items.filter((i) => re.test(i.title));
117
+ if (hits.length === 0) {
118
+ return { ok: false, error: `no window title matches /${sel.titleRegex}/i; call list_windows to see what is open` };
119
+ }
120
+ if (hits.length > 1) {
121
+ const names = hits.map((h) => `${h.ref} ${JSON.stringify(clip(h.title, 50))}`).join(', ');
122
+ return {
123
+ ok: false,
124
+ error: `title_regex /${sel.titleRegex}/i is ambiguous (${hits.length} windows: ${names}); use window: "wN" or a narrower regex`,
125
+ };
126
+ }
127
+ return { ok: true, item: hits[0] };
128
+ }
129
+ /**
130
+ * flow 回放用:按进程名(+可选标题正则)在原始窗口里选唯一目标。
131
+ * 与 pickWindow 的区别:不依赖 wN 快照;同进程多窗口时,只在恰有一个前台窗口时取前台,否则报歧义。
132
+ */
133
+ export function pickWindowForReplay(raw, sel) {
134
+ let re;
135
+ if (sel.titleRegex !== undefined) {
136
+ try {
137
+ re = new RegExp(sel.titleRegex, 'i');
138
+ }
139
+ catch {
140
+ return { ok: false, error: `window titleRegex is not a valid regular expression: ${sel.titleRegex}` };
141
+ }
142
+ }
143
+ const proc = sel.processName?.toLowerCase();
144
+ const label = [sel.processName ? `${sel.processName}.exe` : undefined, re ? `title /${sel.titleRegex}/i` : undefined]
145
+ .filter(Boolean)
146
+ .join(' ');
147
+ const hits = raw.filter((w) => w.title.trim().length > 0 &&
148
+ !w.toolWindow &&
149
+ !w.cloaked &&
150
+ (proc === undefined || w.processName.toLowerCase() === proc) &&
151
+ (!re || re.test(w.title)));
152
+ if (hits.length === 0)
153
+ return { ok: false, error: `no open window matches ${label || '(any)'}` };
154
+ if (hits.length === 1)
155
+ return { ok: true, window: hits[0] };
156
+ const foreground = hits.filter((w) => w.foreground);
157
+ if (foreground.length === 1)
158
+ return { ok: true, window: foreground[0] };
159
+ const names = hits.map((h) => JSON.stringify(clip(h.title, 40))).join(', ');
160
+ return {
161
+ ok: false,
162
+ error: `${hits.length} windows match ${label || '(any)'} and none is uniquely in front: ${names}`,
163
+ };
164
+ }
@@ -693,7 +693,7 @@ export async function compactHistory(history, opts) {
693
693
  }
694
694
  let oldGroups = groups.slice(0, groups.length - kept.length);
695
695
  // 非空 user 兜底:保留区从尾部累积,长回合里最近的若干组常常全是 assistant+tool,
696
- // 一个 8000 字符的工具结果就能吃满小窗口的 keepBudget → user 全落进旧区被摘要掉 →
696
+ // 一个 MAX_HISTORY_RESULT 量级的工具结果就能吃满小窗口的 keepBudget → user 全落进旧区被摘要掉 →
697
697
  // 重建后 history 无 user → 下一步 chat() 被 hasNonEmptyUser 守卫直接拒(整轮中断)。
698
698
  // 把紧邻保留区之前的那条 user 移进保留区:它是当前任务的原始请求,且 user 消息本身极小。
699
699
  // 放这里(而非重建后补 stub)是因为:原文保留,不造新消息形状,也不必进摘要器。
@@ -16,6 +16,7 @@
16
16
  import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { getSandboxRoot } from '../sandbox/root.js';
19
+ import { LIST_WINDOWS_OUTPUT_PREFIX } from '../runtime/window-list.js';
19
20
  import { getCurrentSessionId } from './state.js';
20
21
  /** 台账文件名:与 notes.md 同目录(会话目录)。 */
21
22
  export const GUI_ACTIONS_FILENAME = 'gui-actions.log';
@@ -50,11 +51,18 @@ function normalizeIntent(summary) {
50
51
  // 顺序敏感:先处理带自身句式的动作,最后才是泛化的 "<action> at (x, y)"。
51
52
  text = text.replace(/^moved cursor to (\(.*\))$/, 'mouse_move $1');
52
53
  text = text.replace(/^typed ([\d.]+) characters$/, 'type $1 chars');
54
+ // type 的 paste 路径(computer.ts):`pasted 300 characters via clipboard[ (WARNING: ...)]` → `paste 300 chars`。
55
+ text = text.replace(/^pasted ([\d.]+) characters via clipboard(?: \(WARNING:.*\))?$/, 'paste $1 chars');
53
56
  text = text.replace(/^pressed key combo (".*")$/, 'key $1');
54
57
  text = text.replace(/^dragged from (\([^)]*\)) to (\([^)]*\))$/, 'drag $1 -> $2');
55
58
  text = text.replace(/^scrolled (\w+) by ([\d.]+) at (\(.*\))$/, 'scroll $1 $2 $3');
56
59
  text = text.replace(/^waited ([\d.]+)ms$/, 'wait $1ms');
57
60
  text = text.replace(/^cursor at physical \([^)]*\) = normalized (\(.*\))$/, 'cursor_position $1');
61
+ // UIA 元素动作(computer.ts executeAction 的 click_element/set_value 句式):
62
+ // `clicked element e9 Button "关闭" at (978, 11)` / `... via invoke pattern` → `click_element e9 "关闭"`;
63
+ // `set value of element e7 Document "文本" (18 chars) via pattern` → `set_value e7 18 chars`。
64
+ text = text.replace(/^clicked element (e\d+) \S+ (".*") (?:at \([^)]*\)|via \w+ pattern)$/, 'click_element $1 $2');
65
+ text = text.replace(/^set value of element (e\d+) \S+ ".*" \((\d+) chars\) via \w+$/, 'set_value $1 $2 chars');
58
66
  text = text.replace(/^(\w+) at (\(.*\))$/, '$1 $2');
59
67
  return text;
60
68
  }
@@ -78,6 +86,45 @@ export function parseComputerOutput(output) {
78
86
  if (text.startsWith('Screenshot captured (')) {
79
87
  return { intent: 'screenshot', observation: 're-captured' };
80
88
  }
89
+ // inspect 输出前缀与 runtime/uia-selector.ts 的 INSPECT_OUTPUT_PREFIX 一致;只回灌文本,不改差分基准。
90
+ if (text.startsWith('UI elements of window ')) {
91
+ const n = /, (\d+) nodes \(/.exec(text);
92
+ return { intent: 'inspect', observation: n ? `${n[1]} nodes` : 'listed' };
93
+ }
94
+ // 窗口作用域(computer.ts, design-notes/computer-use-rpa.md §4)。
95
+ // list_windows:`Windows (N, front to back). ...`,只回灌文本。
96
+ if (text.startsWith(LIST_WINDOWS_OUTPUT_PREFIX)) {
97
+ const n = /^Windows \((\d+)/.exec(text);
98
+ return { intent: 'list_windows', observation: n ? `${n[1]} windows` : 'listed' };
99
+ }
100
+ // focus_window 副屏窗口默认不搬动:`w3 app.exe "title" is on a secondary display or off-screen, so it was NOT focused ...`。
101
+ const notFocused = /^(w\d+) .* is on a secondary display or off-screen, so it was NOT focused/.exec(text);
102
+ if (notFocused) {
103
+ return { intent: `focus_window ${notFocused[1]}`, observation: 'not focused (secondary display)' };
104
+ }
105
+ // focus_window 成功路径:`Focused w3 app.exe "title" rect=[...]. Screen re-captured ...`;系统拒绝前台切换时如实标注。
106
+ const focused = /^Focused (w\d+) /.exec(text);
107
+ if (focused) {
108
+ const refused = text.includes('The OS refused the foreground switch');
109
+ return {
110
+ intent: `focus_window ${focused[1]}`,
111
+ observation: refused ? 're-captured, OS refused foreground switch' : 're-captured',
112
+ };
113
+ }
114
+ // 窗口截图:`Window screenshot of w3 app.exe "title" (printwindow, 923×587 physical, ...)`;不是整屏基准,不参与差分。
115
+ const winShot = /^Window screenshot of (w\d+) .*? \((printwindow|screen),/.exec(text);
116
+ if (winShot) {
117
+ return { intent: `screenshot window ${winShot[1]}`, observation: `captured (${winShot[2]}, window only)` };
118
+ }
119
+ // wait_until(computer.ts):`waited until <cond>: met (1200ms, 4 polls...)` / `... timed out (10000ms, ...)`。
120
+ // 台账只记条件与耗时;met 只代表轮询条件成立,仍遵循"像素优先"纪律,不升级成任务成功。
121
+ const waited = /^waited until (.+?): (met|timed out) \((\d+)ms/.exec(text);
122
+ if (waited) {
123
+ return {
124
+ intent: `wait_until ${truncateDetail(waited[1])}`,
125
+ observation: `${waited[2]}, ${waited[3]}ms`,
126
+ };
127
+ }
81
128
  const diffMatch = /\.\s*No visible change on screen \(frame diff ([\d.]+)%/.exec(text);
82
129
  if (diffMatch) {
83
130
  // 「no visible change」即"未达成"的现成措辞:保留原措辞,不升级成任何成功断言(§10.5)。