mingdao-harness 0.1.54

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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +246 -0
  3. package/assets/tokenizer-data.json.gz +0 -0
  4. package/docs/ARCHITECTURE.md +108 -0
  5. package/docs/CONFIG.md +257 -0
  6. package/docs/DESKTOP-EVALUATION.md +48 -0
  7. package/docs/PROVIDERS.md +98 -0
  8. package/docs/QA-REPORT.md +333 -0
  9. package/install.bat +8 -0
  10. package/install.ps1 +57 -0
  11. package/install.sh +154 -0
  12. package/package.json +61 -0
  13. package/skills/api-design/SKILL.md +21 -0
  14. package/skills/code-review/SKILL.md +31 -0
  15. package/skills/debugging/SKILL.md +21 -0
  16. package/skills/docker/SKILL.md +27 -0
  17. package/skills/docx/SKILL.md +28 -0
  18. package/skills/frontend-design/SKILL.md +31 -0
  19. package/skills/git-commit/SKILL.md +32 -0
  20. package/skills/pdf/SKILL.md +30 -0
  21. package/skills/pptx/SKILL.md +24 -0
  22. package/skills/refactoring/SKILL.md +24 -0
  23. package/skills/release-checklist/SKILL.md +20 -0
  24. package/skills/testing/SKILL.md +30 -0
  25. package/skills/webapp-testing/SKILL.md +27 -0
  26. package/skills/xlsx/SKILL.md +28 -0
  27. package/src/agent.js +472 -0
  28. package/src/audit.js +68 -0
  29. package/src/autostart.js +74 -0
  30. package/src/batch.js +182 -0
  31. package/src/cachestats.js +215 -0
  32. package/src/cli.js +1099 -0
  33. package/src/commands/key.js +75 -0
  34. package/src/commands/schedule.js +176 -0
  35. package/src/commands/skill.js +168 -0
  36. package/src/commands/sync.js +222 -0
  37. package/src/commands/update.js +157 -0
  38. package/src/commands/workspace.js +109 -0
  39. package/src/compact.js +112 -0
  40. package/src/config.js +134 -0
  41. package/src/context.js +86 -0
  42. package/src/cost-guard.js +58 -0
  43. package/src/credentials.js +69 -0
  44. package/src/hooks.js +123 -0
  45. package/src/index.js +42 -0
  46. package/src/mcp-presets.js +78 -0
  47. package/src/mcp.js +284 -0
  48. package/src/memory.js +242 -0
  49. package/src/model-discovery.js +153 -0
  50. package/src/models.js +186 -0
  51. package/src/notify.js +38 -0
  52. package/src/permissions.js +83 -0
  53. package/src/pricing.js +156 -0
  54. package/src/prompts.js +58 -0
  55. package/src/providers/index.js +135 -0
  56. package/src/providers/openai-compatible.js +171 -0
  57. package/src/routing.js +126 -0
  58. package/src/schedule.js +434 -0
  59. package/src/session-index.js +122 -0
  60. package/src/session.js +131 -0
  61. package/src/skill-lib.js +350 -0
  62. package/src/skill-registry.js +165 -0
  63. package/src/skills.js +135 -0
  64. package/src/sync-server.js +564 -0
  65. package/src/sync.js +479 -0
  66. package/src/tasks.js +126 -0
  67. package/src/titles.js +75 -0
  68. package/src/tokenizer.js +287 -0
  69. package/src/tools/bash.js +165 -0
  70. package/src/tools/fs-tools.js +346 -0
  71. package/src/tools/index.js +287 -0
  72. package/src/ui.js +643 -0
  73. package/src/update.js +222 -0
  74. package/src/web/attachments.js +49 -0
  75. package/src/web/index.html +993 -0
  76. package/src/web/server.js +1173 -0
  77. package/src/web/web-io.js +107 -0
  78. package/src/workspace.js +157 -0
