mocode-ai 1.7.1 → 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 (44) 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/session/notes.js +58 -8
  29. package/dist/tools/builtins/computer.js +647 -10
  30. package/dist/tools/builtins/index.js +4 -0
  31. package/dist/tools/builtins/run-flow.js +123 -0
  32. package/dist/tools/constants.js +1 -0
  33. package/dist/tools/router.js +1 -1
  34. package/dist/tools/validation.js +57 -0
  35. package/dist/ui/composer.js +6 -2
  36. package/dist/ui/layout-internal/content-write.js +74 -6
  37. package/dist/ui/layout-internal/core.js +32 -8
  38. package/dist/ui/layout-internal/screen.js +4 -0
  39. package/dist/ui/layout-internal/scroll.js +5 -3
  40. package/dist/ui/layout-internal/state.js +6 -0
  41. package/dist/ui/layout-internal/statusbar.js +16 -1
  42. package/dist/ui/render.js +19 -11
  43. package/docs/usage.md +344 -344
  44. 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
+ }
@@ -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)。
@@ -307,6 +307,16 @@ function trimSectionToBudget(body, budgetTokens) {
307
307
  return null;
308
308
  return `${header}\n${kept.join('\n')}`;
309
309
  }
310
+ /**
311
+ * 快照段正文的已知子标题(extractProgressSnapshot 从摘要原样透传 `## Objective` /
312
+ * `## In Progress` / `## Next Steps` 级标题)。段扫描器把任何 `##` 行当段边界,
313
+ * 若不豁免,快照正文会被切碎成游离段(优先级 0)漏进注入、绕过快照的过滤逻辑。
314
+ */
315
+ const SNAPSHOT_SUBHEADERS = /^##\s+(Objective|In Progress|Next Steps)\s*(?:[—–-].*)?$/;
316
+ /** 快照段的边界:下一个非快照子标题的 `##` 行。 */
317
+ function isSnapshotBoundary(line) {
318
+ return /^##\s/.test(line) && !SNAPSHOT_SUBHEADERS.test(line);
319
+ }
310
320
  /**
311
321
  * 读 notes.md,提取所有活跃笔记段正文(排除 `## Plan:` 与 `## Done:`),
312
322
  * 按 NOTES_INJECT_BUDGET_TOKENS 裁剪后返回——供 reinject 注入 system prompt。
@@ -327,6 +337,9 @@ export function extractActiveNotesSections(budget = NOTES_INJECT_BUDGET_TOKENS,
327
337
  return '';
328
338
  }
329
339
  const lines = content.split('\n');
340
+ // 活跃 plan 存在时快照即过期(writeCompactionSnapshot 在 plan 在场时不写,
341
+ // 但快照先于 plan 写入的时序残留仍会被本函数读到):跳过注入,与写入侧对称。
342
+ const hasActivePlan = lines.some((l) => /^## Plan:\s*.+$/.test(l));
330
343
  const sections = [];
331
344
  let i = 0;
332
345
  while (i < lines.length) {
@@ -336,16 +349,23 @@ export function extractActiveNotesSections(budget = NOTES_INJECT_BUDGET_TOKENS,
336
349
  continue;
337
350
  }
338
351
  const title = m[1];
339
- // 跳过 Plan/Done 段(Plan 有专属 ACTIVE_PLAN_MARKER 重注入;Done 是归档不常驻)
340
- if (/^Plan:/.test(title) || /^Done:/.test(title)) {
352
+ const isSnapshot = matchSectionKey(title) === 'compaction_snapshot';
353
+ const skip =
354
+ // Plan/Done 段(Plan 有专属 ACTIVE_PLAN_MARKER 重注入;Done 是归档不常驻)
355
+ /^Plan:/.test(title) ||
356
+ /^Done:/.test(title) ||
357
+ // 活跃 plan 在场时快照过期:不注入(authoritative 标签 + 过期数据 = 模型反复自证忽略)
358
+ (hasActivePlan && isSnapshot);
359
+ const nextSection = (line) => (isSnapshot ? isSnapshotBoundary(line) : /^##\s/.test(line));
360
+ if (skip) {
341
361
  i++;
342
- while (i < lines.length && !/^##\s/.test(lines[i]))
362
+ while (i < lines.length && !nextSection(lines[i]))
343
363
  i++;
344
364
  continue;
345
365
  }
346
366
  const start = i;
347
367
  i++;
348
- while (i < lines.length && !/^##\s/.test(lines[i]))
368
+ while (i < lines.length && !nextSection(lines[i]))
349
369
  i++;
350
370
  const body = lines.slice(start, i).join('\n').trim();
351
371
  if (body)
@@ -384,18 +404,48 @@ export function extractActiveNotesSections(budget = NOTES_INJECT_BUDGET_TOKENS,
384
404
  // 与 plan/notes 不同:这是机器产出、整段替换(不逐条累积),因此不会跨压缩膨胀。
385
405
  /** 快照段标题。extractActiveNotesSections 经 matchSectionKey 识别并注入。 */
