@windsland52/maafw-live 0.0.0-stage → 0.3.0

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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +278 -2
  3. package/bin/maafw-live.mjs +25 -0
  4. package/docs/daemon-protocol.md +116 -0
  5. package/docs/keyframe-evidence-contract-v0.md +61 -0
  6. package/docs/keyframe-retention-contract-v0.md +236 -0
  7. package/lib/client/daemon.js +234 -0
  8. package/lib/client/daemon.js.map +1 -0
  9. package/lib/commands/annotate.js +172 -0
  10. package/lib/commands/annotate.js.map +1 -0
  11. package/lib/commands/calibrate.js +61 -0
  12. package/lib/commands/calibrate.js.map +1 -0
  13. package/lib/commands/color.js +73 -0
  14. package/lib/commands/color.js.map +1 -0
  15. package/lib/commands/crop.js +228 -0
  16. package/lib/commands/crop.js.map +1 -0
  17. package/lib/commands/env.js +215 -0
  18. package/lib/commands/env.js.map +1 -0
  19. package/lib/commands/index.js +64 -0
  20. package/lib/commands/index.js.map +1 -0
  21. package/lib/commands/input.js +219 -0
  22. package/lib/commands/input.js.map +1 -0
  23. package/lib/commands/kf.js +131 -0
  24. package/lib/commands/kf.js.map +1 -0
  25. package/lib/commands/reco.js +211 -0
  26. package/lib/commands/reco.js.map +1 -0
  27. package/lib/commands/repl.js +698 -0
  28. package/lib/commands/repl.js.map +1 -0
  29. package/lib/commands/runtime.js +514 -0
  30. package/lib/commands/runtime.js.map +1 -0
  31. package/lib/commands/skill.js +169 -0
  32. package/lib/commands/skill.js.map +1 -0
  33. package/lib/commands/timing.js +229 -0
  34. package/lib/commands/timing.js.map +1 -0
  35. package/lib/commands/version.js +66 -0
  36. package/lib/commands/version.js.map +1 -0
  37. package/lib/daemon/framed.mjs +2850 -0
  38. package/lib/daemon/reco_child.mjs +146 -0
  39. package/lib/exec.js +96 -0
  40. package/lib/exec.js.map +1 -0
  41. package/lib/flags.js +42 -0
  42. package/lib/flags.js.map +1 -0
  43. package/lib/image.js +90 -0
  44. package/lib/image.js.map +1 -0
  45. package/lib/index.js +139 -0
  46. package/lib/index.js.map +1 -0
  47. package/lib/interface/index.js +12 -0
  48. package/lib/interface/index.js.map +1 -0
  49. package/lib/interface/jsonc.js +95 -0
  50. package/lib/interface/jsonc.js.map +1 -0
  51. package/lib/interface/load.js +307 -0
  52. package/lib/interface/load.js.map +1 -0
  53. package/lib/interface/override.js +103 -0
  54. package/lib/interface/override.js.map +1 -0
  55. package/lib/interface/plan.js +188 -0
  56. package/lib/interface/plan.js.map +1 -0
  57. package/lib/pkg.js +18 -0
  58. package/lib/pkg.js.map +1 -0
  59. package/lib/protocol.js +90 -0
  60. package/lib/protocol.js.map +1 -0
  61. package/lib/runtime/actions.js +110 -0
  62. package/lib/runtime/actions.js.map +1 -0
  63. package/lib/runtime/keyframes.js +73 -0
  64. package/lib/runtime/keyframes.js.map +1 -0
  65. package/lib/runtime/session.js +86 -0
  66. package/lib/runtime/session.js.map +1 -0
  67. package/lib/types/client/daemon.d.ts +86 -0
  68. package/lib/types/commands/annotate.d.ts +3 -0
  69. package/lib/types/commands/calibrate.d.ts +7 -0
  70. package/lib/types/commands/color.d.ts +9 -0
  71. package/lib/types/commands/crop.d.ts +3 -0
  72. package/lib/types/commands/env.d.ts +2 -0
  73. package/lib/types/commands/index.d.ts +13 -0
  74. package/lib/types/commands/input.d.ts +17 -0
  75. package/lib/types/commands/kf.d.ts +10 -0
  76. package/lib/types/commands/reco.d.ts +23 -0
  77. package/lib/types/commands/repl.d.ts +2 -0
  78. package/lib/types/commands/runtime.d.ts +48 -0
  79. package/lib/types/commands/skill.d.ts +17 -0
  80. package/lib/types/commands/timing.d.ts +64 -0
  81. package/lib/types/commands/version.d.ts +3 -0
  82. package/lib/types/exec.d.ts +31 -0
  83. package/lib/types/flags.d.ts +58 -0
  84. package/lib/types/image.d.ts +27 -0
  85. package/lib/types/index.d.ts +1 -0
  86. package/lib/types/interface/index.d.ts +11 -0
  87. package/lib/types/interface/jsonc.d.ts +9 -0
  88. package/lib/types/interface/load.d.ts +113 -0
  89. package/lib/types/interface/override.d.ts +33 -0
  90. package/lib/types/interface/plan.d.ts +47 -0
  91. package/lib/types/pkg.d.ts +6 -0
  92. package/lib/types/protocol.d.ts +108 -0
  93. package/lib/types/runtime/actions.d.ts +166 -0
  94. package/lib/types/runtime/keyframes.d.ts +43 -0
  95. package/lib/types/runtime/session.d.ts +53 -0
  96. package/lib/types/update-check.d.ts +20 -0
  97. package/lib/types/usage.d.ts +3 -0
  98. package/lib/update-check.js +97 -0
  99. package/lib/update-check.js.map +1 -0
  100. package/lib/usage.js +50 -0
  101. package/lib/usage.js.map +1 -0
  102. package/package.json +81 -4
  103. package/skills/README.md +51 -0
  104. package/skills/maafw-live/SKILL.md +263 -0
  105. package/skills/maafw-live/references/examples.md +238 -0
  106. package/skills/maafw-live/references/pitfalls.md +118 -0
  107. package/skills/maafw-live/references/workflows.md +159 -0