package/src/batch.js ADDED
@@ -0,0 +1,182 @@
1
+ // Batch API 半价通道(四报告共识 A1/OfficeACE P0:批量任务省 50%):
2
+ // 单轮批量任务(无工具、无流式)走 OpenAI 兼容批处理协议:
3
+ // POST {base}/files(multipart,purpose=batch)→ input_file_id
4
+ // POST {base}/batches {input_file_id, endpoint, completion_window:'24h'} → batchId
5
+ // 轮询 GET {base}/batches/{id} → completed → 下载结果(DeepSeek 风格 /files/result,回退 OpenAI /files/{id}/content)
6
+ // 端点不可用(404/405)→ 明确报错告知网关不支持批处理,绝不静默假装成功。
7
+ // 计费:闲时全未命中 × BATCH_DISCOUNT(0.5),结果记入 cache-stats(batch:true)供 /cost 汇总。
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { resolveProviderConfig } from './providers/index.js';
12
+ import { estimateBatchCost, BATCH_DISCOUNT } from './pricing.js';
13
+ import { recordCacheStats } from './cachestats.js';
14
+ import { buildSystemPrompt } from './prompts.js';
15
+
16
+ const DEFAULT_WINDOW = '24h';
17
+ const DEFAULT_ENDPOINT = '/v1/chat/completions';
18
+
19
+ // 批处理端点基址:config.batchBaseUrl 优先;否则取当前服务商 baseUrl 去掉 /v1 后缀
20
+ function batchBase(cfg, model) {
21
+ const explicit = String(cfg?.batchBaseUrl || '').trim().replace(/\/+$/, '');
22
+ if (explicit) return explicit;
23
+ const pc = resolveProviderConfig(cfg, model);
24
+ // 审计 P2-10:DeepSeek 的批处理端点在根路径(/files /batches),其余 OpenAI 兼容网关在 /v1 下
25
+ const base = String(pc.baseUrl || '').replace(/\/+$/, '');
26
+ return pc.name === 'deepseek' ? base.replace(/\/v1\/?$/, '') : base;
27
+ }
28
+
29
+ async function api(base, apiKey, methodPath, payload, httpMethod = 'POST') {
30
+ const res = await fetch(base + methodPath, {
31
+ method: httpMethod,
32
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
33
+ body: payload === undefined ? undefined : JSON.stringify(payload),
34
+ });
35
+ const j = await res.json().catch(() => ({}));
36
+ if (!res.ok) {
37
+ const e = new Error(j?.error?.message || j?.message || `HTTP ${res.status}`);
38
+ e.status = res.status;
39
+ throw e;
40
+ }
41
+ return j;
42
+ }
43
+
44
+ async function uploadFile(base, apiKey, jsonl) {
45
+ const form = new FormData();
46
+ form.append('file', new Blob([jsonl], { type: 'application/jsonl' }), 'mingdao-batch.jsonl');
47
+ form.append('purpose', 'batch');
48
+ const res = await fetch(base + '/files', {
49
+ method: 'POST',
50
+ headers: { Authorization: `Bearer ${apiKey}` },
51
+ body: form,
52
+ });
53
+ const j = await res.json().catch(() => ({}));
54
+ if (!res.ok) {
55
+ const e = new Error(j?.error?.message || j?.message || `上传失败 HTTP ${res.status}`);
56
+ e.status = res.status;
57
+ throw e;
58
+ }
59
+ return j.id;
60
+ }
61
+
62
+ async function downloadResults(base, apiKey, batch) {
63
+ // DeepSeek 风格:直接取结果文件;回退 OpenAI 风格:按 output_file_id 取内容
64
+ const attempts = [
65
+ `/batches/${batch.id}/files/result`,
66
+ ...(batch.output_file_id ? [`/files/${batch.output_file_id}/content`] : []),
67
+ ];
68
+ for (const m of attempts) {
69
+ const res = await fetch(base + m, { headers: { Authorization: `Bearer ${apiKey}` } });
70
+ if (!res.ok) continue;
71
+ const text = await res.text();
72
+ return text
73
+ .split('\n')
74
+ .filter(Boolean)
75
+ .map((l) => {
76
+ try {
77
+ return JSON.parse(l);
78
+ } catch {
79
+ return null;
80
+ }
81
+ })
82
+ .filter(Boolean);
83
+ }
84
+ throw new Error('批处理结果文件不可用(端点不支持或文件已过期)');
85
+ }
86
+
87
+ // 执行一次批处理。questions: string[]。返回 { ok, outputFile, results, usage, cost, batchId }
88
+ export async function runBatch({ cfg, model, questions, workingDir = process.cwd(), maxTokens = 4096, temperature, signal, onStatus }) {
89
+ const list = (questions || []).map((q) => String(q).trim()).filter(Boolean);
90
+ if (!list.length) return { error: '没有可批处理的问题(每行一个问题)' };
91
+ const pc = resolveProviderConfig(cfg, model);
92
+ if (!pc.apiKey) return { error: `模型 ${model} 没有可用 API Key(mingdao key set ${pc.name})` };
93
+ const base = batchBase(cfg, model);
94
+ const apiKey = pc.apiKey;
95
+ const systemPrompt = buildSystemPrompt({ workingDir });
96
+ const bodyTemplate = {
97
+ model,
98
+ messages: null, // 逐行填充
99
+ max_tokens: maxTokens,
100
+ temperature: temperature ?? cfg?.temperature ?? 0.6,
101
+ stream: false,
102
+ };
103
+ const jsonl =
104
+ list
105
+ .map((q, i) =>
106
+ JSON.stringify({
107
+ custom_id: `md-${i}`,
108
+ method: 'POST',
109
+ url: cfg?.batchEndpoint || DEFAULT_ENDPOINT,
110
+ body: { ...bodyTemplate, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: q }] },
111
+ })
112
+ )
113
+ .join('\n') + '\n';
114
+
115
+ try {
116
+ onStatus?.('上传输入文件…');
117
+ const fileId = await uploadFile(base, apiKey, jsonl);
118
+ onStatus?.('创建批处理任务…');
119
+ const batch = await api(base, apiKey, '/batches', {
120
+ input_file_id: fileId,
121
+ endpoint: cfg?.batchEndpoint || DEFAULT_ENDPOINT,
122
+ completion_window: cfg?.batchWindow || DEFAULT_WINDOW,
123
+ });
124
+ onStatus?.(`任务已创建:${batch.id}`);
125
+ // 轮询(间隔可用 MINGDAO_BATCH_POLL_MS 覆盖,测试用);连续失败 10 次 → 报错,绝不无限重试
126
+ const interval = Math.max(500, Number(process.env.MINGDAO_BATCH_POLL_MS) || 5000);
127
+ const t0 = Date.now();
128
+ let st = batch.status;
129
+ let failures = 0;
130
+ for (;;) {
131
+ if (signal?.aborted) return { error: '已取消轮询(任务仍在服务端运行)', batchId: batch.id };
132
+ if (Date.now() - t0 > 24 * 3600 * 1000) return { error: '批处理超过 24h 窗口', batchId: batch.id };
133
+ let j = null;
134
+ try {
135
+ j = await api(base, apiKey, `/batches/${batch.id}`, undefined, 'GET');
136
+ failures = 0;
137
+ } catch (err) {
138
+ failures += 1;
139
+ if (failures >= 10) return { error: `轮询失败:${err?.message || err}(任务仍在服务端,ID ${batch.id})`, batchId: batch.id };
140
+ await new Promise((r) => setTimeout(r, interval));
141
+ continue;
142
+ }
143
+ st = j.status;
144
+ if (st === 'completed') {
145
+ batch.output_file_id = j.output_file_id;
146
+ break;
147
+ }
148
+ if (['failed', 'expired', 'cancelled', 'canceled'].includes(st)) {
149
+ const detail = j?.errors?.data?.[0]?.message || j?.errors?.message || '';
150
+ return { error: `批处理失败:${st}${detail ? '(' + detail + ')' : ''}`, batchId: batch.id };
151
+ }
152
+ if (st !== 'in_progress' && st !== 'validating' && st !== 'finalizing') {
153
+ // 未知状态:继续等待但报告
154
+ onStatus?.(`状态:${st}`);
155
+ }
156
+ await new Promise((r) => setTimeout(r, interval));
157
+ }
158
+ onStatus?.('下载结果…');
159
+ const results = await downloadResults(base, apiKey, batch);
160
+ // 汇总 usage 与费用(batch 半价)
161
+ let prompt = 0;
162
+ let completion = 0;
163
+ const outputs = [];
164
+ for (const r of results) {
165
+ const body = r?.response?.body || {};
166
+ prompt += body.usage?.prompt_tokens || 0;
167
+ completion += body.usage?.completion_tokens || 0;
168
+ const content = body.choices?.[0]?.message?.content ?? body.choices?.[0]?.text ?? (r?.response?.status_code !== 200 ? `(错误 ${r?.response?.status_code})` : '');
169
+ outputs.push({ id: r.custom_id, content: String(content || '').trim() });
170
+ }
171
+ const usage = { prompt_tokens: prompt, completion_tokens: completion };
172
+ const cost = estimateBatchCost(model, prompt, completion);
173
+ const outFile = path.join(workingDir, `mingdao-batch-result-${Date.now()}.jsonl`);
174
+ fs.writeFileSync(outFile, outputs.map((o) => JSON.stringify(o)).join('\n') + '\n');
175
+ recordCacheStats({ model, prompt, completion, hit: null, miss: null, cost, saved: null, batch: true });
176
+ onStatus?.(`完成:${outputs.length} 条结果`);
177
+ return { ok: true, batchId: batch.id, outputFile: outFile, results: outputs, usage, cost, discount: BATCH_DISCOUNT };
178
+ } catch (err) {
179
+ // 批处理端点不支持(404/405 等)→ 明确告知,不静默
180
+ return { error: `批处理不可用:${err?.message || err}(该服务商可能不支持 Batch API,可用 config.batchBaseUrl 指定支持的网关)` };
181
+ }
182
+ }
@@ -0,0 +1,215 @@
1
+ // 缓存命中统计:记录每次用量的缓存命中/未命中,支撑「缓存命中率仪表盘」。
2
+ // 落盘 <home>/cache-stats.jsonl,每行:{at, model, prompt, completion, hit, miss, cost, saved}
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { mingdaoHome, ensureHome } from './config.js';
7
+ import { estimateCost, cacheSplit, beijingDayStart, beijingParts } from './pricing.js';
8
+ import { modelPreset } from './models.js';
9
+
10
+ export function cacheStatsFile() {
11
+ return path.join(mingdaoHome(), 'cache-stats.jsonl');
12
+ }
13
+
14
+ // 内存计数 + 低频轮转(评估 P3-3:此前无限增长且每次全量解析)
15
+ const MAX_LINES = 20000;
16
+ const KEEP_LINES = 10000;
17
+ let cacheStatsCount = 0;
18
+
19
+ export function recordCacheStats(entry) {
20
+ try {
21
+ ensureHome(); // 审计 B10:与 audit/journal 一致,目录缺失不静默丢统计
22
+ const line = JSON.stringify({
23
+ at: Date.now(),
24
+ model: entry.model || '',
25
+ prompt: entry.prompt || 0,
26
+ completion: entry.completion || 0,
27
+ hit: entry.hit ?? null,
28
+ miss: entry.miss ?? null,
29
+ cost: entry.cost ?? null,
30
+ saved: entry.saved ?? null,
31
+ batch: entry.batch === true ? true : undefined, // Batch API 半价任务标记(/cost 分账展示)
32
+ steps: entry.steps ?? undefined, // 本回合步数(状态栏 步数统计)
33
+ llmMs: entry.llmMs ?? undefined, // 模型调用总耗时(状态栏 LLM 时长/tok-s)
34
+ toolMs: entry.toolMs ?? undefined, // 工具执行总耗时(状态栏 工具调用时长)
35
+ firstTokenMs: entry.firstTokenMs ?? undefined, // 首 token 延迟(状态栏平均首 token)
36
+ });
37
+ fs.appendFileSync(cacheStatsFile(), line + '\n');
38
+ cacheStatsCount += 1;
39
+ } catch {}
40
+ if (cacheStatsCount > MAX_LINES && cacheStatsCount % 200 === 0) {
41
+ try {
42
+ const raw = fs.readFileSync(cacheStatsFile(), 'utf8');
43
+ const lines = raw.split('\n').filter(Boolean);
44
+ if (lines.length > MAX_LINES) {
45
+ fs.writeFileSync(cacheStatsFile(), lines.slice(-KEEP_LINES).join('\n') + '\n');
46
+ cacheStatsCount = KEEP_LINES;
47
+ }
48
+ } catch {}
49
+ }
50
+ }
51
+
52
+ export function listCacheStats(limit = 2000) {
53
+ try {
54
+ const raw = fs.readFileSync(cacheStatsFile(), 'utf8');
55
+ const out = [];
56
+ for (const l of raw.split('\n')) {
57
+ if (!l.trim()) continue;
58
+ try {
59
+ out.push(JSON.parse(l));
60
+ } catch {}
61
+ }
62
+ return out.slice(-limit);
63
+ } catch {
64
+ return [];
65
+ }
66
+ }
67
+
68
+ export function summarizeCacheStats(entries) {
69
+ const sum = { turns: entries.length, prompt: 0, completion: 0, hit: 0, miss: 0, cost: 0, saved: 0, steps: 0, llmMs: 0, toolMs: 0, firstTokenCount: 0, firstTokenSumMs: 0 };
70
+ for (const e of entries) {
71
+ sum.prompt += e.prompt || 0;
72
+ sum.completion += e.completion || 0;
73
+ sum.hit += e.hit || 0;
74
+ sum.miss += e.miss || 0;
75
+ sum.cost += e.cost || 0;
76
+ sum.saved += e.saved || 0;
77
+ sum.steps += e.steps || 0;
78
+ sum.llmMs += e.llmMs || 0;
79
+ sum.toolMs += e.toolMs || 0;
80
+ if (e.firstTokenMs != null) {
81
+ sum.firstTokenCount += 1;
82
+ sum.firstTokenSumMs += e.firstTokenMs;
83
+ }
84
+ }
85
+ sum.rate = sum.hit + sum.miss > 0 ? sum.hit / (sum.hit + sum.miss) : null;
86
+ sum.firstTokenAvgMs = sum.firstTokenCount > 0 ? sum.firstTokenSumMs / sum.firstTokenCount : null;
87
+ sum.tokensPerSec = sum.llmMs > 0 ? (sum.completion / (sum.llmMs / 1000)) : null;
88
+ return sum;
89
+ }
90
+
91
+ // 记录一次用量(agent 侧调用):自动计算命中拆分与节省额
92
+ // 记录一次用量(agent 侧调用):自动计算命中拆分与节省额;perf 为回合性能指标(状态栏)
93
+ export function recordUsage(modelName, usage, perf = null) {
94
+ const split = cacheSplit(usage);
95
+ const prompt = usage?.prompt_tokens || 0;
96
+ const completion = usage?.completion_tokens || 0;
97
+ let cost = null;
98
+ let saved = null;
99
+ if (split) {
100
+ cost = estimateCost(modelName, prompt, completion, split);
101
+ saved = estimateCost(modelName, prompt, completion, null) - cost;
102
+ } else if (modelPreset(modelName)?.pricing) {
103
+ // 审计 B10:无缓存字段时按全未命中估算(不再计 0 元,费用护栏口径更真实)
104
+ cost = estimateCost(modelName, prompt, completion, null);
105
+ }
106
+ recordCacheStats({
107
+ model: modelName,
108
+ prompt,
109
+ completion,
110
+ hit: split?.hit ?? null,
111
+ miss: split?.miss ?? null,
112
+ cost,
113
+ saved,
114
+ steps: perf?.steps ?? undefined,
115
+ llmMs: perf?.llmMs ?? undefined,
116
+ toolMs: perf?.toolMs ?? undefined,
117
+ firstTokenMs: perf?.firstTokenMs ?? undefined,
118
+ });
119
+ }
120
+
121
+ export function formatCacheSummary(sum) {
122
+ const fmt = (n) => (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n));
123
+ const lines = [
124
+ `轮次 ${sum.turns} · ↑${fmt(sum.prompt)} ↓${fmt(sum.completion)} tokens`,
125
+ `缓存命中率 ${sum.rate != null ? (sum.rate * 100).toFixed(0) + '%' : '暂无缓存数据'}`,
126
+ `实际费用 ≈¥${sum.cost.toFixed(5)} · 相比全未命中节省 ≈¥${sum.saved.toFixed(5)}`,
127
+ ];
128
+ return lines;
129
+ }
130
+
131
+ // 分账统计(评估 /cost 升级):按模型分账、今日费用、batch 半价任务、节省归因
132
+ export function costBreakdown() {
133
+ const entries = listCacheStats(100000);
134
+ const byModel = new Map();
135
+ const start = beijingDayStart().getTime();
136
+ let totalCost = 0;
137
+ let totalSaved = 0;
138
+ let today = 0;
139
+ let batchCost = 0;
140
+ let hit = 0;
141
+ let miss = 0;
142
+ for (const e of entries) {
143
+ totalCost += e.cost || 0;
144
+ totalSaved += e.saved || 0;
145
+ hit += e.hit || 0;
146
+ miss += e.miss || 0;
147
+ if (e.batch) batchCost += e.cost || 0;
148
+ if (e.at >= start) today += e.cost || 0;
149
+ const m = byModel.get(e.model) || { prompt: 0, completion: 0, cost: 0, saved: 0, turns: 0, batchTurns: 0 };
150
+ m.prompt += e.prompt || 0;
151
+ m.completion += e.completion || 0;
152
+ m.cost += e.cost || 0;
153
+ m.saved += e.saved || 0;
154
+ m.turns += 1;
155
+ if (e.batch) m.batchTurns += 1;
156
+ byModel.set(e.model, m);
157
+ }
158
+ return {
159
+ totalCost,
160
+ totalSaved,
161
+ today,
162
+ batchCost,
163
+ hit,
164
+ miss,
165
+ rate: hit + miss > 0 ? hit / (hit + miss) : null,
166
+ byModel: [...byModel.entries()].map(([model, m]) => ({ model, ...m })).sort((a, b) => b.cost - a.cost),
167
+ };
168
+ }
169
+
170
+ // 月度费用报告(/cost 导出):按北京时间月份聚合,month 形如 'YYYY-MM',缺省返回全部月份
171
+ export function costMonthlyReport(month) {
172
+ const entries = listCacheStats(100000);
173
+ const want = String(month || '').trim();
174
+ const months = new Map();
175
+ for (const e of entries) {
176
+ const p = beijingParts(new Date(e.at));
177
+ const key = `${p.year}-${String(p.month).padStart(2, '0')}`;
178
+ if (want && key !== want) continue;
179
+ let m = months.get(key);
180
+ if (!m) {
181
+ m = { month: key, cost: 0, saved: 0, prompt: 0, completion: 0, hit: 0, miss: 0, batchCost: 0, days: new Map(), models: new Map() };
182
+ months.set(key, m);
183
+ }
184
+ m.cost += e.cost || 0;
185
+ m.saved += e.saved || 0;
186
+ m.prompt += e.prompt || 0;
187
+ m.completion += e.completion || 0;
188
+ m.hit += e.hit || 0;
189
+ m.miss += e.miss || 0;
190
+ if (e.batch) m.batchCost += e.cost || 0;
191
+ const day = String(p.day).padStart(2, '0');
192
+ m.days.set(day, (m.days.get(day) || 0) + (e.cost || 0));
193
+ const mm = m.models.get(e.model) || { prompt: 0, completion: 0, cost: 0, turns: 0 };
194
+ mm.prompt += e.prompt || 0;
195
+ mm.completion += e.completion || 0;
196
+ mm.cost += e.cost || 0;
197
+ mm.turns += 1;
198
+ m.models.set(e.model, mm);
199
+ }
200
+ return [...months.values()]
201
+ .sort((a, b) => a.month.localeCompare(b.month))
202
+ .map((m) => ({
203
+ month: m.month,
204
+ cost: m.cost,
205
+ saved: m.saved,
206
+ prompt: m.prompt,
207
+ completion: m.completion,
208
+ hit: m.hit,
209
+ miss: m.miss,
210
+ rate: m.hit + m.miss > 0 ? m.hit / (m.hit + m.miss) : null,
211
+ batchCost: m.batchCost,
212
+ days: [...m.days.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([day, cost]) => ({ day, cost })),
213
+ models: [...m.models.entries()].map(([model, x]) => ({ model, ...x })).sort((a, b) => b.cost - a.cost),
214
+ }));
215
+ }