386
406
  export const COMPACTION_SNAPSHOT_TITLE = NOTE_SECTION_TITLES.compaction_snapshot;
407
+ /** 从 notes.md 正文移除整个 `## Compaction Snapshot` 段(含其 `## Objective` 等子标题);无该段返原文。 */
408
+ function stripSnapshotSection(existing) {
409
+ const header = `## ${COMPACTION_SNAPSHOT_TITLE}`;
410
+ const lines = existing.split('\n');
411
+ const start = lines.findIndex((l) => l.trim() === header);
412
+ if (start < 0)
413
+ return existing;
414
+ let end = lines.length;
415
+ for (let k = start + 1; k < lines.length; k++) {
416
+ if (isSnapshotBoundary(lines[k])) {
417
+ end = k;
418
+ break;
419
+ }
420
+ }
421
+ const before = lines.slice(0, start).join('\n').replace(/\s+$/, '');
422
+ const after = lines.slice(end).join('\n').replace(/^\s+/, '');
423
+ return [before, after].filter((s) => s.length > 0).join('\n\n') + '\n';
424
+ }
387
425
  /**
388
426
  * 把压缩摘要的关键段固结到 notes.md 的 `## Compaction Snapshot` 段。
389
- * 整段替换旧快照(不累积);仅在当前无活跃 plan 时写入(已有 plan 时权威计划仍在,
390
- * 快照只会重复)。body 为空时不动。永不抛错:压缩主流程不能因快照失败而失败。
427
+ * 整段替换旧快照(不累积);仅在当前无活跃 plan 时写入。body 为空时不动。
428
+ * 有活跃 plan 时移除残留的旧快照段:写入侧「不写」若配上注入侧「照旧注入」,
429
+ * 模型每步都会看到一份 authoritative 标签的过期数据,被迫反复自证「快照是旧的,
430
+ * 我忽略它」——与 {@link extractActiveNotesSections} 的对称过滤一起根治。
431
+ * 永不抛错:压缩主流程不能因快照失败而失败。
391
432
  */
392
433
  export function writeCompactionSnapshot(body, sessionId = getCurrentSessionId()) {
393
434
  try {
394
435
  const trimmed = (body ?? '').trim();
395
436
  if (!trimmed)
396
437
  return;
397
- if (readActivePlanTitle(sessionId))
398
- return; // 已有权威计划,快照是冗余
438
+ if (readActivePlanTitle(sessionId)) {
439
+ // 已有权威计划,快照是冗余:连同旧快照段一并移除(注入侧不再有过期数据可读)
440
+ const p = getNotesFilePath(sessionId);
441
+ if (!p)
442
+ return;
443
+ const existing = fs.readFileSync(p, 'utf8').replace(/\r\n?/g, '\n');
444
+ const next = stripSnapshotSection(existing);
445
+ if (next !== existing)
446
+ fs.writeFileSync(p, next, 'utf8');
447
+ return;
448
+ }
399
449
  const p = getNotesFilePath(sessionId);
400
450
  if (!p)
401
451
  return;