@@ -0,0 +1,698 @@
1
+ /**
2
+ * maafw-live repl —— 一次连接、多步操作的会话外壳。
3
+ *
4
+ * 为什么需要它:daemon 的连接是进程态,而设备操作天生是多步的(连上 → 看画面 → 点 → 再看)。
5
+ * 一次性命令每次都要重连,所以把"连接一次、命令复用"做成一个前台循环:内部与一次性命令
6
+ * 走同一批动作函数(runtime/actions),因此不存在第二套参数规则。
7
+ *
8
+ * 既能交互用,也能被脚本/agent 用管道驱动:printf 'probe\nquit\n' | maafw-live repl
9
+ */
10
+ import readline from 'node:readline';
11
+ import { spawnDaemon } from '../client/daemon.js';
12
+ import { EXIT } from '../protocol.js';
13
+ import * as act from '../runtime/actions.js';
14
+ import { defaultFramesDir, describeRecord, loadManifest, resolveFrame } from '../runtime/keyframes.js';
15
+ import { ensureSession, SessionError } from '../runtime/session.js';
16
+ import { CONNECT_OPTIONS, sessionOptions } from './runtime.js';
17
+ const HELP = [
18
+ '命令:',
19
+ ' probe 自检运行时',
20
+ ' device [all|adb|win32] 列设备',
21
+ ' connect <win32|adb|gamepad> [target] 手动连接',
22
+ ' connect project <dir> [hwnd|address] 按 interface.json 规划连接(推荐)',
23
+ ' disconnect 断开并销毁 Tasker/Controller',
24
+ ' screencap [out.png] 截一帧落盘(同时进 L0,可升格)',
25
+ ' stream start [--fps n] [--scale n] [--l0-roll n] [--l0-anchor n] [--max-frames n] [--block-thresh n] [--change-global n] [--l0-bytes n]',
26
+ ' stream stop | stream status',
27
+ ' frame [seq] [--roi x,y,w,h] [--src auto|full|ring] [--out f.png] 取帧(--src full = 控制器分辨率原图)',
28
+ ' frame diff --a <seq> --b <seq> --rois "x,y,w,h;x,y,w,h" 两帧在若干 ROI 上的差分(判断"改了哪几格")',
29
+ ' wait stable|change [--timeout ms] [--quiet ms] [--roi x,y,w,h] [--threshold n] 等状态谓词成立(别硬睡)',
30
+ ' color [--roi x,y,w,h] 探色(均值/HSV/主色,最新缓冲帧)',
31
+ ' crop --roi x,y,w,h | --point x,y [--pad n] [--out f.png] [--from-kf kf:...] 模板裁剪(原图 + 自匹配;--from-kf 走库内留存帧,离线)',
32
+ ' annotate [--out f.png] [--som-limit n] [--som-max-area-ratio r] [--from-kf kf:...] SoM 候选与编号回画(回画优先全分辨率;库帧路径离线、diff 不可用)',
33
+ ' events [n] 最近 n 条帧流事件(默认 10)',
34
+ ' logs [n] daemon 原生 stderr 尾部(默认 20)',
35
+ ' run <entry> [--timeout ms|0] [--override json] [--resource-dir d] (异步执行,不阻塞提示符)',
36
+ ' stop 停止运行中的任务',
37
+ ' click <x> <y> | swipe <x1> <y1> <x2> <y2> [--duration ms] [--via "x,y;x,y"] | key <code> | text <string>',
38
+ ' swipe --via 走 touch_down→逐点 touch_move→touch_up:**一条笔画画折线**(中途不抬手)',
39
+ ' press <x> <y> [--duration ms] | dbclick <x> <y> | keys <c[,c+..]> | scroll <dx> <dy> | move <dx> <dy>',
40
+ ' touchdown <x> <y> | touchmove <x> <y> | touchup 裸触点原语(跨命令维持同一次按压)',
41
+ ' reco <type> [k=v ...] [--sweep json] 识别单测(用缓冲最新帧)',
42
+ ' kf status | kf promote [seq|latest] [--note s] | kf list | kf resolve <kf:...>',
43
+ ' help | quit',
44
+ ];
45
+ /** 极简分词:空格分隔,支持 --flag value 与 key=value;text 用整行剩余内容。
46
+ *
47
+ * **成对引号要剥掉**:脚本与 agent 习惯把带逗号的参数写进引号(`--roi "470,10,180,110"`),
48
+ * 不剥就整串当参数:`Number('"470')` = NaN → 走 JSON 变 null → daemon `Number(null)` = 0,
49
+ * 于是退化成一个**合法但错误**的框(实测 `--roi "400,20,200,60"` 变成 `w=200,h=0`)且不报错。
50
+ * 剥引号让"带引号"与"不带引号"等价——这是调用方最自然的写法。 */
51
+ function tokenize(line) {
52
+ return line.trim().split(/\s+/).filter((t) => t !== '').map((t) => {
53
+ if (t.length >= 2) {
54
+ const a = t[0], z = t[t.length - 1];
55
+ if ((a === '"' && z === '"') || (a === "'" && z === "'"))
56
+ return t.slice(1, -1);
57
+ }
58
+ return t;
59
+ });
60
+ }
61
+ function readFlag(tokens, name) {
62
+ const i = tokens.indexOf(name);
63
+ return i >= 0 ? tokens[i + 1] : undefined;
64
+ }
65
+ /**
66
+ * 取 count 个数字参数;任何一个不是有限数就返回 null。
67
+ *
68
+ * REPL 直接复用同一个设备会话,敲错一个坐标就是**真机上一次真实输入**——空参的 `click`
69
+ * 会变成 `Number(undefined)` = NaN → JSON 里变 null → daemon `Number(null)` = 0,
70
+ * 也就是"什么都没写"等于"点左上角"。CLI 侧(`input.ts`)每条都有这道校验,REPL 也得有。
71
+ */
72
+ function nums(tokens, from, count) {
73
+ const out = [];
74
+ for (let i = 0; i < count; i++) {
75
+ const n = Number(tokens[from + i]);
76
+ if (!Number.isFinite(n))
77
+ return null;
78
+ out.push(n);
79
+ }
80
+ return out;
81
+ }
82
+ /** `--flag` 的数字值:没给就用默认;给了但不是数字 → null(调用方报用法,不把 NaN 发给设备) */
83
+ function numFlag(tokens, name, def) {
84
+ const raw = readFlag(tokens, name);
85
+ if (raw === undefined)
86
+ return def;
87
+ const n = Number(raw);
88
+ return Number.isFinite(n) ? n : null;
89
+ }
90
+ function short(v, max = 400) {
91
+ const s = typeof v === 'string' ? v : JSON.stringify(v);
92
+ return s.length > max ? s.slice(0, max) + ' …' : s;
93
+ }
94
+ /** REPL 自己的词表(与 CLI 命令集**不同**:stream/wait/events/logs 只在会话里)。
95
+ * 敲错时给最近的命令名——一次性 CLI 早有编辑距离建议,会话里没有,而会话里敲错更贵
96
+ * (要么重新起会话,要么对着"未知命令"猜半天)。 */
97
+ const REPL_HEADS = [
98
+ 'probe', 'device', 'connect', 'disconnect', 'screencap', 'stream', 'wait', 'frame',
99
+ 'color', 'crop', 'annotate', 'events', 'logs', 'run', 'stop', 'click', 'swipe',
100
+ 'key', 'keys', 'press', 'dbclick', 'scroll', 'move', 'text', 'touchdown', 'touchmove',
101
+ 'touchup', 'reco', 'kf', 'help', 'quit',
102
+ ];
103
+ function editDistance(a, b) {
104
+ const m = a.length, n = b.length;
105
+ if (!m)
106
+ return n;
107
+ if (!n)
108
+ return m;
109
+ let prev = Array.from({ length: n + 1 }, (_, i) => i);
110
+ for (let i = 1; i <= m; i++) {
111
+ const cur = [i];
112
+ for (let j = 1; j <= n; j++) {
113
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
114
+ }
115
+ prev = cur;
116
+ }
117
+ return prev[n];
118
+ }
119
+ function suggestHead(name, maxDistance = 3) {
120
+ let best;
121
+ let bestDist = maxDistance + 1;
122
+ for (const h of REPL_HEADS) {
123
+ const d = editDistance(name, h);
124
+ if (d < bestDist) {
125
+ bestDist = d;
126
+ best = h;
127
+ }
128
+ }
129
+ return bestDist <= maxDistance ? best : undefined;
130
+ }
131
+ export const replCommand = {
132
+ name: 'repl',
133
+ summary: '交互/管道会话:连接一次,后续命令复用同一个 daemon 与设备会话',
134
+ usage: 'maafw-live repl [--project <dir>|--kind win32|adb|gamepad [--target ...]]',
135
+ options: { ...CONNECT_OPTIONS },
136
+ async run(ctx) {
137
+ const client = spawnDaemon();
138
+ const out = (line) => { process.stdout.write(line + '\n'); };
139
+ const state = { session: '(未连接)', plan: null };
140
+ const remember = (s) => {
141
+ state.session = String(s.session?.target ?? '(已连接)');
142
+ state.plan = s.plan ?? null;
143
+ };
144
+ const o = sessionOptions(ctx.values);
145
+ if (o.project || o.kind) {
146
+ try {
147
+ const s = await ensureSession(client, o);
148
+ remember(s);
149
+ out('已连接:' + state.session);
150
+ }
151
+ catch (e) {
152
+ out('连接失败:' + (e instanceof Error ? e.message : String(e)));
153
+ }
154
+ }
155
+ else {
156
+ out('未连接设备。先 connect project <dir> 或 connect win32 <target>。输入 help 看命令。');
157
+ }
158
+ const rl = readline.createInterface({ input: process.stdin, terminal: process.stdin.isTTY === true });
159
+ /* 行队列:readline 的 'line' 事件不排队——命令 await 期间到达的行会被 once('line') 直接丢失,
160
+ * 管道驱动的多行脚本(printf 'connect…\nscreencap\nquit\n' | repl)就死在第二行。
161
+ * 持续监听 + 队列缓冲,ask() 从队列取或挂起等新行/EOF。 */
162
+ let rlEnded = false;
163
+ const lineQueue = [];
164
+ let lineWake = null;
165
+ rl.on('line', (l) => {
166
+ lineQueue.push(l);
167
+ if (lineWake) {
168
+ const w = lineWake;
169
+ lineWake = null;
170
+ w();
171
+ }
172
+ });
173
+ rl.on('close', () => {
174
+ rlEnded = true;
175
+ if (lineWake) {
176
+ const w = lineWake;
177
+ lineWake = null;
178
+ w();
179
+ }
180
+ });
181
+ const ask = async () => {
182
+ for (;;) {
183
+ if (lineQueue.length)
184
+ return lineQueue.shift() ?? null;
185
+ if (rlEnded)
186
+ return null;
187
+ await new Promise((r) => { lineWake = r; });
188
+ }
189
+ };
190
+ try {
191
+ for (;;) {
192
+ if (process.stdin.isTTY)
193
+ process.stdout.write('maa> ');
194
+ const line = await ask();
195
+ if (line === null)
196
+ break;
197
+ const tokens = tokenize(line);
198
+ if (!tokens.length)
199
+ continue;
200
+ const head = tokens[0];
201
+ if (head === 'quit' || head === 'exit')
202
+ break;
203
+ if (head === 'help') {
204
+ for (const h of HELP)
205
+ out(h);
206
+ continue;
207
+ }
208
+ try {
209
+ switch (head) {
210
+ case 'probe':
211
+ out(short(await act.probe(client)));
212
+ break;
213
+ case 'device':
214
+ out(short(await act.deviceList(client, tokens[1] ?? 'all')));
215
+ break;
216
+ case 'connect': {
217
+ let s;
218
+ if (tokens[1] === 'project') {
219
+ s = await ensureSession(client, { project: tokens[2], hwnd: tokens[3], address: tokens[3] });
220
+ }
221
+ else {
222
+ s = await ensureSession(client, { kind: tokens[1], target: tokens[2] });
223
+ }
224
+ remember(s);
225
+ out('已连接:' + state.session);
226
+ break;
227
+ }
228
+ case 'disconnect':
229
+ out(short(await act.disconnect(client)));
230
+ state.session = '(未连接)';
231
+ break;
232
+ case 'screencap':
233
+ out(short(await act.screencap(client, tokens[1])));
234
+ break;
235
+ case 'stream': {
236
+ const sub = tokens[1] ?? 'status';
237
+ if (sub === 'start') {
238
+ const fps = readFlag(tokens, '--fps');
239
+ const scale = readFlag(tokens, '--scale');
240
+ const blockThresh = readFlag(tokens, '--block-thresh');
241
+ const changeGlobal = readFlag(tokens, '--change-global');
242
+ const l0Bytes = readFlag(tokens, '--l0-bytes');
243
+ /* 观测策略参数:l0-roll 决定"我有多久回看窗口"(默认 16 帧 ≈ 1.6s @10fps),
244
+ * max-frames 决定环形缓冲长度。协议本就支持,命令面必须可达——
245
+ * 不可达的参数会被调用方当成"没有"。 */
246
+ const maxFrames = readFlag(tokens, '--max-frames');
247
+ const l0Roll = readFlag(tokens, '--l0-roll');
248
+ const l0Anchor = readFlag(tokens, '--l0-anchor');
249
+ out(short(await act.streamStart(client, {
250
+ ...(fps ? { fps: Number(fps) } : {}),
251
+ ...(scale ? { scale: Number(scale) } : {}),
252
+ ...(blockThresh ? { blockThresh: Number(blockThresh) } : {}),
253
+ ...(changeGlobal ? { changeGlobal: Number(changeGlobal) } : {}),
254
+ ...(l0Bytes ? { l0Bytes: Number(l0Bytes) } : {}),
255
+ ...(maxFrames ? { maxFrames: Number(maxFrames) } : {}),
256
+ ...(l0Roll ? { l0Roll: Number(l0Roll) } : {}),
257
+ ...(l0Anchor ? { l0Anchor: Number(l0Anchor) } : {}),
258
+ })));
259
+ }
260
+ else if (sub === 'stop')
261
+ out(short(await act.streamStop(client)));
262
+ else
263
+ out(short(await act.streamStatus(client)));
264
+ break;
265
+ }
266
+ case 'wait': {
267
+ /* 状态谓词:等到条件成立再往下读,取代硬睡时钟。超时不算错误(satisfied=false)。 */
268
+ const modeRaw = tokens[1];
269
+ if (modeRaw !== 'stable' && modeRaw !== 'change') {
270
+ out('用法:wait stable|change [--timeout ms] [--quiet ms] [--roi x,y,w,h] [--threshold n]'
271
+ + '(模式只认 stable / change'
272
+ + (modeRaw ? ';收到 ' + JSON.stringify(modeRaw)
273
+ + (suggestHead(modeRaw) ? ',是想输入 ' + String(suggestHead(modeRaw)) + ' 吗' : '') : '')
274
+ + ')');
275
+ break;
276
+ }
277
+ /* 数值 flag 必须校验:`--timeout abc` 会变成 NaN 一路传下去,
278
+ * 客户端超时算成 NaN → 调用秒超时,看起来像"工具坏了"。 */
279
+ const numOr = (name) => {
280
+ const raw = readFlag(tokens, name);
281
+ if (raw === undefined)
282
+ return undefined;
283
+ const n = Number(raw);
284
+ return Number.isFinite(n) ? n : null;
285
+ };
286
+ const tmo = numOr('--timeout');
287
+ const qt = numOr('--quiet');
288
+ const thr = numOr('--threshold');
289
+ if (tmo === null || qt === null || thr === null) {
290
+ out('--timeout / --quiet / --threshold 必须是数字'
291
+ + (tmo === null ? '(--timeout 收到 ' + JSON.stringify(readFlag(tokens, '--timeout')) + ')' : ''));
292
+ break;
293
+ }
294
+ const roiRaw = readFlag(tokens, '--roi');
295
+ const r = await act.waitState(client, {
296
+ mode: modeRaw,
297
+ ...(tmo !== undefined ? { timeout: tmo } : {}),
298
+ ...(qt !== undefined ? { quiet: qt } : {}),
299
+ ...(thr !== undefined ? { threshold: thr } : {}),
300
+ ...(roiRaw ? { roi: roiRaw.split(',').map((x) => Number(x)) } : {}),
301
+ });
302
+ out(short(r, 500));
303
+ break;
304
+ }
305
+ case 'frame': {
306
+ const roiRaw = readFlag(tokens, '--roi');
307
+ const srcRaw = readFlag(tokens, '--src');
308
+ const seq = tokens[1] && !tokens[1].startsWith('--') ? Number(tokens[1]) : undefined;
309
+ if (tokens[1] === 'diff') {
310
+ /* 两帧在若干 ROI 上的差分:离散网格要的是"哪几格变了",不是整帧 bbox。 */
311
+ const a = readFlag(tokens, '--a');
312
+ const b = readFlag(tokens, '--b');
313
+ const roisRaw = readFlag(tokens, '--rois');
314
+ if (a === undefined || b === undefined || !roisRaw) {
315
+ out('用法:frame diff --a <seq> --b <seq> --rois "x,y,w,h;x,y,w,h"');
316
+ break;
317
+ }
318
+ const rois = roisRaw.split(';').map((s) => s.split(',').map((x) => Number(x)));
319
+ if (rois.some((r) => r.length !== 4 || r.some((n) => !Number.isFinite(n)))) {
320
+ out('--rois 每段要 4 个数字,段间用 ; 分隔');
321
+ break;
322
+ }
323
+ const d = await act.frameDiff(client, { a: Number(a), b: Number(b), rois });
324
+ out(short(d, 900));
325
+ break;
326
+ }
327
+ out(short(await act.frameGet(client, {
328
+ ...(seq !== undefined ? { seq } : {}),
329
+ ...(roiRaw ? { roi: roiRaw.split(',').map((x) => Number(x)) } : {}),
330
+ ...(readFlag(tokens, '--out') ? { out: readFlag(tokens, '--out') } : {}),
331
+ ...(srcRaw ? { src: srcRaw } : {}),
332
+ })));
333
+ break;
334
+ }
335
+ case 'color': {
336
+ const roiRaw = readFlag(tokens, '--roi');
337
+ out(short(await act.colorProbe(client, {
338
+ ...(roiRaw ? { roi: roiRaw.split(',').map((x) => Number(x)) } : {}),
339
+ }), 400));
340
+ break;
341
+ }
342
+ case 'crop': {
343
+ const roiRaw = readFlag(tokens, '--roi');
344
+ const pointRaw = readFlag(tokens, '--point');
345
+ const padRaw = readFlag(tokens, '--pad');
346
+ /* --from-kf:源换成库内留存帧(跨会话可用)。解析走与一次性 CLI 同一份离线读侧。 */
347
+ const kfId = readFlag(tokens, '--from-kf');
348
+ let kfSource;
349
+ if (kfId) {
350
+ const dir = readFlag(tokens, '--frames-dir') ?? defaultFramesDir();
351
+ const rr = resolveFrame(dir, kfId);
352
+ if (rr.status !== 'available' || !rr.path || !rr.record) {
353
+ out('库帧不可用(' + rr.status + '):' + String(rr.reason ?? '') + '(库 ' + dir + ')');
354
+ break;
355
+ }
356
+ kfSource = {
357
+ id: rr.record.id, path: rr.path, sha256: rr.record.sha256,
358
+ ctrlW: rr.record.ctrlW, ctrlH: rr.record.ctrlH,
359
+ captureSeq: rr.record.captureSeq, capturedAt: rr.record.capturedAt,
360
+ };
361
+ }
362
+ const ck = await act.tplCrop(client, {
363
+ ...(roiRaw ? { roi: roiRaw.split(',').map((x) => Number(x)) } : {}),
364
+ ...(pointRaw ? { point: pointRaw.split(',').map((x) => Number(x)) } : {}),
365
+ ...(padRaw ? { pad: Number(padRaw) } : {}),
366
+ ...(readFlag(tokens, '--out') ? { out: readFlag(tokens, '--out') } : {}),
367
+ ...(kfSource ? { kfSource, cross: false } : {}),
368
+ ...(readFlag(tokens, '--resource-dir')
369
+ ? { resourceDir: readFlag(tokens, '--resource-dir') }
370
+ : (state.plan?.resource?.paths.length ? { resourceDir: state.plan.resource.paths[0] } : {})),
371
+ });
372
+ out(short(ck, 600));
373
+ if (ck.ok !== false && ck.provPath)
374
+ out('L2 出处:' + String(ck.provPath));
375
+ break;
376
+ }
377
+ case 'annotate': {
378
+ /* --from-kf:源换成库内留存帧(离线;diff 源不可用,工具会如实报) */
379
+ const kfSom = readFlag(tokens, '--from-kf');
380
+ let somKf;
381
+ if (kfSom) {
382
+ const dir = readFlag(tokens, '--frames-dir') ?? defaultFramesDir();
383
+ const rr = resolveFrame(dir, kfSom);
384
+ if (rr.status !== 'available' || !rr.path || !rr.record) {
385
+ out('库帧不可用(' + rr.status + '):' + String(rr.reason ?? '') + '(库 ' + dir + ')');
386
+ break;
387
+ }
388
+ somKf = {
389
+ id: rr.record.id, path: rr.path, sha256: rr.record.sha256,
390
+ ctrlW: rr.record.ctrlW, ctrlH: rr.record.ctrlH,
391
+ captureSeq: rr.record.captureSeq, capturedAt: rr.record.capturedAt,
392
+ };
393
+ }
394
+ const somLimitRaw = readFlag(tokens, '--som-limit');
395
+ const somLimit = somLimitRaw === undefined ? undefined : Number(somLimitRaw);
396
+ if (somLimitRaw !== undefined && (!Number.isFinite(somLimit) || somLimit <= 0)) {
397
+ out('--som-limit 必须是正整数(收到 ' + JSON.stringify(somLimitRaw) + ');0 不等于"不限量"');
398
+ break;
399
+ }
400
+ const somRatioRaw = readFlag(tokens, '--som-max-area-ratio');
401
+ const somRatio = somRatioRaw === undefined ? undefined : Number(somRatioRaw);
402
+ if (somRatioRaw !== undefined && (!Number.isFinite(somRatio) || somRatio <= 0 || somRatio >= 1)) {
403
+ out('--som-max-area-ratio 要在 (0,1) 之间(收到 ' + JSON.stringify(somRatioRaw) + ');0 不等于"关闭剔除"');
404
+ break;
405
+ }
406
+ const r = await act.annotate(client, {
407
+ ...(readFlag(tokens, '--out') ? { out: readFlag(tokens, '--out') } : {}),
408
+ ...(somKf ? { kfSource: somKf } : {}),
409
+ ...(somLimit !== undefined ? { somLimit } : {}),
410
+ ...(somRatio !== undefined ? { somMaxAreaRatio: somRatio } : {}),
411
+ ...(readFlag(tokens, '--roi') ? { roi: String(readFlag(tokens, '--roi')).split(',').map((x) => Number(x)) } : {}),
412
+ ...(readFlag(tokens, '--resource-dir')
413
+ ? { resourceDir: readFlag(tokens, '--resource-dir') }
414
+ : (state.plan?.resource?.paths.length ? { resourceDir: state.plan.resource.paths[0] } : {})),
415
+ });
416
+ if (r.ok === false) {
417
+ out('annotate 失败:' + String(r.error));
418
+ break;
419
+ }
420
+ out('SoM ' + String(r.count) + ' 候选 → ' + String(r.out) + (somKf ? '(源:留存帧)' : ''));
421
+ /* 可用性三列:不读它就会把"30 条"当成"画面就这些可点区域"。 */
422
+ const av = r.availability;
423
+ if (av) {
424
+ out(' 可用性:合并池 ' + String(av.mergedTotal ?? '?') + ' → 进表 ' + String(r.count)
425
+ + '(上限 ' + String(av.limit ?? '?') + ',截断 ' + String(av.truncated ?? 0) + ')'
426
+ + ';剔除 巨框 ' + String(av.dropped?.tooBig ?? 0) + ' / 重复 ' + String(av.dropped?.duplicated ?? 0));
427
+ if (av.byBand)
428
+ out(' 分带(上/中/下)进表/池:' + av.byBand.map((b) => String(b.inTable) + '/' + String(b.inPool)).join(' '));
429
+ if (av.outResolution)
430
+ out(' 回画分辨率:' + String(av.outResolution.w) + 'x' + String(av.outResolution.h) + '(' + String(av.outResolution.source) + ')');
431
+ }
432
+ for (const c of r.candidates ?? []) {
433
+ out(' #' + c.id + ' [' + c.source + '] ctrl=' + c.ctrl.join(',')
434
+ + (typeof c.areaRatio === 'number' ? ' 面积比=' + c.areaRatio : '')
435
+ + (typeof c.score === 'number' ? ' score=' + c.score : '')
436
+ + (c.text ? ' "' + c.text + '"' : ''));
437
+ }
438
+ break;
439
+ }
440
+ case 'events': {
441
+ const n = Number(tokens[1] ?? 10);
442
+ const evs = client.events.events.slice(-n);
443
+ out('事件 ' + evs.length + ' 条(共 ' + client.events.events.length + ')');
444
+ for (const e of evs)
445
+ out(' ' + short(e, 200));
446
+ break;
447
+ }
448
+ case 'logs': {
449
+ const n = Number(tokens[1] ?? 20);
450
+ for (const l of client.logTail(n))
451
+ out(' ' + l);
452
+ break;
453
+ }
454
+ case 'run': {
455
+ const entry = tokens[1];
456
+ if (!entry) {
457
+ out('用法:run <entry> [--timeout ms|0] [--override json] [--resource-dir d]');
458
+ break;
459
+ }
460
+ /* 任务名 → pipeline 入口解析(与一次性 CLI 同规则):interface 的 task 名(如中文
461
+ * "启动游戏")不是 pipeline 节点名,不解析会 task not exist 秒败 */
462
+ const task = state.plan?.loaded.tasks.find((t) => t.name === entry || t.entry === entry);
463
+ const resolvedEntry = task?.entry ?? entry;
464
+ const timeout = Number(readFlag(tokens, '--timeout') ?? 30000);
465
+ const overrideRaw = readFlag(tokens, '--override');
466
+ let override = {};
467
+ if (overrideRaw) {
468
+ try {
469
+ override = JSON.parse(overrideRaw);
470
+ }
471
+ catch {
472
+ out('--override 不是合法 JSON');
473
+ break;
474
+ }
475
+ }
476
+ /* 资源目录:--resource-dir 优先,其次当前项目规划(connect project 的 interface.json) */
477
+ const resDirs = state.plan?.resource?.paths ?? [];
478
+ const explicitDir = readFlag(tokens, '--resource-dir');
479
+ if (!explicitDir && !resDirs.length) {
480
+ out('缺资源:给 --resource-dir <dir>,或先 connect project <dir>');
481
+ break;
482
+ }
483
+ /* 异步执行不阻塞提示符:--timeout 0 的长任务靠 stop 中断(同一条客户端连接) */
484
+ out('已提交 ' + resolvedEntry + (task && task.name !== resolvedEntry ? '(任务 ' + task.name + ')' : '') + '(timeout=' + timeout + ',结果异步打印;stop 可中断)');
485
+ const plan = state.plan;
486
+ act.run(client, explicitDir
487
+ ? { resourceDir: explicitDir, entry: resolvedEntry, override, timeoutMs: timeout }
488
+ : {
489
+ resourceDirs: resDirs, entry: resolvedEntry, override, timeoutMs: timeout,
490
+ ...(plan?.loaded.agents.length
491
+ ? { agents: plan.loaded.agents.map((a) => ({ exec: a.exec, args: a.args })), agentCwd: plan.loaded.dir ?? undefined }
492
+ : {}),
493
+ }, timeout)
494
+ .then((r) => {
495
+ const rec = r.record;
496
+ out('run 完成:任务级 ' + (rec ? (rec.ok ? 'ok' : 'FAIL') : '未知') +
497
+ (rec?.agent ? ',agent ' + (rec.agent.ok ? '已连接' : '失败') : '') + ' ' + short(r, 700));
498
+ })
499
+ .catch((e) => out('run 失败:' + (e instanceof Error ? e.message : String(e))));
500
+ break;
501
+ }
502
+ case 'stop':
503
+ out(short(await act.runStop(client)));
504
+ break;
505
+ case 'click': {
506
+ const a = nums(tokens, 1, 2);
507
+ if (!a) {
508
+ out('用法:click <x> <y>');
509
+ break;
510
+ }
511
+ out(short(await act.input(client, { kind: 'click', x: a[0], y: a[1] })));
512
+ break;
513
+ }
514
+ case 'swipe': {
515
+ const a = nums(tokens, 1, 4);
516
+ const duration = numFlag(tokens, '--duration', 300);
517
+ if (!a || duration === null) {
518
+ out('用法:swipe <x1> <y1> <x2> <y2> [--duration ms] [--via "x,y;x,y"]');
519
+ break;
520
+ }
521
+ /* --via:中途路径点。走的是 touch_down→逐点 touch_move→touch_up —— **一条笔画画折线**,
522
+ * 中途不抬手。连线/谱曲/拖拽排序这类玩法要的正是这个;只有两端点时走原生 post_swipe。 */
523
+ const viaRaw = readFlag(tokens, '--via');
524
+ let via;
525
+ if (viaRaw) {
526
+ via = viaRaw.split(';').map((s) => s.split(',').map((x) => Number(x)));
527
+ if (via.some((p) => p.length !== 2 || p.some((n) => !Number.isFinite(n)))) {
528
+ out('--via 每段要 2 个数字(x,y),段间用 ; 分隔');
529
+ break;
530
+ }
531
+ }
532
+ out(short(await act.input(client, {
533
+ kind: 'swipe', x1: a[0], y1: a[1], x2: a[2], y2: a[3], duration,
534
+ ...(via ? { via } : {}),
535
+ })));
536
+ break;
537
+ }
538
+ case 'touchdown': {
539
+ const a = nums(tokens, 1, 2);
540
+ if (!a) {
541
+ out('用法:touchdown <x> <y>');
542
+ break;
543
+ }
544
+ out(short(await act.input(client, { kind: 'touch_down', x: a[0], y: a[1] })));
545
+ break;
546
+ }
547
+ case 'touchmove': {
548
+ const a = nums(tokens, 1, 2);
549
+ if (!a) {
550
+ out('用法:touchmove <x> <y>');
551
+ break;
552
+ }
553
+ out(short(await act.input(client, { kind: 'touch_move', x: a[0], y: a[1] })));
554
+ break;
555
+ }
556
+ case 'touchup':
557
+ out(short(await act.input(client, { kind: 'touch_up' })));
558
+ break;
559
+ case 'key': {
560
+ const a = nums(tokens, 1, 1);
561
+ if (!a) {
562
+ out('用法:key <code>(Android KeyEvent 码:3=Home、4=返回)');
563
+ break;
564
+ }
565
+ out(short(await act.input(client, { kind: 'key', code: a[0] })));
566
+ break;
567
+ }
568
+ case 'keys': {
569
+ /* 键码只保留正整数:与 CLI/daemon 同一条口径,空集合直接报用法 */
570
+ const codes = String(tokens[1] ?? '').split(/[+,]/)
571
+ .map((s) => Number(s.trim())).filter((n) => Number.isFinite(n) && n > 0);
572
+ if (!codes.length) {
573
+ out('用法:keys <code[,code+...]>(至少一个正整数键码,如 17,67)');
574
+ break;
575
+ }
576
+ out(short(await act.input(client, { kind: 'keys', codes })));
577
+ break;
578
+ }
579
+ case 'press': {
580
+ const a = nums(tokens, 1, 2);
581
+ const duration = numFlag(tokens, '--duration', 800);
582
+ if (!a || duration === null) {
583
+ out('用法:press <x> <y> [--duration ms]');
584
+ break;
585
+ }
586
+ out(short(await act.input(client, { kind: 'press', x: a[0], y: a[1], duration })));
587
+ break;
588
+ }
589
+ case 'dbclick': {
590
+ const a = nums(tokens, 1, 2);
591
+ if (!a) {
592
+ out('用法:dbclick <x> <y>');
593
+ break;
594
+ }
595
+ out(short(await act.input(client, { kind: 'dbclick', x: a[0], y: a[1] })));
596
+ break;
597
+ }
598
+ case 'scroll': {
599
+ const a = nums(tokens, 1, 2);
600
+ if (!a) {
601
+ out('用法:scroll <dx> <dy>(建议 120 的倍数)');
602
+ break;
603
+ }
604
+ out(short(await act.input(client, { kind: 'scroll', dx: a[0], dy: a[1] })));
605
+ break;
606
+ }
607
+ case 'move': {
608
+ const a = nums(tokens, 1, 2);
609
+ if (!a) {
610
+ out('用法:move <dx> <dy>');
611
+ break;
612
+ }
613
+ out(short(await act.input(client, { kind: 'move', dx: a[0], dy: a[1] })));
614
+ break;
615
+ }
616
+ case 'text':
617
+ out(short(await act.input(client, { kind: 'text', text: line.trim().slice(5) })));
618
+ break;
619
+ case 'reco': {
620
+ const type = tokens[1] ?? 'TemplateMatch';
621
+ const param = {};
622
+ for (const t of tokens.slice(2)) {
623
+ const eq = t.indexOf('=');
624
+ if (eq > 0) {
625
+ const k = t.slice(0, eq);
626
+ const v = t.slice(eq + 1);
627
+ param[k] = Number.isFinite(Number(v)) && v !== '' ? Number(v) : v;
628
+ }
629
+ }
630
+ const sweepRaw = readFlag(tokens, '--sweep');
631
+ const resourceDir = readFlag(tokens, '--resource-dir') ?? '';
632
+ out(short(await act.recoTest(client, {
633
+ resourceDir, type, param,
634
+ ...(sweepRaw ? { sweep: JSON.parse(sweepRaw) } : {}),
635
+ }), 800));
636
+ break;
637
+ }
638
+ case 'kf': {
639
+ const sub = tokens[1] ?? 'status';
640
+ if (sub === 'status') {
641
+ out(short(await act.l0Status(client), 600));
642
+ }
643
+ else if (sub === 'promote') {
644
+ const what = tokens[2];
645
+ const note = readFlag(tokens, '--note');
646
+ const r = await act.kfPromote(client, /^\d+$/.test(String(what))
647
+ ? { seq: Number(what), ...(note ? { note } : {}) }
648
+ : { latest: true, ...(note ? { note } : {}) });
649
+ if (r.ok === false)
650
+ out('升格失败:' + String(r.error));
651
+ else
652
+ out('已升格 ' + String(r.id) + ' → ' + String(r.path) + (r.idempotent ? '(幂等重试)' : ''));
653
+ }
654
+ else if (sub === 'list') {
655
+ const { manifest, error } = loadManifest(defaultFramesDir());
656
+ if (error) {
657
+ out('库不可读:' + error);
658
+ break;
659
+ }
660
+ if (!manifest) {
661
+ out('关键帧库尚无留存');
662
+ break;
663
+ }
664
+ out('关键帧库 ' + manifest.frames.length + ' 条(libraryId ' + manifest.libraryId + ')');
665
+ for (const f of manifest.frames)
666
+ out(' ' + describeRecord(f));
667
+ }
668
+ else if (sub === 'resolve') {
669
+ const r = resolveFrame(defaultFramesDir(), String(tokens[2] ?? ''));
670
+ out(r.status + (r.reason ? '(' + r.reason + ')' : '') + (r.path ? ' ' + r.path : ''));
671
+ }
672
+ else {
673
+ out('用法:kf status | kf promote [seq|latest] [--note s] | kf list | kf resolve <kf:...>');
674
+ }
675
+ break;
676
+ }
677
+ default:
678
+ out('未知命令:' + head
679
+ + (suggestHead(head) ? '(是想输入 ' + String(suggestHead(head)) + ' 吗?)' : '')
680
+ + '(输入 help 看命令表)');
681
+ }
682
+ }
683
+ catch (e) {
684
+ if (e instanceof SessionError)
685
+ out('会话错误 [' + e.code + ']:' + e.message);
686
+ else
687
+ out('错误:' + (e instanceof Error ? e.message : String(e)));
688
+ }
689
+ }
690
+ }
691
+ finally {
692
+ rl.close();
693
+ client.close();
694
+ }
695
+ return { exitCode: EXIT.OK, human: ['会话结束。'] };
696
+ },
697
+ };
698
+ //# sourceMappingURL=repl.js.map