@windsland52/maafw-live 0.0.0-stage → 0.4.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 (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -2
  3. package/bin/maafw-live.mjs +25 -0
  4. package/docs/daemon-protocol.md +121 -0
  5. package/docs/keyframe-retention-contract-v0.md +225 -0
  6. package/docs/session-unify-v0.md +115 -0
  7. package/lib/client/daemon.js +276 -0
  8. package/lib/commands/annotate.js +179 -0
  9. package/lib/commands/calibrate.js +61 -0
  10. package/lib/commands/color.js +73 -0
  11. package/lib/commands/crop.js +228 -0
  12. package/lib/commands/dispatch.js +121 -0
  13. package/lib/commands/env.js +409 -0
  14. package/lib/commands/index.js +70 -0
  15. package/lib/commands/input.js +257 -0
  16. package/lib/commands/kf.js +167 -0
  17. package/lib/commands/probe.js +70 -0
  18. package/lib/commands/reco.js +440 -0
  19. package/lib/commands/repl.js +1501 -0
  20. package/lib/commands/roi-check.js +273 -0
  21. package/lib/commands/roi-compare.js +388 -0
  22. package/lib/commands/runtime.js +718 -0
  23. package/lib/commands/skill.js +169 -0
  24. package/lib/commands/timing.js +247 -0
  25. package/lib/commands/version.js +66 -0
  26. package/lib/daemon/framed.mjs +3680 -0
  27. package/lib/daemon/reco_child.mjs +287 -0
  28. package/lib/exec.js +96 -0
  29. package/lib/flags.js +44 -0
  30. package/lib/image.js +90 -0
  31. package/lib/index.js +143 -0
  32. package/lib/interface/hotkey.js +142 -0
  33. package/lib/interface/index.js +12 -0
  34. package/lib/interface/jsonc.js +95 -0
  35. package/lib/interface/load.js +397 -0
  36. package/lib/interface/override.js +287 -0
  37. package/lib/interface/plan.js +279 -0
  38. package/lib/option-args.js +29 -0
  39. package/lib/pkg.js +54 -0
  40. package/lib/protocol.js +94 -0
  41. package/lib/runtime/actions.js +225 -0
  42. package/lib/runtime/keyframes.js +73 -0
  43. package/lib/runtime/log-watch.js +160 -0
  44. package/lib/runtime/node-source.js +100 -0
  45. package/lib/runtime/ocr-model.js +45 -0
  46. package/lib/runtime/pi-env.js +34 -0
  47. package/lib/runtime/pretask.js +217 -0
  48. package/lib/runtime/prune.js +28 -0
  49. package/lib/runtime/receipts.js +57 -0
  50. package/lib/runtime/reco-args.js +38 -0
  51. package/lib/runtime/reco-verdict.js +82 -0
  52. package/lib/runtime/roi-compare.js +198 -0
  53. package/lib/runtime/session-forward.js +79 -0
  54. package/lib/runtime/session-ipc.js +471 -0
  55. package/lib/runtime/session.js +135 -0
  56. package/lib/runtime/signal-guard.js +35 -0
  57. package/lib/types/client/daemon.d.ts +86 -0
  58. package/lib/types/commands/annotate.d.ts +3 -0
  59. package/lib/types/commands/calibrate.d.ts +7 -0
  60. package/lib/types/commands/color.d.ts +9 -0
  61. package/lib/types/commands/crop.d.ts +3 -0
  62. package/lib/types/commands/dispatch.d.ts +22 -0
  63. package/lib/types/commands/env.d.ts +21 -0
  64. package/lib/types/commands/index.d.ts +13 -0
  65. package/lib/types/commands/input.d.ts +17 -0
  66. package/lib/types/commands/kf.d.ts +10 -0
  67. package/lib/types/commands/probe.d.ts +15 -0
  68. package/lib/types/commands/reco.d.ts +27 -0
  69. package/lib/types/commands/repl.d.ts +10 -0
  70. package/lib/types/commands/roi-check.d.ts +3 -0
  71. package/lib/types/commands/roi-compare.d.ts +3 -0
  72. package/lib/types/commands/runtime.d.ts +66 -0
  73. package/lib/types/commands/skill.d.ts +17 -0
  74. package/lib/types/commands/timing.d.ts +64 -0
  75. package/lib/types/commands/version.d.ts +3 -0
  76. package/lib/types/exec.d.ts +31 -0
  77. package/lib/types/flags.d.ts +62 -0
  78. package/lib/types/image.d.ts +27 -0
  79. package/lib/types/index.d.ts +1 -0
  80. package/lib/types/interface/hotkey.d.ts +36 -0
  81. package/lib/types/interface/index.d.ts +11 -0
  82. package/lib/types/interface/jsonc.d.ts +9 -0
  83. package/lib/types/interface/load.d.ts +190 -0
  84. package/lib/types/interface/override.d.ts +78 -0
  85. package/lib/types/interface/plan.d.ts +68 -0
  86. package/lib/types/option-args.d.ts +19 -0
  87. package/lib/types/pkg.d.ts +14 -0
  88. package/lib/types/protocol.d.ts +124 -0
  89. package/lib/types/runtime/actions.d.ts +248 -0
  90. package/lib/types/runtime/keyframes.d.ts +43 -0
  91. package/lib/types/runtime/log-watch.d.ts +28 -0
  92. package/lib/types/runtime/node-source.d.ts +25 -0
  93. package/lib/types/runtime/ocr-model.d.ts +8 -0
  94. package/lib/types/runtime/pi-env.d.ts +24 -0
  95. package/lib/types/runtime/pretask.d.ts +53 -0
  96. package/lib/types/runtime/prune.d.ts +4 -0
  97. package/lib/types/runtime/receipts.d.ts +7 -0
  98. package/lib/types/runtime/reco-args.d.ts +18 -0
  99. package/lib/types/runtime/reco-verdict.d.ts +50 -0
  100. package/lib/types/runtime/roi-compare.d.ts +117 -0
  101. package/lib/types/runtime/session-forward.d.ts +10 -0
  102. package/lib/types/runtime/session-ipc.d.ts +117 -0
  103. package/lib/types/runtime/session.d.ts +77 -0
  104. package/lib/types/runtime/signal-guard.d.ts +19 -0
  105. package/lib/types/update-check.d.ts +20 -0
  106. package/lib/types/usage.d.ts +3 -0
  107. package/lib/update-check.js +97 -0
  108. package/lib/usage.js +50 -0
  109. package/package.json +94 -4
  110. package/skills/README.md +51 -0
  111. package/skills/maafw-live/SKILL.md +293 -0
  112. package/skills/maafw-live/references/examples.md +238 -0
  113. package/skills/maafw-live/references/pitfalls.md +211 -0
  114. package/skills/maafw-live/references/workflows.md +164 -0
@@ -0,0 +1,45 @@
1
+ /**
2
+ * OCR 模型是否在资源链里:只回答"链上有没有框架会去读的那几个文件"。
3
+ *
4
+ * 为什么需要它:OCR 的 miss 与模板匹配不同——**模型没加载时框架不报错**,只回一个空结果
5
+ * (`detail.all: []`、`hit:false`、约 200ms),与"roi 里真的没有字"完全同形(实测:一个不带
6
+ * `model/` 的资源目录跑 `--type OCR` 就是这个签名)。不做这条判断,`describeMisses` 会把
7
+ * "没加载 OCR 模型"报成"roi 内无文字"——正是本提案要清掉的那类误诊。
8
+ *
9
+ * 依据(MaaFramework 5.11.2 pinned source;本机运行时 5.14.2,相差一个小版本,
10
+ * 相关实现见 `Resource/ResourceMgr.cpp::load_bundle` 与 `Resource/OCRResMgr.cpp`
11
+ * 的 `lazy_load` / `load_deter` / `load_recer`):
12
+ * - `load_bundle` 对每个 bundle 调一次 `lazy_load(<bundle>/model/ocr)`,把该目录登记进
13
+ * roots 列表(后加载的在后),目录不存在就不登记;
14
+ * - `load_deter(name)` 遍历 roots,取第一个存在 `<root>/<name>/det.onnx` 的 root;
15
+ * `load_recer(name)` 遍历 roots,取第一个 `<root>/<name>/rec.onnx` 与
16
+ * `<root>/<name>/keys.txt` **同时**存在的 root;
17
+ * - `load_ocrer(name)` 要求 deter 与 recer 都拿到,所以判据是**合并**的:det 与 rec 允许
18
+ * 来自不同 root(base 放 det、overlay 放 rec+keys 是齐的),但 rec 与 keys 必须同属一个
19
+ * root——它们是 load_recer 同一次调用里的两项,不会被拆开拼;
20
+ * - `<name>` 来自节点参数 `model`,缺省空串 = 直接放在 `model/ocr/` 下;
21
+ * - 缺任一项 → 拿不到 ocrer,`OCRer::predict` 只是记日志并回空结果,**不抛错**。
22
+ *
23
+ * 因此这里不能把"某一段目录三件套齐全"当判据:那会把"det 在 base、rec+keys 在 overlay"
24
+ * 误报成 missing,而它其实完全可用——误报会把人引去补模型,真正该看的 roi 反而被略过。
25
+ */
26
+ import { existsSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ const DET = 'det.onnx';
29
+ /** recer 的两项必须同目录同时存在:对应 `load_recer` 同一次调用里的两个文件。 */
30
+ const RECER = ['rec.onnx', 'keys.txt'];
31
+ /**
32
+ * 资源链上是否能找到 OCR 模型。`modelName` 是节点/参数里声明的模型名(缺省空串)。
33
+ *
34
+ * 只查文件存在性、不解析 onnx:判断的是"框架会不会找到它",不是"模型能不能跑"。
35
+ * 按框架的合并规则判:det 与 rec 可以来自不同段,rec 与 keys 必须同段。
36
+ */
37
+ export function ocrModelStatus(resourceDirs, modelName) {
38
+ const name = typeof modelName === 'string' ? modelName.trim() : '';
39
+ // 每段一个候选目录,顺序与框架扫 roots 的顺序一致(这里不需要顺序,只在同段内成组)。
40
+ const folders = resourceDirs.map((dir) => name === '' ? join(dir, 'model', 'ocr') : join(dir, 'model', 'ocr', name));
41
+ if (!folders.some((folder) => existsSync(join(folder, DET))))
42
+ return 'missing';
43
+ return folders.some((folder) => RECER.every((f) => existsSync(join(folder, f)))) ? 'present' : 'missing';
44
+ }
45
+ //# sourceMappingURL=ocr-model.js.map
@@ -0,0 +1,34 @@
1
+ import { cliPackage, maaNodeVersion } from '../pkg.js';
2
+ /**
3
+ * 本工具**实际兑现**的 PI 能力版本。
4
+ *
5
+ * 不写"最新":这个值给 agent 判特性用,谎报会让它以为没实现的行为存在。
6
+ * 当前兑现到 v2.7.0:option 四级链 / preset / import / `attach_resource_path` /
7
+ * option 适用性过滤 / `resource.hash` 完整性校验(v2.6,warn-only)/ pretask(v2.7)。
8
+ */
9
+ const PI_INTERFACE_VERSION = 'v2.7.0';
10
+ /** 本 CLI 的人类输出固定中文、没有 UI 语言切换——按协议示例的 `zh_cn` 形态如实报。 */
11
+ const PI_CLIENT_LANGUAGE = 'zh_cn';
12
+ /** 生成要注入 agent 子进程的环境变量;返回空对象表示本次没有可注入的项。 */
13
+ export function buildAgentEnv(i) {
14
+ const env = {
15
+ PI_INTERFACE_VERSION,
16
+ PI_CLIENT_NAME: 'maafw-live',
17
+ PI_CLIENT_VERSION: cliPackage().version,
18
+ PI_CLIENT_LANGUAGE,
19
+ };
20
+ const maafw = maaNodeVersion();
21
+ if (maafw)
22
+ env.PI_CLIENT_MAAFW_VERSION = maafw;
23
+ /* 顶层 version 是**项目**版本(不是 interface_version=2) */
24
+ if (i.loaded.projectVersion)
25
+ env.PI_VERSION = i.loaded.projectVersion;
26
+ const c = i.controllerName ? i.loaded.controllers.find((x) => x.name === i.controllerName) : undefined;
27
+ if (c && c.raw !== undefined)
28
+ env.PI_CONTROLLER = JSON.stringify(c.raw);
29
+ const r = i.resourceName ? i.loaded.resources.find((x) => x.name === i.resourceName) : undefined;
30
+ if (r && r.raw !== undefined)
31
+ env.PI_RESOURCE = JSON.stringify(r.raw);
32
+ return env;
33
+ }
34
+ //# sourceMappingURL=pi-env.js.map
@@ -0,0 +1,217 @@
1
+ /**
2
+ * pretask:连接 Controller **之前**跑一次的子进程(PI v2.7.0+,过滤字段 v2.8.1)。
3
+ *
4
+ * 契约(MaaFW 5.14.2 `3.3-ProjectInterfaceV2协议`,pinned commit 8061d5bc):
5
+ * - 命令行 = `exec` + `args[]` + 可选末位 JSON(只有 `option` 非空时才追加);
6
+ * - 那个 JSON 是**单行紧凑**字符串,作为**单个 argv 元素**传递,值为 OptionValue
7
+ * (select/switch → `case.name`;checkbox → `case.name` 数组;input → `{字段: 值}`);
8
+ * - 按声明序串行、等它结束,CWD = interface.json 所在目录;
9
+ * - **非零退出即中止本次启动**——任务根本不开始。
10
+ *
11
+ * 协议没规定、由本工具定的三处(写在这里,免得下一个人重新猜):
12
+ * 1. 声明了 `controller` / `resource` 过滤且当前不适用时**跳过并说明**。协议只说 Client
13
+ * 可以隐藏/置灰,不保证跳过执行;但"启动模拟器"这类命令跑错控制器代价更大。
14
+ * 2. 末位 JSON **不用 preset**:pretask 发生在任务选择之前,而 preset 是按任务的快照。
15
+ * 这里只吃 CLI `--option` 与声明里的初始值(`default_case` / `inputs[].default`)。
16
+ * 3. 超时协议没规定,本工具定 120s(`MAAFW_PRETASK_TIMEOUT_MS` 覆盖;<=0 关超时):
17
+ * "启动模拟器"挂成死等时不能靠调用方 Ctrl-C 收摊(设计审查 P0-2)。超时杀的是
18
+ * **进程树**(Windows taskkill /T)——pretask 拉起模拟器的链式脚本只杀头部会留孙代。
19
+ */
20
+ import { spawn, spawnSync } from 'node:child_process';
21
+ import { resolveOptionValue } from '../interface/override.js';
22
+ /** 子进程输出只留尾部:够归因,不会把大日志塞进上下文。 */
23
+ const TAIL_LIMIT = 4000;
24
+ /**
25
+ * 拼一条 pretask 的完整 argv(含末位 JSON)。**纯函数**:执行与参数构造分开,
26
+ * 参数形状可以离线单测——这部分错了就是"脚本收到了奇怪的参数但没人报错"。
27
+ */
28
+ export function buildPretaskArgv(p, loaded, ctx) {
29
+ const warns = [];
30
+ const json = {};
31
+ for (const key of p.option) {
32
+ const o = loaded.options[key];
33
+ if (!o) {
34
+ warns.push('pretask ' + (p.name ?? p.exec) + ' 引用了未定义的 option ' + key + ',未写入末位 JSON');
35
+ continue;
36
+ }
37
+ /* 适用性过滤:不满足当前 controller / resource 的 option 不进这个对象 */
38
+ if (o.controller?.length && ctx.controllerName && !o.controller.includes(ctx.controllerName))
39
+ continue;
40
+ if (o.resource?.length && ctx.resourceName && !o.resource.includes(ctx.resourceName))
41
+ continue;
42
+ const v = resolveOptionValue(o, ctx.valueCtx);
43
+ if (!v)
44
+ continue;
45
+ if (v.kind === 'fields') {
46
+ json[key] = v.fields;
47
+ continue;
48
+ }
49
+ /* case 型取值要校验:写一个不存在的 case 名进 JSON,脚本会按错的配置跑而不报错 */
50
+ const known = o.type === 'checkbox' ? v.cases : [v.cases[0]];
51
+ const bad = known.filter((c) => !o.cases.some((x) => x.name === c));
52
+ if (bad.length) {
53
+ warns.push('pretask ' + (p.name ?? p.exec) + ' 的 option ' + key + ' 取值 ' + bad.join(' / ') + ' 不是已声明的 case,未写入末位 JSON');
54
+ continue;
55
+ }
56
+ json[key] = o.type === 'checkbox' ? v.cases : v.cases[0];
57
+ }
58
+ const argv = [p.exec, ...p.args, ...(Object.keys(json).length ? [JSON.stringify(json)] : [])];
59
+ return { argv, warns };
60
+ }
61
+ /** pretask 超时缺省 120s:够"启动模拟器到可连"的常态,又兜住真正挂死的脚本。 */
62
+ export const PRETASK_TIMEOUT_MS = 120000;
63
+ function pretaskTimeoutMs() {
64
+ const raw = process.env.MAAFW_PRETASK_TIMEOUT_MS;
65
+ if (raw === undefined)
66
+ return PRETASK_TIMEOUT_MS;
67
+ const n = Number(raw);
68
+ return Number.isFinite(n) ? n : PRETASK_TIMEOUT_MS;
69
+ }
70
+ /** 进程树杀:pretask 常是"拉起模拟器"的壳脚本,只杀头部会留孙代占设备。 */
71
+ function killTree(child) {
72
+ const pid = child.pid;
73
+ if (pid === undefined) {
74
+ try {
75
+ child.kill();
76
+ }
77
+ catch { /* ignore */ }
78
+ return;
79
+ }
80
+ if (process.platform === 'win32') {
81
+ try {
82
+ spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { timeout: 5000 });
83
+ }
84
+ catch { /* ignore */ }
85
+ return;
86
+ }
87
+ try {
88
+ process.kill(-pid, 'SIGKILL');
89
+ }
90
+ catch {
91
+ try {
92
+ child.kill('SIGKILL');
93
+ }
94
+ catch { /* ignore */ }
95
+ }
96
+ }
97
+ /**
98
+ * 执行一条 pretask;timeoutMs<=0 不限时。
99
+ * Windows 的 .cmd/.bat 必须过 cmd.exe(CreateProcess 只认 exe;不带 shell 的 spawn 同步抛
100
+ * EINVAL,原实现却提示"检查 PATH"——把用户往完全错的方向带)。args 逐元素传给 cmd.exe:
101
+ * Node 会自己做 cmd 元字符转义,不经 shell:true(那个走全串拼接,空格参数必坏)。
102
+ */
103
+ function spawnOnce(exe, args, cwd, timeoutMs) {
104
+ return new Promise((resolve) => {
105
+ let tail = '';
106
+ let settled = false;
107
+ const done = (r) => {
108
+ if (!settled) {
109
+ settled = true;
110
+ resolve(r);
111
+ }
112
+ };
113
+ const isBatch = process.platform === 'win32' && /\.(cmd|bat)$/i.test(exe);
114
+ const realExe = isBatch ? 'cmd.exe' : exe;
115
+ const realArgs = isBatch ? ['/d', '/s', '/c', exe, ...args] : args;
116
+ let child;
117
+ try {
118
+ child = spawn(realExe, realArgs, {
119
+ cwd: cwd ?? undefined, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
120
+ /* POSIX 单独成组,超时才能 -pid 收整棵树;Windows 不靠 detached(它另有语义),taskkill /T 收。 */
121
+ ...(isBatch || process.platform === 'win32' ? {} : { detached: true }),
122
+ });
123
+ }
124
+ catch (e) {
125
+ done({ code: null, signal: null, tail: '', error: String(e.message || e) });
126
+ return;
127
+ }
128
+ const feed = (b) => { tail = (tail + String(b)).slice(-TAIL_LIMIT); };
129
+ child.stdout?.on('data', feed);
130
+ child.stderr?.on('data', feed);
131
+ child.on('error', (e) => done({ code: null, signal: null, tail, error: e.message }));
132
+ child.on('exit', (code, signal) => done({ code, signal, tail }));
133
+ if (timeoutMs > 0) {
134
+ const timer = setTimeout(() => {
135
+ killTree(child);
136
+ done({ code: null, signal: null, tail, timedOut: true });
137
+ }, timeoutMs);
138
+ timer.unref?.();
139
+ }
140
+ });
141
+ }
142
+ /**
143
+ * 按声明序执行全部适用的 pretask。任一条失败即返回 ok:false(调用方据此中止连接)。
144
+ * 不抛异常:失败是一种结果,由调用方决定怎么呈现。
145
+ */
146
+ export async function runPretasks(input) {
147
+ const { loaded } = input;
148
+ const ran = [];
149
+ const skipped = [];
150
+ const notes = [];
151
+ const warns = [];
152
+ const valueCtx = {
153
+ ...(input.optionCases ? { optionCases: input.optionCases } : {}),
154
+ ...(input.optionFields ? { optionFields: input.optionFields } : {}),
155
+ };
156
+ for (const p of loaded.pretask ?? []) {
157
+ const label = p.name ?? p.exec;
158
+ if (p.controller?.length && input.controllerName && !p.controller.includes(input.controllerName)) {
159
+ skipped.push(label + '(声明只适用于控制器 ' + p.controller.join(' / ') + ')');
160
+ continue;
161
+ }
162
+ if (p.resource?.length && input.resourceName && !p.resource.includes(input.resourceName)) {
163
+ skipped.push(label + '(声明只适用于资源 ' + p.resource.join(' / ') + ')');
164
+ continue;
165
+ }
166
+ const built = buildPretaskArgv(p, loaded, {
167
+ controllerName: input.controllerName,
168
+ resourceName: input.resourceName,
169
+ valueCtx,
170
+ });
171
+ warns.push(...built.warns);
172
+ const [exe, ...args] = built.argv;
173
+ const started = Date.now();
174
+ const r = await spawnOnce(exe, args, loaded.dir, input.timeoutMs ?? pretaskTimeoutMs());
175
+ const outcome = {
176
+ name: label, exec: exe, argv: built.argv, code: r.code, signal: r.signal, ms: Date.now() - started, tail: r.tail,
177
+ };
178
+ ran.push(outcome);
179
+ if (r.error) {
180
+ return {
181
+ ok: false, ran, skipped, notes, warns,
182
+ error: {
183
+ code: 'PRETASK_SPAWN',
184
+ message: 'pretask ' + label + ' 启动失败:' + r.error,
185
+ hint: '检查 exec 是否在 PATH 里(协议的 exec 不走 shell,不能写管道/重定向)',
186
+ },
187
+ };
188
+ }
189
+ if (r.timedOut) {
190
+ return {
191
+ ok: false, ran, skipped, notes, warns,
192
+ error: {
193
+ code: 'PRETASK_TIMEOUT',
194
+ message: 'pretask ' + label + ' 超时(' + (input.timeoutMs ?? pretaskTimeoutMs()) + 'ms)已树杀,本次启动中止',
195
+ hint: (r.tail ? '子进程输出尾部:\n' + r.tail + '\n' : '') +
196
+ '启动模拟器类脚本确实需要更久时,用 MAAFW_PRETASK_TIMEOUT_MS 放宽(毫秒),不是去掉超时',
197
+ },
198
+ };
199
+ }
200
+ if (r.code !== 0) {
201
+ return {
202
+ ok: false, ran, skipped, notes, warns,
203
+ error: {
204
+ code: 'PRETASK_FAILED',
205
+ message: 'pretask ' + label + ' 以 ' + (r.signal ? '信号 ' + r.signal : '退出码 ' + r.code) +
206
+ ' 结束,本次启动中止(PI 契约:非零退出即中止)',
207
+ ...(r.tail ? { hint: '子进程输出尾部:\n' + r.tail } : {}),
208
+ },
209
+ };
210
+ }
211
+ notes.push('pretask 已执行:' + label + '(' + outcome.ms + 'ms)');
212
+ }
213
+ if (skipped.length)
214
+ notes.push('跳过 ' + skipped.length + ' 条 pretask:' + skipped.join(';'));
215
+ return { ok: true, ran, skipped, notes, warns };
216
+ }
217
+ //# sourceMappingURL=pretask.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 落盘目录的保留上限(runs/、png/ 共用):按 mtime 留最新 keep 个,删其余。
3
+ * 背景:~/.maafw-live 下这些目录以前没有上限——回执与截图只进不出,
4
+ * 用久了会无声膨胀(实测 runs/ 44 个、frames 15MB)。修剪失败不抛:
5
+ * 落盘/清理从来不是任务结论,最坏是目录多留几个。
6
+ *
7
+ * 只删 suffix 匹配的文件,不碰目录与其他扩展名;目录本身不存在时直接返回。
8
+ * daemon 侧(framed.mjs)的 out/ 有同逻辑的内联副本——它自包含、不能 import lib。
9
+ */
10
+ import { readdirSync, statSync, unlinkSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ export function pruneDir(dir, opts) {
13
+ try {
14
+ const suffix = opts.suffix ?? '';
15
+ const names = readdirSync(dir)
16
+ .filter((n) => n.endsWith(suffix))
17
+ .map((n) => ({ n, t: statSync(join(dir, n)).mtimeMs }))
18
+ .sort((a, b) => b.t - a.t); /* 新的在前 */
19
+ for (const f of names.slice(Math.max(0, opts.keep))) {
20
+ try {
21
+ unlinkSync(join(dir, f.n));
22
+ }
23
+ catch { /* 单个删不掉就留 */ }
24
+ }
25
+ }
26
+ catch { /* 目录不存在/读不了:按没有要清的看 */ }
27
+ }
28
+ //# sourceMappingURL=prune.js.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * run 回执落盘(repl 与一次性 CLI 共用):W1 runs 存储的种子。
3
+ *
4
+ * 目录默认 `~/.maafw-live/runs/`,可用 `MAAFW_LIVE_RUNS_DIR` 覆盖——
5
+ * 单测必须指向临时目录,不许往真实 home 写桩数据(2026-10-26 实测污染过一轮)。
6
+ * 落盘失败不属于任务结论:返回体没有 path 时,调用方自行退化(如打印完整 JSON)。
7
+ *
8
+ * 文件名带完整毫秒时间戳,且**独占创建**(flag 'wx'):回执是"当时只能观测一次"的证据,
9
+ * 被后来的同名写入静默覆盖就等于丢证据——所以撞名不覆盖,改为加序号重试。
10
+ */
11
+ import { mkdirSync, writeFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { pruneDir } from './prune.js';
15
+ /** 保留上限:回执是"一次性观测"的证据,留够回看的就够,不让它无声膨胀。 */
16
+ const RUNS_KEEP = 50;
17
+ export function runsDir() {
18
+ const override = process.env.MAAFW_LIVE_RUNS_DIR;
19
+ return override && override !== '' ? override : join(homedir(), '.maafw-live', 'runs');
20
+ }
21
+ /** 回执文件名:`run-<ISO 时间戳去分隔符>-<entry>.json`(时间戳保留到毫秒,含 Z 时区标记)。
22
+ * `now` 可注入:单测据此制造"同一毫秒撞名",不必靠 sleep。 */
23
+ export function receiptFileName(entry, now = new Date()) {
24
+ const stamp = now.toISOString().replace(/[-:.]/g, '');
25
+ const safeEntry = entry.replace(/[\\/:*?"<>|\s]+/g, '_');
26
+ return 'run-' + stamp + '-' + safeEntry + '.json';
27
+ }
28
+ /** 同一毫秒内最多重试到这个序号(撞名说明并发落盘,实际不会逼近这个量级) */
29
+ const MAX_SAME_NAME = 100;
30
+ export function writeRunReceipt(record, entry, now = new Date()) {
31
+ try {
32
+ const dir = runsDir();
33
+ mkdirSync(dir, { recursive: true });
34
+ const base = receiptFileName(entry, now);
35
+ const body = JSON.stringify(record, null, 2) + '\n';
36
+ for (let n = 0; n < MAX_SAME_NAME; n++) {
37
+ const name = n === 0 ? base : base.replace(/\.json$/, '-' + n + '.json');
38
+ const path = join(dir, name);
39
+ try {
40
+ writeFileSync(path, body, { flag: 'wx' });
41
+ pruneDir(dir, { keep: RUNS_KEEP, suffix: '.json' });
42
+ return { path };
43
+ }
44
+ catch (e) {
45
+ /* 撞名是"已有同名回执",换序号重试;别的 IO 错误按落盘失败处理 */
46
+ if (e.code !== 'EEXIST')
47
+ throw e;
48
+ }
49
+ }
50
+ return {};
51
+ }
52
+ catch {
53
+ /* 落盘失败:调用方按"没有 path"退化,不吞任务结论 */
54
+ return {};
55
+ }
56
+ }
57
+ //# sourceMappingURL=receipts.js.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * reco 参数面的共享判据:**roi 空间**校验。
3
+ *
4
+ * 为什么单独立一个文件:repl 与一次性 CLI 都要能给「roi 越出所用图像尺寸」下一个明确结论,
5
+ * 判据必须只有一份 —— 两处各写一遍,迟早分叉成「CLI 报错、repl 静默」,而静默的正是最贵的那种
6
+ * (G16:ctrl 坐标的 roi 打在 L1 小图上 → `results: []`,看起来像"模板没读到")。
7
+ */
8
+ /**
9
+ * roi(控制器/图像空间)是否越出图像尺寸。越界就是**参数用错空间**,不是"没识别到":
10
+ * 边界相等不算越界(`x+w === w` 是贴右边)。
11
+ *
12
+ * **左上边界同样要判**:负坐标是最典型的"空间算错"(L1↔ctrl 换算时先减了偏移),
13
+ * 而它只比右下是看不出来的(`[-50,200,80,300]` 的右下 = 30/500,落在画面里)。
14
+ * 漏判的代价正是本判据要防的那件事:回落到"静默未中 + 指向模板/资源"。
15
+ *
16
+ * 返回 null 表示在界内或无法判定(缺尺寸)。
17
+ */
18
+ export function roiOutOfBounds(roi, w, h) {
19
+ if (!roi || roi.length !== 4)
20
+ return null;
21
+ const iw = Number(w);
22
+ const ih = Number(h);
23
+ if (!Number.isFinite(iw) || !Number.isFinite(ih) || iw <= 0 || ih <= 0)
24
+ return null;
25
+ const [x, y, rw, rh] = roi;
26
+ const right = x + rw;
27
+ const bottom = y + rh;
28
+ if (x >= 0 && y >= 0 && right <= iw && bottom <= ih)
29
+ return null;
30
+ const where = (x < 0 || y < 0)
31
+ ? '(左上 ' + x + ',' + y + ' 为负' + (right > iw || bottom > ih ? ',且右 ' + right + ' / 下 ' + bottom + ' 超出' : '') + ')'
32
+ : '(右 ' + right + ' / 下 ' + bottom + ')';
33
+ return 'roi 越出图像:roi=' + roi.join(',') + where
34
+ + ' 超出图像尺寸 ' + iw + 'x' + ih
35
+ + ' —— 这是**参数用错空间**(roi 按图像像素给;负值多半是换算时先减了偏移),不是识别未命中;'
36
+ + '控制器的 roi 要按所用图的尺寸换算(L1 小图 vs 控制器原图差一个 scale)';
37
+ }
38
+ //# sourceMappingURL=reco-args.js.map
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 识别未命中的原因与计数:**只有这一份判据**,一次性 CLI 与 REPL 都读它。
3
+ *
4
+ * 为什么单独立文件:miss 的病因直接决定下一步查什么(roi 里没有字 / expected 过滤掉了 /
5
+ * 分数不够 / 模板根本读不到),两个壳各推一遍必然分叉,而分叉的代价是把"expected 不匹配"
6
+ * 当成"模板读不到"去翻资源目录。
7
+ *
8
+ * 名字按**算法**分开给,不共用一套 OCR 口径:OCR 没有阈值这回事,模板匹配也没有 `expected`
9
+ * 这回事——共用口径就会编出不存在的病因。表以外的算法不猜,如实不给原因。
10
+ *
11
+ * OCR 的空结果还有一个同形病因:**模型没加载时框架不报错,只回空结果**。这条不在本文件的
12
+ * 职责内(要查文件系统),由调用方用 `runtime/ocr-model` 判好、以 `options.ocrModel` 传进来。
13
+ *
14
+ * 派生的前提是**先看 hit**:`noConfidence` 旧式判据(`threshold 建议为 null`)在 OCR 命中时
15
+ * 也成立(OCR 无数值分),不看 `ok` 就会把命中标成"无置信度"。
16
+ */
17
+ function innerDetail(detail) {
18
+ const outer = detail;
19
+ const inner = outer && typeof outer === 'object' ? outer.detail : undefined;
20
+ return inner && typeof inner === 'object' ? inner : null;
21
+ }
22
+ function countOf(v) {
23
+ return Array.isArray(v) ? v.length : null;
24
+ }
25
+ function reasonFor(hit, type, rawCount, scores, ocrModel, failure) {
26
+ if (hit)
27
+ return null;
28
+ /* 调用本身报错(detail 为 null)优先于一切算法口径:这不是"未命中",别按未命中解释 */
29
+ if (failure !== null)
30
+ return '识别调用报错:' + failure;
31
+ if (type === 'OCR') {
32
+ if (rawCount === 0) {
33
+ /* 模型没加载时框架不报错,只回空结果——与"roi 里没有字"同形,先排前者 */
34
+ if (ocrModel === 'missing')
35
+ return 'OCR 模型不在资源链里(model/ocr 需 det.onnx + rec.onnx + keys.txt)';
36
+ return 'roi 内无文字';
37
+ }
38
+ if (rawCount !== null && rawCount > 0)
39
+ return 'expected 无匹配';
40
+ return null;
41
+ }
42
+ if (type === 'TemplateMatch') {
43
+ if (scores === null)
44
+ return '识别未返回任何置信度(模板/资源读不到)';
45
+ if (scores.lowMatch)
46
+ return '模板与画面不匹配(最高置信度 ' + scores.bestScore + ')';
47
+ return '低于阈值(最高置信度 ' + scores.bestScore + ')';
48
+ }
49
+ return null;
50
+ }
51
+ export function describeMisses(results, type, scores, options = {}) {
52
+ const perResult = results.map((r) => {
53
+ const inner = innerDetail(r.detail);
54
+ const rawCount = countOf(inner?.all);
55
+ const filteredCount = countOf(inner?.filtered);
56
+ /* detail 缺失 = 子进程捕获到异常(正常 miss 时框架仍回 detail 对象)。异常文本直接给人看,
57
+ * 但截断:它是框架的原生消息,可能很长。 */
58
+ const hasDetail = r.detail !== null && typeof r.detail === 'object';
59
+ const errText = typeof r.error === 'string' && r.error.trim() !== '' ? r.error.trim() : null;
60
+ const failure = hasDetail ? null : (errText ?? '未返回 detail(调用失败)');
61
+ return {
62
+ missReason: reasonFor(r.ok === true, type, rawCount, scores, options.ocrModel, failure && failure.length > 160 ? failure.slice(0, 160) + ' …' : failure),
63
+ rawCount,
64
+ filteredCount,
65
+ failed: failure !== null && r.ok !== true,
66
+ };
67
+ });
68
+ const reasons = [];
69
+ for (const p of perResult) {
70
+ if (p.missReason && !reasons.includes(p.missReason))
71
+ reasons.push(p.missReason);
72
+ }
73
+ const single = results.length === 1 ? perResult[0] : undefined;
74
+ return {
75
+ missReason: reasons.length === 1 ? reasons[0] : null,
76
+ missReasons: reasons,
77
+ rawCount: single ? single.rawCount : null,
78
+ filteredCount: single ? single.filteredCount : null,
79
+ perResult,
80
+ };
81
+ }
82
+ //# sourceMappingURL=reco-verdict.js.map