@stalfh233/omc-cli 0.4.98 → 0.4.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/call.js +8 -3
- package/dist/capability-overview.js +12 -0
- package/dist/cli.js +238 -12
- package/dist/closest-match.js +34 -0
- package/dist/compound-writes.js +310 -0
- package/dist/discovery.js +223 -9
- package/dist/field-families.js +27 -0
- package/dist/goal-routes.js +127 -57
- package/dist/lanes/report.js +21 -5
- package/dist/lanes/runtime-data-contract.js +1 -1
- package/dist/lanes/runtime-data.js +30 -0
- package/dist/recipes/field.js +1 -1
- package/dist/recipes/runtime-data.js +3 -1
- package/dist/refusal-guidance.js +207 -0
- package/dist/skills.js +89 -21
- package/dist/tools.js +35 -0
- package/dist/usage-log.js +21 -2
- package/dist/write.js +2 -2
- package/package.json +3 -3
- package/skills/omc-business-configuration.md +1 -1
- package/skills/omc-start-here.md +2 -1
- package/skills/omc-write-hazards.md +17 -9
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* r19 tickets 11–13 — 复合写命令。
|
|
3
|
+
*
|
|
4
|
+
* 16 轮账本里三对相邻调用(提交→读回 157 次 / 保存规则→启用 120 次 / 更新流
|
|
5
|
+
* 程模板→发布 93 次):第二步是忘得最快、忘了就静默不生效的地方。三条命令把
|
|
6
|
+
* 各自的两步压成一次调用,两阶段各自回报。
|
|
7
|
+
*
|
|
8
|
+
* 共同纪律(与 omc call 的边界:这里封装的是**业务意图与顺序**,不是批量处理
|
|
9
|
+
* ——批处理仍走 call --args-list / omc script):
|
|
10
|
+
* - 默认 dry-run,显式 --apply 才写;
|
|
11
|
+
* - 第一步失败立即停:提交绝不自动重试(写不可重放)、保存失败绝不启用半成
|
|
12
|
+
* 品、更新失败绝不发布;
|
|
13
|
+
* - 阶段性结果如实回报:启用失败报「已保存但未启用」,发布失败保留草稿。
|
|
14
|
+
*
|
|
15
|
+
* 测试注入:invoke(默认走 runToolCall)与 emit(默认写 CLI 信封),进程内测
|
|
16
|
+
* 试用 harness.call 直连,不需要起服务器。
|
|
17
|
+
*/
|
|
18
|
+
import { runToolCall } from "./call.js";
|
|
19
|
+
function defaultInvoke(environment) {
|
|
20
|
+
return async (tool, args) => {
|
|
21
|
+
const { result } = await runToolCall(tool, {
|
|
22
|
+
args: { ...args, ...(environment !== undefined && args.environment === undefined ? { environment } : {}) },
|
|
23
|
+
...(environment !== undefined ? { environment } : {}),
|
|
24
|
+
print: () => undefined,
|
|
25
|
+
});
|
|
26
|
+
return result;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function isFailure(result) {
|
|
30
|
+
const status = String(result.status ?? "ok");
|
|
31
|
+
return status !== "ok" && status !== "dry-run";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 提交差异归一:平台读回可能把 {id} 展开成对象、数字存成字符串。比较双方先
|
|
35
|
+
* 解包到可比形式,只有真正不同才进 diff(假阴性比噪音 diff 危险,假阳性
|
|
36
|
+
* diff 只是浪费注意力)。
|
|
37
|
+
*/
|
|
38
|
+
function normalizeForDiff(value) {
|
|
39
|
+
if (value === null || value === undefined)
|
|
40
|
+
return "";
|
|
41
|
+
if (Array.isArray(value))
|
|
42
|
+
return value.map((entry) => normalizeForDiff(entry));
|
|
43
|
+
if (typeof value === "object") {
|
|
44
|
+
const record = value;
|
|
45
|
+
if (typeof record.id === "string" && Object.keys(record).length <= 3)
|
|
46
|
+
return record.id;
|
|
47
|
+
return Object.fromEntries(Object.entries(record).map(([key, entry]) => [key, normalizeForDiff(entry)]));
|
|
48
|
+
}
|
|
49
|
+
return typeof value === "string" ? value.trim() : value;
|
|
50
|
+
}
|
|
51
|
+
function equalAfterNormalize(sent, got) {
|
|
52
|
+
const left = normalizeForDiff(sent);
|
|
53
|
+
const right = normalizeForDiff(got);
|
|
54
|
+
if (left === right)
|
|
55
|
+
return true;
|
|
56
|
+
try {
|
|
57
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function diffSubmittedAgainstLoaded(sent, loaded) {
|
|
64
|
+
const diff = {};
|
|
65
|
+
if (!loaded || typeof loaded !== "object")
|
|
66
|
+
return diff;
|
|
67
|
+
for (const [key, value] of Object.entries(sent)) {
|
|
68
|
+
const got = loaded[key];
|
|
69
|
+
if (!equalAfterNormalize(value, got))
|
|
70
|
+
diff[key] = { sent: value, got: got ?? null };
|
|
71
|
+
}
|
|
72
|
+
return diff;
|
|
73
|
+
}
|
|
74
|
+
function bizObjectIdOf(submitResult) {
|
|
75
|
+
const readback = (submitResult.readback ?? {});
|
|
76
|
+
const submittedData = (readback.submittedData ?? {});
|
|
77
|
+
const performed = (submitResult.performed ?? {});
|
|
78
|
+
for (const candidate of [readback.objectId, readback.bizObjectId, submittedData.bizObjectId, submittedData.id, performed.bizObjectId]) {
|
|
79
|
+
if (typeof candidate === "string" && candidate.length > 0)
|
|
80
|
+
return candidate;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
export async function runSubmitVerify(options) {
|
|
85
|
+
const invoke = options.invoke ?? defaultInvoke(options.environment);
|
|
86
|
+
const emit = options.emit ?? (() => undefined);
|
|
87
|
+
if (!options.schema) {
|
|
88
|
+
emit({ status: "error", blocker: "invalid-input", message: "--schema 必填" }, "error");
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
if (options.rows.length === 0) {
|
|
92
|
+
emit({ status: "error", blocker: "invalid-input", message: "--rows 至少一条(JSON 数组或单对象)" }, "error");
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
if (!options.apply) {
|
|
96
|
+
emit({
|
|
97
|
+
status: "ok",
|
|
98
|
+
dryRun: true,
|
|
99
|
+
schemaCode: options.schema,
|
|
100
|
+
plannedRows: options.rows.length,
|
|
101
|
+
rowKeys: options.rows.map((row) => Object.keys(row)),
|
|
102
|
+
note: "dry-run 仅预览;--apply 逐条 data.submit → 用真实 id data.load → 回报差异。提交失败立即停(不重试、不降级);差异是发现不是失败。批处理(不需要读回)用 omc call data.submit --args-list。",
|
|
103
|
+
}, "ok");
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
const rows = [];
|
|
107
|
+
let verified = 0;
|
|
108
|
+
let mismatched = 0;
|
|
109
|
+
for (let index = 0; index < options.rows.length; index += 1) {
|
|
110
|
+
const formData = options.rows[index];
|
|
111
|
+
// 提交不可重放:一次失败立即停——重试同参数在写路径上是危险的乐观。
|
|
112
|
+
const submit = await invoke("data.submit", { schemaCode: options.schema, formData });
|
|
113
|
+
if (isFailure(submit)) {
|
|
114
|
+
emit({
|
|
115
|
+
status: submit.status,
|
|
116
|
+
schemaCode: options.schema,
|
|
117
|
+
stoppedAtIndex: index,
|
|
118
|
+
rows,
|
|
119
|
+
retried: false,
|
|
120
|
+
failure: { index, tool: "data.submit", result: submit },
|
|
121
|
+
message: `第 ${index + 1} 条提交被拒/失败——立即停止,未重试、未降级;前面 ${index} 条已提交的各自见 rows`,
|
|
122
|
+
}, String(submit.status));
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
const bizObjectId = bizObjectIdOf(submit);
|
|
126
|
+
if (bizObjectId === undefined) {
|
|
127
|
+
emit({
|
|
128
|
+
status: "error",
|
|
129
|
+
schemaCode: options.schema,
|
|
130
|
+
stoppedAtIndex: index,
|
|
131
|
+
rows,
|
|
132
|
+
retried: false,
|
|
133
|
+
failure: { index, tool: "data.submit", result: submit, reason: "提交成功但读回没给 bizObjectId——无法读回验证" },
|
|
134
|
+
message: "提交成功但回执缺少 bizObjectId,无法读回(不猜 id)",
|
|
135
|
+
}, "error");
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
const loaded = await invoke("data.load", { schemaCode: options.schema, bizObjectId });
|
|
139
|
+
const loadData = (loaded.data ?? loaded.content ?? null);
|
|
140
|
+
const diff = diffSubmittedAgainstLoaded(formData, loadData);
|
|
141
|
+
if (Object.keys(diff).length === 0)
|
|
142
|
+
verified += 1;
|
|
143
|
+
else
|
|
144
|
+
mismatched += 1;
|
|
145
|
+
rows.push({
|
|
146
|
+
index,
|
|
147
|
+
status: "ok",
|
|
148
|
+
bizObjectId,
|
|
149
|
+
verified: Object.keys(diff).length === 0,
|
|
150
|
+
diff,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
emit({
|
|
154
|
+
status: "ok",
|
|
155
|
+
schemaCode: options.schema,
|
|
156
|
+
rows,
|
|
157
|
+
summary: { submitted: rows.length, verified, mismatched },
|
|
158
|
+
note: mismatched > 0 ? "有字段读回与提交不一致(见各行 diff)——差异是发现不是失败;常见原因:日期/关联字段平台侧归一化,先核对是否实质差异" : undefined,
|
|
159
|
+
}, "ok");
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
export async function runRuleSaveEnable(options) {
|
|
163
|
+
const invoke = options.invoke ?? defaultInvoke(options.environment);
|
|
164
|
+
const emit = options.emit ?? (() => undefined);
|
|
165
|
+
if (!options.schema || !options.ruleCode || !options.name || !options.nodeGraph) {
|
|
166
|
+
emit({ status: "error", blocker: "invalid-input", message: "--schema/--rule-code/--name/--node-graph 全部必填" }, "error");
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
if (!options.apply) {
|
|
170
|
+
emit({
|
|
171
|
+
status: "ok",
|
|
172
|
+
dryRun: true,
|
|
173
|
+
schemaCode: options.schema,
|
|
174
|
+
ruleCode: options.ruleCode,
|
|
175
|
+
phases: ["rule.save(upsert)", "rule.enable"],
|
|
176
|
+
note: "dry-run 仅预览;--apply 先保存、保存成功才启用。规则保存后默认禁用——不启用就不会运行(16 轮账本里这步相邻遗漏 120 次)。",
|
|
177
|
+
}, "ok");
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
const save = await invoke("rule.save", {
|
|
181
|
+
schemaCode: options.schema,
|
|
182
|
+
ruleCode: options.ruleCode,
|
|
183
|
+
name: options.name,
|
|
184
|
+
nodeGraph: options.nodeGraph,
|
|
185
|
+
...(options.publish === true ? { publish: true } : {}),
|
|
186
|
+
});
|
|
187
|
+
if (isFailure(save)) {
|
|
188
|
+
emit({
|
|
189
|
+
status: save.status,
|
|
190
|
+
phase: "save",
|
|
191
|
+
willRun: false,
|
|
192
|
+
saved: save,
|
|
193
|
+
message: "保存失败——规则未落库,绝不启用半成品;修正 nodeGraph 后重跑",
|
|
194
|
+
}, String(save.status));
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
const enable = await invoke("rule.enable", { schemaCode: options.schema, ruleCode: options.ruleCode });
|
|
198
|
+
if (isFailure(enable)) {
|
|
199
|
+
emit({
|
|
200
|
+
status: enable.status,
|
|
201
|
+
phase: "enable",
|
|
202
|
+
willRun: false,
|
|
203
|
+
saved: save,
|
|
204
|
+
enabled: enable,
|
|
205
|
+
message: "已保存但未启用——规则现在不会运行;排查启用失败原因后单跑 omc call rule.enable(草稿已保留,不需要重保存)",
|
|
206
|
+
}, String(enable.status));
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
emit({
|
|
210
|
+
status: "ok",
|
|
211
|
+
schemaCode: options.schema,
|
|
212
|
+
ruleCode: options.ruleCode,
|
|
213
|
+
saved: save,
|
|
214
|
+
enabled: enable,
|
|
215
|
+
willRun: true,
|
|
216
|
+
answer: "会运行:规则已保存且已启用",
|
|
217
|
+
}, "ok");
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* 合并语义显式化:先读当前草稿,算出新模板没覆盖的旧节点。
|
|
222
|
+
* - 默认(无 --confirm-replace):旧节点**保留**——追加进最终模板(插在 END
|
|
223
|
+
* 节点之前),回执 preservedNodes 列明;
|
|
224
|
+
* - --confirm-replace:按调用者模板原样发送,旧节点被丢弃,回执
|
|
225
|
+
* droppedNodes 列明。
|
|
226
|
+
* 两种路径都不依赖平台的合并/整替行为:最终模板由本命令显式合成。
|
|
227
|
+
*/
|
|
228
|
+
function composeFinalTemplate(draftActivities, template, confirmReplace) {
|
|
229
|
+
const newCodes = new Set((template.activities ?? []).map((activity) => String(activity.activityCode ?? "")));
|
|
230
|
+
const oldNodes = draftActivities.filter((activity) => {
|
|
231
|
+
const code = String(activity.activityCode ?? "");
|
|
232
|
+
return code !== "" && !newCodes.has(code);
|
|
233
|
+
});
|
|
234
|
+
const oldCodes = oldNodes.map((activity) => String(activity.activityCode ?? ""));
|
|
235
|
+
if (confirmReplace || oldCodes.length === 0) {
|
|
236
|
+
return { finalTemplate: template, preservedNodes: [], droppedNodes: confirmReplace ? oldCodes : [] };
|
|
237
|
+
}
|
|
238
|
+
const activities = template.activities ?? [];
|
|
239
|
+
const endIndex = activities.findIndex((activity) => String(activity.activityType ?? "").toUpperCase() === "END");
|
|
240
|
+
const merged = endIndex >= 0
|
|
241
|
+
? [...activities.slice(0, endIndex), ...oldNodes, ...activities.slice(endIndex)]
|
|
242
|
+
: [...activities, ...oldNodes];
|
|
243
|
+
return { finalTemplate: { ...template, activities: merged }, preservedNodes: oldCodes, droppedNodes: [] };
|
|
244
|
+
}
|
|
245
|
+
export async function runWorkflowUpdatePublish(options) {
|
|
246
|
+
const invoke = options.invoke ?? defaultInvoke(options.environment);
|
|
247
|
+
const emit = options.emit ?? (() => undefined);
|
|
248
|
+
if (!options.schema || !options.code || !options.template) {
|
|
249
|
+
emit({ status: "error", blocker: "invalid-input", message: "--schema/--code/--template 必填" }, "error");
|
|
250
|
+
return 1;
|
|
251
|
+
}
|
|
252
|
+
const draft = options.apply ? await invoke("workflow.get_draft", { schemaCode: options.schema, ...(options.code ? { code: options.code } : {}) }) : { status: "ok", data: null };
|
|
253
|
+
const draftData = (draft.data ?? null);
|
|
254
|
+
const draftActivities = (draftData?.activities ?? draftData?.template?.activities ?? []);
|
|
255
|
+
const { finalTemplate, preservedNodes, droppedNodes } = composeFinalTemplate(draftActivities, options.template, options.confirmReplace === true);
|
|
256
|
+
if (!options.apply) {
|
|
257
|
+
emit({
|
|
258
|
+
status: "ok",
|
|
259
|
+
dryRun: true,
|
|
260
|
+
schemaCode: options.schema,
|
|
261
|
+
workflowCode: options.code,
|
|
262
|
+
preservedNodes,
|
|
263
|
+
droppedNodes: options.confirmReplace === true ? droppedNodes : [],
|
|
264
|
+
finalTemplate,
|
|
265
|
+
note: "dry-run 仅合成预览;--apply 走 workflow.update(上面的 finalTemplate)→ workflow.publish。默认保留旧节点(preservedNodes);--confirm-replace 才允许丢弃(droppedNodes)。",
|
|
266
|
+
}, "ok");
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
const update = await invoke("workflow.update", { schemaCode: options.schema, code: options.code, template: finalTemplate });
|
|
270
|
+
if (isFailure(update)) {
|
|
271
|
+
emit({
|
|
272
|
+
status: update.status,
|
|
273
|
+
phase: "update",
|
|
274
|
+
update,
|
|
275
|
+
message: "更新失败——立即停止,未发布;草稿保持原样",
|
|
276
|
+
}, String(update.status));
|
|
277
|
+
return 1;
|
|
278
|
+
}
|
|
279
|
+
const publish = await invoke("workflow.publish", { schemaCode: options.schema, code: options.code });
|
|
280
|
+
if (isFailure(publish)) {
|
|
281
|
+
emit({
|
|
282
|
+
status: publish.status,
|
|
283
|
+
phase: "publish",
|
|
284
|
+
update,
|
|
285
|
+
published: publish,
|
|
286
|
+
preservedNodes,
|
|
287
|
+
droppedNodes,
|
|
288
|
+
message: "已更新未发布——模板改动保留在草稿;发布失败原因见 published,处理后单跑 omc call workflow.publish",
|
|
289
|
+
}, String(publish.status));
|
|
290
|
+
return 1;
|
|
291
|
+
}
|
|
292
|
+
emit({
|
|
293
|
+
status: "ok",
|
|
294
|
+
schemaCode: options.schema,
|
|
295
|
+
workflowCode: options.code,
|
|
296
|
+
update,
|
|
297
|
+
published: publish,
|
|
298
|
+
finalTemplate,
|
|
299
|
+
preservedNodes,
|
|
300
|
+
droppedNodes,
|
|
301
|
+
...(preservedNodes.length > 0
|
|
302
|
+
? { mergeWarning: `合并语义:旧节点 ${preservedNodes.join("、")} 不在新模板里、默认保留(已追加进 finalTemplate)。要丢弃请用 --confirm-replace 重跑。` }
|
|
303
|
+
: {}),
|
|
304
|
+
...(options.confirmReplace === true && droppedNodes.length > 0
|
|
305
|
+
? { replaceWarning: `确认替换:旧节点 ${droppedNodes.join("、")} 已被丢弃(不在 finalTemplate 里)。` }
|
|
306
|
+
: {}),
|
|
307
|
+
}, "ok");
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
//# sourceMappingURL=compound-writes.js.map
|
package/dist/discovery.js
CHANGED
|
@@ -88,17 +88,57 @@ export function commandInventory(registry, options = {}) {
|
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
90
|
/**
|
|
91
|
-
*
|
|
92
|
-
* not the 54 KB catalogue.
|
|
91
|
+
* r19 ticket 05 — semantic `commands --filter`.
|
|
93
92
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* the full
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
93
|
+
* The old filter matched only `name + lane + capability id` substrings, so an
|
|
94
|
+
* agent asking in capability language(「聚合」「字典」「业务集成」)got an
|
|
95
|
+
* empty list plus the full governance prose and had to re-read the 20 KB
|
|
96
|
+
* catalogue. The semantic face matches, in authority order: tool name →
|
|
97
|
+
* capability aliases/title/id → recipe id/title/aliases → tool description,
|
|
98
|
+
* with case and separator (`.`/`-`/`_`/space) normalization on both sides.
|
|
99
|
+
* Deterministic: hits sort by matched-field authority, then name.
|
|
101
100
|
*/
|
|
101
|
+
export function filterTools(registry, query) {
|
|
102
|
+
const rawQuery = query.trim().toLowerCase();
|
|
103
|
+
if (rawQuery.length === 0)
|
|
104
|
+
return [];
|
|
105
|
+
const normalizedQuery = normalizeFaceText(query);
|
|
106
|
+
const routeByLane = capabilityRouteIndex(registry).routeByLane;
|
|
107
|
+
const hits = [];
|
|
108
|
+
for (const tool of registry) {
|
|
109
|
+
const capability = tool.lane ? routeByLane.get(tool.lane) : undefined;
|
|
110
|
+
const fields = [
|
|
111
|
+
{ label: "工具名", text: tool.name },
|
|
112
|
+
...(capability ? [
|
|
113
|
+
{ label: `能力 ${capability.id} 别名`, text: capability.aliases.join(" ") },
|
|
114
|
+
{ label: `能力 ${capability.id} 标题`, text: capability.title },
|
|
115
|
+
{ label: "能力 id", text: capability.id },
|
|
116
|
+
] : []),
|
|
117
|
+
...(tool.executionMetadata?.recipes ?? []).flatMap((recipe) => [
|
|
118
|
+
{ label: `配方 ${recipe.id} 标题`, text: recipe.title },
|
|
119
|
+
{ label: "配方 id", text: recipe.id },
|
|
120
|
+
{ label: `配方 ${recipe.id} 别名`, text: (recipe.aliases ?? []).join(" ") },
|
|
121
|
+
]),
|
|
122
|
+
{ label: "工具摘要", text: tool.description },
|
|
123
|
+
];
|
|
124
|
+
let via;
|
|
125
|
+
let rank = 0;
|
|
126
|
+
for (const field of fields) {
|
|
127
|
+
const lower = field.text.toLowerCase();
|
|
128
|
+
if (lower.includes(rawQuery) || normalizeFaceText(field.text).includes(normalizedQuery)) {
|
|
129
|
+
via = `${field.label}含 "${query.trim()}"`;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
rank += 1;
|
|
133
|
+
}
|
|
134
|
+
if (via !== undefined)
|
|
135
|
+
hits.push({ name: tool.name, via, rank });
|
|
136
|
+
}
|
|
137
|
+
return hits.sort((left, right) => left.rank - right.rank || left.name.localeCompare(right.name)).map(({ name, via }) => ({ name, via }));
|
|
138
|
+
}
|
|
139
|
+
function normalizeFaceText(text) {
|
|
140
|
+
return text.toLowerCase().replace(/[._\s-]/g, "");
|
|
141
|
+
}
|
|
102
142
|
export function commandIndex(registry) {
|
|
103
143
|
const byLane = new Map();
|
|
104
144
|
for (const tool of registry) {
|
|
@@ -177,6 +217,178 @@ function exampleList(tool) {
|
|
|
177
217
|
requiresPriorReadback: recipe.requiresEnvironmentDerivedIds,
|
|
178
218
|
}));
|
|
179
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* `firstCall`: one runnable minimal shape, inline.
|
|
222
|
+
*
|
|
223
|
+
* r18 measured 71 adjacent `describe <tool>` → `describe <tool> --example <id>`
|
|
224
|
+
* pairs across 16 rounds: the compact default named the recipes but withheld
|
|
225
|
+
* the args, so learning the shape cost a second turn. The default now carries
|
|
226
|
+
* the cheapest *offline-valid* recipe (deterministic: status, then args bytes,
|
|
227
|
+
* then id) with its args inlined when small enough to stay a cheap face.
|
|
228
|
+
*
|
|
229
|
+
* Two honesty rules, because a wrong shape here is worse than no shape:
|
|
230
|
+
* - shipped recipes use stand-in ids (`omc_recipe_model`, `<environment>`).
|
|
231
|
+
* They are rewritten to explicit `<...>` placeholders and listed in
|
|
232
|
+
* `missing`, so nothing pretends to be a legal value.
|
|
233
|
+
* - `executable` is true only when `missing` is empty. Today that is never,
|
|
234
|
+
* because every authored recipe is a template; the field exists so a future
|
|
235
|
+
* concrete recipe becomes executable without a shape change.
|
|
236
|
+
*/
|
|
237
|
+
const RECIPE_INLINE_ARGS_CEILING = 1_000;
|
|
238
|
+
const RECIPE_PLACEHOLDER_MODEL = "omc_recipe_model";
|
|
239
|
+
const RECIPE_PLACEHOLDER_ENV = "<environment>";
|
|
240
|
+
const PLACEHOLDER_SOURCES = {
|
|
241
|
+
environment: "omc env / 项目 .omc-env",
|
|
242
|
+
schemaCode: "omc call model.list(models[].schemaCode)",
|
|
243
|
+
workflowCode: "omc call workflow.get_published(workflow.code)",
|
|
244
|
+
bizObjectId: "data.save 读回的 performed.bizObjectId",
|
|
245
|
+
workflowInstanceId: "data.save 读回的 performed.workflowInstanceId",
|
|
246
|
+
workItemId: "data.save 读回的 performed.workItemId",
|
|
247
|
+
propertyCode: "omc call field.list(fields[].code)",
|
|
248
|
+
code: "任务要求;模型/应用内唯一",
|
|
249
|
+
name: "任务要求",
|
|
250
|
+
propertyType: "按 inputSchema / 任务要求选枚举值",
|
|
251
|
+
};
|
|
252
|
+
/**
|
|
253
|
+
* Keys whose value always comes from the caller's own environment, never from
|
|
254
|
+
* the recipe. A recipe may name a concrete example (`wms_material`), but
|
|
255
|
+
* presenting that as the first call would send a write at the wrong model —
|
|
256
|
+
* the exact failure mode this projection exists to avoid. The literal is
|
|
257
|
+
* rewritten to `<key>` and named in `missing` with its source.
|
|
258
|
+
*/
|
|
259
|
+
const CONTEXT_KEYS = new Set([
|
|
260
|
+
"environment",
|
|
261
|
+
"schemaCode",
|
|
262
|
+
"schemaCodes",
|
|
263
|
+
"sheetCode",
|
|
264
|
+
"workflowCode",
|
|
265
|
+
"bizObjectId",
|
|
266
|
+
"bizObjectIds",
|
|
267
|
+
"workflowInstanceId",
|
|
268
|
+
"workItemId",
|
|
269
|
+
"propertyCode",
|
|
270
|
+
"propertyCodes",
|
|
271
|
+
"appCode",
|
|
272
|
+
"parentCode",
|
|
273
|
+
// Service/dictionary/form identifiers are also tenant-specific: a sample
|
|
274
|
+
// `wms_material_service` pasted into another environment fails at the
|
|
275
|
+
// platform, not at the CLI gate.
|
|
276
|
+
"serviceCode",
|
|
277
|
+
"serviceMethodCode",
|
|
278
|
+
"serviceMethodParameterCode",
|
|
279
|
+
"dictionaryCode",
|
|
280
|
+
"recordCode",
|
|
281
|
+
"formCode",
|
|
282
|
+
"formSchemaCode",
|
|
283
|
+
]);
|
|
284
|
+
function contextValue(key) {
|
|
285
|
+
return key === "environment" ? "<env>" : `<${key}>`;
|
|
286
|
+
}
|
|
287
|
+
function hasPlaceholder(value) {
|
|
288
|
+
if (typeof value === "string") {
|
|
289
|
+
return value.includes(RECIPE_PLACEHOLDER_MODEL) || value === RECIPE_PLACEHOLDER_ENV || /<[^<>]+>/.test(value);
|
|
290
|
+
}
|
|
291
|
+
if (Array.isArray(value))
|
|
292
|
+
return value.some(hasPlaceholder);
|
|
293
|
+
if (value && typeof value === "object")
|
|
294
|
+
return Object.values(value).some(hasPlaceholder);
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
/** Replace stand-in ids with explicit placeholders; never invent a real-looking id. */
|
|
298
|
+
function replacePlaceholders(value, key) {
|
|
299
|
+
if (key !== undefined && CONTEXT_KEYS.has(key)) {
|
|
300
|
+
return Array.isArray(value) ? [contextValue(key)] : contextValue(key);
|
|
301
|
+
}
|
|
302
|
+
if (typeof value === "string")
|
|
303
|
+
return value.split(RECIPE_PLACEHOLDER_MODEL).join("<existing-model-schemaCode>");
|
|
304
|
+
if (Array.isArray(value))
|
|
305
|
+
return value.map((entry) => replacePlaceholders(entry));
|
|
306
|
+
if (value && typeof value === "object") {
|
|
307
|
+
const output = {};
|
|
308
|
+
for (const [entryKey, entry] of Object.entries(value))
|
|
309
|
+
output[entryKey] = replacePlaceholders(entry, entryKey);
|
|
310
|
+
return output;
|
|
311
|
+
}
|
|
312
|
+
return value;
|
|
313
|
+
}
|
|
314
|
+
function placeholderKeys(args) {
|
|
315
|
+
return Object.entries(args)
|
|
316
|
+
.filter(([key, value]) => CONTEXT_KEYS.has(key) || hasPlaceholder(value))
|
|
317
|
+
.map(([key]) => key)
|
|
318
|
+
.sort();
|
|
319
|
+
}
|
|
320
|
+
function trimPreconditions(preconditions) {
|
|
321
|
+
const kept = [];
|
|
322
|
+
let bytes = 0;
|
|
323
|
+
for (const entry of preconditions) {
|
|
324
|
+
const next = bytes + Buffer.byteLength(entry, "utf8") + 4;
|
|
325
|
+
if (kept.length >= 3 || next > 420)
|
|
326
|
+
break;
|
|
327
|
+
kept.push(entry);
|
|
328
|
+
bytes = next;
|
|
329
|
+
}
|
|
330
|
+
return kept;
|
|
331
|
+
}
|
|
332
|
+
function selectRecipe(recipes) {
|
|
333
|
+
if (recipes.length === 0)
|
|
334
|
+
return undefined;
|
|
335
|
+
const ranked = [...recipes].sort((left, right) => {
|
|
336
|
+
const leftRank = left.status === "offline-valid" ? 0 : 1;
|
|
337
|
+
const rightRank = right.status === "offline-valid" ? 0 : 1;
|
|
338
|
+
if (leftRank !== rightRank)
|
|
339
|
+
return leftRank - rightRank;
|
|
340
|
+
const leftBytes = Buffer.byteLength(JSON.stringify(left.args), "utf8");
|
|
341
|
+
const rightBytes = Buffer.byteLength(JSON.stringify(right.args), "utf8");
|
|
342
|
+
if (leftBytes !== rightBytes)
|
|
343
|
+
return leftBytes - rightBytes;
|
|
344
|
+
return left.id.localeCompare(right.id);
|
|
345
|
+
});
|
|
346
|
+
return ranked[0];
|
|
347
|
+
}
|
|
348
|
+
function firstCallFor(tool) {
|
|
349
|
+
const recipe = selectRecipe(tool.executionMetadata?.recipes ?? []);
|
|
350
|
+
if (!recipe)
|
|
351
|
+
return undefined;
|
|
352
|
+
const rawArgs = recipe.args;
|
|
353
|
+
const hoistedEnvironment = "environment" in rawArgs;
|
|
354
|
+
const withoutEnv = {};
|
|
355
|
+
for (const [key, value] of Object.entries(rawArgs))
|
|
356
|
+
if (key !== "environment")
|
|
357
|
+
withoutEnv[key] = value;
|
|
358
|
+
const inlined = replacePlaceholders(withoutEnv);
|
|
359
|
+
const inlinedJson = JSON.stringify(inlined);
|
|
360
|
+
const argsBytes = Buffer.byteLength(inlinedJson, "utf8");
|
|
361
|
+
const missing = [...(hoistedEnvironment ? ["environment"] : []), ...placeholderKeys(withoutEnv)];
|
|
362
|
+
// A single quote cannot ride inside `--args '<json>'`; fall back to the file
|
|
363
|
+
// form rather than emit a command that is subtly wrong when pasted.
|
|
364
|
+
const inline = argsBytes <= RECIPE_INLINE_ARGS_CEILING;
|
|
365
|
+
// Above the one-liner size, `args` already carries the shape; repeating it as
|
|
366
|
+
// a shell-quoted `command` doubles the bytes for no new information.
|
|
367
|
+
const inlineCommand = inline && argsBytes <= 512 && !inlinedJson.includes("'");
|
|
368
|
+
const firstCall = {
|
|
369
|
+
recipeId: recipe.id,
|
|
370
|
+
...(inline
|
|
371
|
+
? {
|
|
372
|
+
args: inlined,
|
|
373
|
+
...(inlineCommand
|
|
374
|
+
? { command: `omc call ${tool.name}${hoistedEnvironment ? " --env <env>" : ""} --args '${inlinedJson}'` }
|
|
375
|
+
: { commandHint: `把上面的 args 原样作为 --args '<json>' 传给 omc call ${tool.name}${hoistedEnvironment ? " --env <env>" : ""}` }),
|
|
376
|
+
note: "args 里的标识(模型/服务/字段 code)来自示例环境,需换成你自己的",
|
|
377
|
+
}
|
|
378
|
+
: { argsBytes, note: `配方参数 ${argsBytes}B,超过默认内联上限;用 readMore 取完整形状(不要凭这一行猜 args)` }),
|
|
379
|
+
executable: missing.length === 0,
|
|
380
|
+
...(missing.length > 0 ? { missing } : {}),
|
|
381
|
+
...(missing.length > 0
|
|
382
|
+
? {
|
|
383
|
+
requiredValues: Object.fromEntries(missing.map((key) => [key, { value: key === "environment" ? "<env>" : `<${key}>`, source: PLACEHOLDER_SOURCES[key] ?? "按 inputSchema 或任务要求" }])),
|
|
384
|
+
}
|
|
385
|
+
: {}),
|
|
386
|
+
preconditions: trimPreconditions(recipe.preconditions),
|
|
387
|
+
requiresPriorReadback: recipe.requiresEnvironmentDerivedIds,
|
|
388
|
+
readMore: `omc describe ${tool.name} --example ${recipe.id}`,
|
|
389
|
+
};
|
|
390
|
+
return firstCall;
|
|
391
|
+
}
|
|
180
392
|
/** Default `omc describe`: the compact contract projection. */
|
|
181
393
|
export function describeTool(registry, name) {
|
|
182
394
|
const tool = registry.find((entry) => entry.name === name);
|
|
@@ -186,6 +398,7 @@ export function describeTool(registry, name) {
|
|
|
186
398
|
const { required, params } = compactParams(schema);
|
|
187
399
|
const examples = exampleList(tool);
|
|
188
400
|
const hasRecipe = (tool.executionMetadata?.recipes.length ?? 0) > 0;
|
|
401
|
+
const firstCall = firstCallFor(tool);
|
|
189
402
|
return {
|
|
190
403
|
name: tool.name,
|
|
191
404
|
lane: tool.lane ?? null,
|
|
@@ -194,6 +407,7 @@ export function describeTool(registry, name) {
|
|
|
194
407
|
required,
|
|
195
408
|
params,
|
|
196
409
|
examples,
|
|
410
|
+
...(firstCall !== undefined ? { firstCall } : {}),
|
|
197
411
|
// Point cold callers at the cheap runnable face BEFORE they reach for
|
|
198
412
|
// --full: a recipe is ~1–2 KB and a single --param is a few hundred bytes,
|
|
199
413
|
// while --full is 5–20 KB. Benchmark (bench13): two --full calls were 34 KB
|
package/dist/field-families.js
CHANGED
|
@@ -954,6 +954,33 @@ export function assertFieldOptionsAllowed(type, options, schemaCode) {
|
|
|
954
954
|
const agg = parseOptionsObject(config.aggregation);
|
|
955
955
|
if (!agg || !isNonEmptyString(agg.schemaCode) || !isNonEmptyString(agg.dataItemCode) || !isNonEmptyString(agg.formula))
|
|
956
956
|
refuseFieldOptions("AGGREGATION aggregation is missing schemaCode/dataItemCode/formula", 'rejected: aggregation 形状 {schemaCode:"<目标模型schemaCode>", dataItemCode:"<目标字段code>", formula:"SUM({<目标模型>.<字段>})", calculateType:"SUM", queryConditions:[[…]]}(FormulaProcessor.executeMainAggregation + AggregationFormula)');
|
|
957
|
+
// r18b (2026-10-26 live): LARGE/SMALL take a position argument and the
|
|
958
|
+
// platform evaluates the FORMULA, so `LARGE({col})` (no k) throws
|
|
959
|
+
// NumberFormatException internally and the target write answers 50000.
|
|
960
|
+
const calcType = typeof agg.calculateType === "string" ? agg.calculateType.toUpperCase() : "";
|
|
961
|
+
if (["LARGE", "SMALL"].includes(calcType) && !/(LARGE|SMALL)\s*\(\s*[^,()]+\s*,/.test(String(agg.formula)))
|
|
962
|
+
throw new ToolRefusalError({
|
|
963
|
+
status: "refused",
|
|
964
|
+
blocker: "aggregation-large-missing-k",
|
|
965
|
+
schemaCode: schemaCode ?? null,
|
|
966
|
+
calculateType: calcType,
|
|
967
|
+
formula: String(agg.formula),
|
|
968
|
+
message: `${calcType} 必须带位置参数 k:写成 ${calcType}({<来源模型>.<数值列>},<k>)(k=1 是最大/最小)。` +
|
|
969
|
+
`缺 k 时平台内部抛 NumberFormatException(日志可见),目标模型写入一律 50000(r18b 真机)。` +
|
|
970
|
+
`真机验证:LARGE({src.amt},2) 对 10/30/20 返回 20;SMALL({src.amt},1) 返回 10。`,
|
|
971
|
+
});
|
|
972
|
+
// UNION concatenates DISTINCT text values ("去重后拼接文本"), so a numeric
|
|
973
|
+
// resultType makes the aggregation parse text as a number and the write
|
|
974
|
+
// 50000s. Text result types: SHORT_TEXT(0) / LONG_TEXT(1).
|
|
975
|
+
if (calcType === "UNION" && ![0, 1].includes(Number(agg.resultType ?? 2)))
|
|
976
|
+
throw new ToolRefusalError({
|
|
977
|
+
status: "refused",
|
|
978
|
+
blocker: "aggregation-union-result-type",
|
|
979
|
+
schemaCode: schemaCode ?? null,
|
|
980
|
+
resultType: Number(agg.resultType ?? 2),
|
|
981
|
+
message: `UNION 是「去重后拼接文本」,resultType 必须是文本类型(0=SHORT_TEXT / 1=LONG_TEXT;顶层 formulaConfig.resultType 决定)。` +
|
|
982
|
+
`用数值 resultType 时平台把拼接结果按数字解析 → NumberFormatException → 目标写入 50000(r18b 真机:resultType:2 失败,0 成功,读回 "P1")。`,
|
|
983
|
+
});
|
|
957
984
|
if (String(agg.dataItemCode).includes("."))
|
|
958
985
|
throw new ToolRefusalError({
|
|
959
986
|
status: "refused",
|