@mrfentmen/cats-multi-cat-sim-mcp 0.0.0-stage → 1.0.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.
- package/README.md +93 -2
- package/dist/config.d.ts +2 -0
- package/dist/config.js +158 -0
- package/dist/engine.d.ts +270 -0
- package/dist/engine.js +660 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/run.d.ts +17 -0
- package/dist/run.js +15 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +9 -0
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +281 -0
- package/package.json +51 -4
- package/server.json +21 -0
package/dist/engine.js
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ToolError } from "./run.js";
|
|
5
|
+
import { config } from "./config.js";
|
|
6
|
+
export const CONDITIONS = ["new", "good", "fair", "worn"];
|
|
7
|
+
export const itemSchema = z.object({
|
|
8
|
+
id: z.string().min(1).max(120).optional(),
|
|
9
|
+
name: z.string().min(1).max(200),
|
|
10
|
+
category: z.string().min(1).max(80),
|
|
11
|
+
tags: z.array(z.string().min(1).max(80)).max(20).optional(),
|
|
12
|
+
cost: z.number().min(0).max(1_000_000).optional(),
|
|
13
|
+
quantity: z.number().min(0).max(1_000_000).optional(),
|
|
14
|
+
acquired: z.string().max(10).optional(),
|
|
15
|
+
expires: z.string().max(10).optional(),
|
|
16
|
+
lastUsed: z.string().max(10).optional(),
|
|
17
|
+
condition: z.enum(CONDITIONS).optional(),
|
|
18
|
+
notes: z.string().max(1000).optional(),
|
|
19
|
+
});
|
|
20
|
+
export const targetSchema = z.object({
|
|
21
|
+
maxItems: z.number().int().min(1).max(1000).optional(),
|
|
22
|
+
budget: z.number().min(0).max(1_000_000).nullable().optional(),
|
|
23
|
+
coverage: z.number().min(0).max(1).optional(),
|
|
24
|
+
expiryDays: z.number().int().min(0).max(3650).optional(),
|
|
25
|
+
});
|
|
26
|
+
const MAX_INPUT = 2_000_000;
|
|
27
|
+
const MAX_ITEMS = 500;
|
|
28
|
+
const round = (value, digits = 2) => {
|
|
29
|
+
if (!Number.isFinite(value))
|
|
30
|
+
return 0;
|
|
31
|
+
const factor = 10 ** digits;
|
|
32
|
+
return Math.round(value * factor) / factor;
|
|
33
|
+
};
|
|
34
|
+
const average = (values) => values.length === 0 ? 0 : values.reduce((total, value) => total + value, 0) / values.length;
|
|
35
|
+
const sum = (values) => values.reduce((total, value) => total + value, 0);
|
|
36
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
37
|
+
const parseDate = (value) => {
|
|
38
|
+
const time = Date.parse(`${value}T00:00:00Z`);
|
|
39
|
+
return Number.isFinite(time) ? time : null;
|
|
40
|
+
};
|
|
41
|
+
const daysBetween = (from, to) => {
|
|
42
|
+
const left = parseDate(from);
|
|
43
|
+
const right = parseDate(to);
|
|
44
|
+
return left === null || right === null ? null : Math.round((right - left) / 86_400_000);
|
|
45
|
+
};
|
|
46
|
+
const normalizeText = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
47
|
+
function isRecord(value) {
|
|
48
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
function normalizeItem(raw, index, issues) {
|
|
51
|
+
const id = raw.id?.trim() || `item-${index + 1}`;
|
|
52
|
+
let dataFields = 0;
|
|
53
|
+
if (raw.cost !== undefined)
|
|
54
|
+
dataFields += 1;
|
|
55
|
+
if (raw.quantity !== undefined)
|
|
56
|
+
dataFields += 1;
|
|
57
|
+
if (raw.acquired !== undefined)
|
|
58
|
+
dataFields += 1;
|
|
59
|
+
if (raw.expires !== undefined)
|
|
60
|
+
dataFields += 1;
|
|
61
|
+
if (raw.lastUsed !== undefined)
|
|
62
|
+
dataFields += 1;
|
|
63
|
+
if (raw.condition !== undefined)
|
|
64
|
+
dataFields += 1;
|
|
65
|
+
if (raw.tags !== undefined)
|
|
66
|
+
dataFields += 1;
|
|
67
|
+
if (raw.notes !== undefined)
|
|
68
|
+
dataFields += 1;
|
|
69
|
+
const checkDate = (key) => {
|
|
70
|
+
const value = raw[key];
|
|
71
|
+
if (!value)
|
|
72
|
+
return null;
|
|
73
|
+
if (parseDate(value) === null) {
|
|
74
|
+
issues.push({
|
|
75
|
+
code: `invalid_${key}`,
|
|
76
|
+
severity: "warning",
|
|
77
|
+
message: `${id} has an invalid ${key} date; expected YYYY-MM-DD.`,
|
|
78
|
+
itemId: id,
|
|
79
|
+
});
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
id,
|
|
86
|
+
name: raw.name.trim(),
|
|
87
|
+
category: raw.category.trim(),
|
|
88
|
+
tags: [...new Set((raw.tags ?? []).map((tag) => tag.trim()).filter(Boolean))],
|
|
89
|
+
cost: raw.cost ?? null,
|
|
90
|
+
quantity: raw.quantity ?? null,
|
|
91
|
+
acquired: checkDate("acquired"),
|
|
92
|
+
expires: checkDate("expires"),
|
|
93
|
+
lastUsed: checkDate("lastUsed"),
|
|
94
|
+
condition: raw.condition ?? "good",
|
|
95
|
+
notes: raw.notes?.trim() || null,
|
|
96
|
+
dataFields,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export async function loadCollection(input) {
|
|
100
|
+
if (input.items !== undefined && (input.content !== undefined || input.path !== undefined)) {
|
|
101
|
+
throw new ToolError("Use one source: items, content, or path.");
|
|
102
|
+
}
|
|
103
|
+
if (input.content !== undefined && input.path !== undefined) {
|
|
104
|
+
throw new ToolError("Use content or path, not both.");
|
|
105
|
+
}
|
|
106
|
+
let raw;
|
|
107
|
+
let source = "inline";
|
|
108
|
+
if (input.items !== undefined) {
|
|
109
|
+
raw = input.items;
|
|
110
|
+
}
|
|
111
|
+
else if (input.content !== undefined) {
|
|
112
|
+
if (input.content.length > MAX_INPUT)
|
|
113
|
+
throw new ToolError(`Input is larger than ${MAX_INPUT} characters.`);
|
|
114
|
+
try {
|
|
115
|
+
raw = JSON.parse(input.content);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new ToolError("Content is not valid JSON.");
|
|
119
|
+
}
|
|
120
|
+
source = "content";
|
|
121
|
+
}
|
|
122
|
+
else if (input.path) {
|
|
123
|
+
if (input.path.includes("\0"))
|
|
124
|
+
throw new ToolError("Path contains a null byte.");
|
|
125
|
+
const file = path.isAbsolute(input.path)
|
|
126
|
+
? input.path
|
|
127
|
+
: path.resolve(input.cwd ? path.resolve(input.cwd) : process.cwd(), input.path);
|
|
128
|
+
const stat = await fs.stat(file);
|
|
129
|
+
if (!stat.isFile())
|
|
130
|
+
throw new ToolError(`Not a file: ${file}`);
|
|
131
|
+
if (stat.size > MAX_INPUT)
|
|
132
|
+
throw new ToolError(`File is larger than ${MAX_INPUT} bytes.`);
|
|
133
|
+
const body = await fs.readFile(file, "utf8");
|
|
134
|
+
try {
|
|
135
|
+
raw = JSON.parse(body);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
throw new ToolError(`File is not valid JSON: ${file}`);
|
|
139
|
+
}
|
|
140
|
+
source = file;
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
throw new ToolError("Provide items, content, or path.");
|
|
144
|
+
}
|
|
145
|
+
const rawItems = Array.isArray(raw)
|
|
146
|
+
? raw
|
|
147
|
+
: isRecord(raw) && Array.isArray(raw.items)
|
|
148
|
+
? raw.items
|
|
149
|
+
: null;
|
|
150
|
+
if (!rawItems)
|
|
151
|
+
throw new ToolError("Collection JSON must be an array or an object with an items array.");
|
|
152
|
+
if (rawItems.length > MAX_ITEMS)
|
|
153
|
+
throw new ToolError(`Collection has more than ${MAX_ITEMS} items.`);
|
|
154
|
+
const items = [];
|
|
155
|
+
const issues = [];
|
|
156
|
+
rawItems.forEach((value, index) => {
|
|
157
|
+
const parsed = itemSchema.safeParse(value);
|
|
158
|
+
if (!parsed.success) {
|
|
159
|
+
issues.push({
|
|
160
|
+
code: "invalid_item",
|
|
161
|
+
severity: "error",
|
|
162
|
+
message: `Item ${index + 1}: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "item"} ${issue.message}`).join("; ")}`,
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
items.push(normalizeItem(parsed.data, index, issues));
|
|
167
|
+
});
|
|
168
|
+
const idCounts = new Map();
|
|
169
|
+
for (const item of items)
|
|
170
|
+
idCounts.set(item.id, (idCounts.get(item.id) ?? 0) + 1);
|
|
171
|
+
for (const [id, count] of idCounts) {
|
|
172
|
+
if (count > 1) {
|
|
173
|
+
issues.push({ code: "duplicate_id", severity: "error", message: `Item id ${id} appears ${count} times.`, itemId: id });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const missingCost = items.filter((item) => item.cost === null).length;
|
|
177
|
+
const missingQuantity = items.filter((item) => item.quantity === null).length;
|
|
178
|
+
const missingExpiry = items.filter((item) => item.expires === null).length;
|
|
179
|
+
if (missingCost)
|
|
180
|
+
issues.push({ code: "missing_cost", severity: "warning", message: `${missingCost} item(s) have no cost.` });
|
|
181
|
+
if (missingQuantity)
|
|
182
|
+
issues.push({ code: "missing_quantity", severity: "warning", message: `${missingQuantity} item(s) have no quantity.` });
|
|
183
|
+
if (missingExpiry)
|
|
184
|
+
issues.push({ code: "missing_expiry", severity: "warning", message: `${missingExpiry} item(s) have no expiry date.` });
|
|
185
|
+
if (items.length === 0)
|
|
186
|
+
issues.push({ code: "empty_collection", severity: "error", message: "No valid items were loaded." });
|
|
187
|
+
return { items, issues, source, rawCount: rawItems.length };
|
|
188
|
+
}
|
|
189
|
+
export function normalizeTarget(input) {
|
|
190
|
+
return {
|
|
191
|
+
maxItems: input?.maxItems ?? config.target.maxItems,
|
|
192
|
+
budget: input?.budget ?? config.target.budget,
|
|
193
|
+
coverage: input?.coverage ?? config.target.coverage,
|
|
194
|
+
expiryDays: input?.expiryDays ?? config.target.expiryDays,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function metricValue(item, tagCounts) {
|
|
198
|
+
switch (config.metric.id) {
|
|
199
|
+
case "cost_per_use":
|
|
200
|
+
return item.cost !== null && item.quantity !== null ? item.cost / Math.max(1, item.quantity) : null;
|
|
201
|
+
case "days_to_expiry":
|
|
202
|
+
return item.expires ? daysBetween(today(), item.expires) : null;
|
|
203
|
+
case "active_overlap":
|
|
204
|
+
return item.tags.filter((tag) => (tagCounts.get(tag) ?? 0) > 1).length;
|
|
205
|
+
case "clearance_risk":
|
|
206
|
+
return item.tags.includes("denied") ? 5 : item.tags.includes("pending") ? 3 : item.tags.includes("cleared") ? 0 : 2;
|
|
207
|
+
case "dtc_severity":
|
|
208
|
+
return item.tags.includes("critical") ? 5 : item.tags.includes("warning") ? 3 : item.tags.includes("info") ? 1 : 2;
|
|
209
|
+
case "battery_health":
|
|
210
|
+
case "germination_rate":
|
|
211
|
+
case "contrast_ratio":
|
|
212
|
+
case "paint_liters":
|
|
213
|
+
case "service_gap":
|
|
214
|
+
return item.quantity;
|
|
215
|
+
default:
|
|
216
|
+
return item.quantity;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function metricPass(value) {
|
|
220
|
+
return config.metric.direction === "lower" ? value <= config.metric.target : value >= config.metric.target;
|
|
221
|
+
}
|
|
222
|
+
function conditionScore(condition) {
|
|
223
|
+
switch (condition) {
|
|
224
|
+
case "new":
|
|
225
|
+
return 1;
|
|
226
|
+
case "good":
|
|
227
|
+
return 0.8;
|
|
228
|
+
case "fair":
|
|
229
|
+
return 0.5;
|
|
230
|
+
case "worn":
|
|
231
|
+
return 0.2;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function findDuplicates(items) {
|
|
235
|
+
const groups = new Map();
|
|
236
|
+
for (const item of items) {
|
|
237
|
+
const key = `${item.category}|${normalizeText(item.name)}`;
|
|
238
|
+
const group = groups.get(key) ?? [];
|
|
239
|
+
group.push(item);
|
|
240
|
+
groups.set(key, group);
|
|
241
|
+
}
|
|
242
|
+
return [...groups.entries()]
|
|
243
|
+
.filter(([, group]) => group.length > 1)
|
|
244
|
+
.map(([key, group]) => ({
|
|
245
|
+
key,
|
|
246
|
+
count: group.length,
|
|
247
|
+
ids: group.map((item) => item.id),
|
|
248
|
+
names: group.map((item) => item.name),
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
function rankFixes(candidates) {
|
|
252
|
+
return candidates
|
|
253
|
+
.map((fix) => ({ ...fix, score: round(fix.impact / Math.max(1, fix.effort), 2) }))
|
|
254
|
+
.sort((left, right) => right.score - left.score || right.impact - left.impact);
|
|
255
|
+
}
|
|
256
|
+
export function analyze(loaded, targetInput) {
|
|
257
|
+
const target = targetInput ?? normalizeTarget();
|
|
258
|
+
const items = loaded.items;
|
|
259
|
+
const categoryCounts = {};
|
|
260
|
+
for (const category of config.categories)
|
|
261
|
+
categoryCounts[category.id] = 0;
|
|
262
|
+
for (const item of items)
|
|
263
|
+
categoryCounts[item.category] = (categoryCounts[item.category] ?? 0) + 1;
|
|
264
|
+
const unknownCategories = [...new Set(items.map((item) => item.category))].filter((category) => !config.categories.some((known) => known.id === category));
|
|
265
|
+
const totalCost = round(sum(items.map((item) => item.cost ?? 0)));
|
|
266
|
+
const knownCostCount = items.filter((item) => item.cost !== null).length;
|
|
267
|
+
const totalQuantity = round(sum(items.map((item) => item.quantity ?? 0)));
|
|
268
|
+
const knownQuantityCount = items.filter((item) => item.quantity !== null).length;
|
|
269
|
+
const current = today();
|
|
270
|
+
const expired = items.filter((item) => item.expires !== null && (daysBetween(current, item.expires) ?? 0) < 0);
|
|
271
|
+
const expiring = items.filter((item) => {
|
|
272
|
+
const days = item.expires ? daysBetween(current, item.expires) : null;
|
|
273
|
+
return days !== null && days >= 0 && days <= target.expiryDays;
|
|
274
|
+
});
|
|
275
|
+
const tagCounts = new Map();
|
|
276
|
+
for (const item of items)
|
|
277
|
+
for (const tag of item.tags)
|
|
278
|
+
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
|
|
279
|
+
const metricRows = items.map((item) => ({ id: item.id, name: item.name, value: metricValue(item, tagCounts) }));
|
|
280
|
+
const metricValues = metricRows.map((row) => row.value).filter((value) => value !== null);
|
|
281
|
+
const metricMean = round(average(metricValues));
|
|
282
|
+
const metricPassCount = metricValues.filter(metricPass).length;
|
|
283
|
+
const metricKnownCount = metricValues.length;
|
|
284
|
+
const categoryCoverage = Object.fromEntries(config.categories.map((category) => [
|
|
285
|
+
category.id,
|
|
286
|
+
category.baseline === 0 ? 1 : Math.min(1, (categoryCounts[category.id] ?? 0) / category.baseline),
|
|
287
|
+
]));
|
|
288
|
+
const coverageScore = average(Object.values(categoryCoverage));
|
|
289
|
+
const conditionAvg = average(items.map((item) => conditionScore(item.condition)));
|
|
290
|
+
const dataCompleteness = average(items.map((item) => item.dataFields / 8));
|
|
291
|
+
const expiryScore = expired.length + expiring.length === 0
|
|
292
|
+
? 1
|
|
293
|
+
: Math.max(0, 1 - (expired.length + expiring.length * 0.5) / Math.max(1, items.filter((item) => item.expires).length));
|
|
294
|
+
const metricScore = metricKnownCount === 0 ? 0 : metricPassCount / metricKnownCount;
|
|
295
|
+
const readiness = Math.round(100 * (0.45 * coverageScore + 0.2 * conditionAvg + 0.15 * dataCompleteness + 0.1 * expiryScore + 0.1 * metricScore));
|
|
296
|
+
const confidence = items.length === 0
|
|
297
|
+
? 0
|
|
298
|
+
: Math.round(100 *
|
|
299
|
+
(0.5 * dataCompleteness +
|
|
300
|
+
0.2 * Math.min(1, items.length / 20) +
|
|
301
|
+
0.15 * (knownCostCount / items.length) +
|
|
302
|
+
0.15 * (knownQuantityCount / items.length)));
|
|
303
|
+
const gaps = [];
|
|
304
|
+
for (const category of config.categories) {
|
|
305
|
+
const missing = Math.max(0, category.baseline - (categoryCounts[category.id] ?? 0));
|
|
306
|
+
if (missing === 0)
|
|
307
|
+
continue;
|
|
308
|
+
gaps.push({
|
|
309
|
+
type: "category",
|
|
310
|
+
key: category.id,
|
|
311
|
+
missing,
|
|
312
|
+
severity: missing >= category.baseline ? "high" : "medium",
|
|
313
|
+
message: `Add ${missing} ${category.label.toLowerCase()} item(s) to reach the ${category.baseline}-item baseline.`,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (expired.length) {
|
|
317
|
+
gaps.push({
|
|
318
|
+
type: "expiry",
|
|
319
|
+
key: "expired",
|
|
320
|
+
missing: expired.length,
|
|
321
|
+
severity: "high",
|
|
322
|
+
message: `${expired.length} item(s) are past their expiry date.`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (expiring.length) {
|
|
326
|
+
gaps.push({
|
|
327
|
+
type: "expiry",
|
|
328
|
+
key: "expiring",
|
|
329
|
+
missing: expiring.length,
|
|
330
|
+
severity: "medium",
|
|
331
|
+
message: `${expiring.length} item(s) expire within ${target.expiryDays} days.`,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
for (const dependency of config.dependencies) {
|
|
335
|
+
if (dependency.categories.some((category) => (categoryCounts[category] ?? 0) > 0))
|
|
336
|
+
continue;
|
|
337
|
+
gaps.push({
|
|
338
|
+
type: "dependency",
|
|
339
|
+
key: dependency.id,
|
|
340
|
+
missing: 1,
|
|
341
|
+
severity: "medium",
|
|
342
|
+
message: `No item covers the ${dependency.label.toLowerCase()} dependency.`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
const duplicates = findDuplicates(items);
|
|
346
|
+
const risks = [];
|
|
347
|
+
if (items.length === 0)
|
|
348
|
+
risks.push({ code: "empty", severity: "critical", message: "No items are loaded.", impact: 5 });
|
|
349
|
+
if (coverageScore < target.coverage) {
|
|
350
|
+
risks.push({
|
|
351
|
+
code: "low_coverage",
|
|
352
|
+
severity: "high",
|
|
353
|
+
message: `Category coverage is ${Math.round(coverageScore * 100)}%, below ${Math.round(target.coverage * 100)}%.`,
|
|
354
|
+
impact: 4,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
if (expired.length)
|
|
358
|
+
risks.push({ code: "expired", severity: "critical", message: `${expired.length} item(s) are expired.`, impact: 5 });
|
|
359
|
+
if (expiring.length)
|
|
360
|
+
risks.push({ code: "expiring", severity: "medium", message: `${expiring.length} item(s) expire soon.`, impact: 3 });
|
|
361
|
+
if (target.budget !== null && totalCost > target.budget) {
|
|
362
|
+
risks.push({ code: "over_budget", severity: "high", message: `Cost ${totalCost} exceeds budget ${target.budget}.`, impact: 4 });
|
|
363
|
+
}
|
|
364
|
+
if (items.length > target.maxItems) {
|
|
365
|
+
risks.push({ code: "over_max", severity: "medium", message: `${items.length} items exceed the ${target.maxItems}-item cap.`, impact: 3 });
|
|
366
|
+
}
|
|
367
|
+
const worn = items.filter((item) => item.condition === "worn");
|
|
368
|
+
if (worn.length)
|
|
369
|
+
risks.push({ code: "worn", severity: "medium", message: `${worn.length} item(s) are worn.`, impact: 3 });
|
|
370
|
+
if (duplicates.length)
|
|
371
|
+
risks.push({ code: "duplicates", severity: "low", message: `${duplicates.length} duplicate group(s) found.`, impact: 2 });
|
|
372
|
+
if (dataCompleteness < 0.7)
|
|
373
|
+
risks.push({ code: "thin_data", severity: "medium", message: "Cost, quantity, or date data is incomplete.", impact: 3 });
|
|
374
|
+
if (metricKnownCount > 0 && metricPassCount < metricKnownCount) {
|
|
375
|
+
risks.push({
|
|
376
|
+
code: "metric_miss",
|
|
377
|
+
severity: "medium",
|
|
378
|
+
message: `${metricKnownCount - metricPassCount} item(s) miss the ${config.metric.label.toLowerCase()} target.`,
|
|
379
|
+
impact: 3,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
if (unknownCategories.length) {
|
|
383
|
+
risks.push({
|
|
384
|
+
code: "unknown_categories",
|
|
385
|
+
severity: "low",
|
|
386
|
+
message: `Unknown categories: ${unknownCategories.join(", ")}.`,
|
|
387
|
+
impact: 2,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
const constraints = [
|
|
391
|
+
{
|
|
392
|
+
key: "max_items",
|
|
393
|
+
label: "Item count",
|
|
394
|
+
pass: items.length <= target.maxItems,
|
|
395
|
+
actual: items.length,
|
|
396
|
+
target: target.maxItems,
|
|
397
|
+
message: items.length <= target.maxItems ? "Within the item cap." : "Over the item cap.",
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
key: "budget",
|
|
401
|
+
label: "Budget",
|
|
402
|
+
pass: target.budget === null || totalCost <= target.budget,
|
|
403
|
+
actual: totalCost,
|
|
404
|
+
target: target.budget,
|
|
405
|
+
message: target.budget === null ? "No budget set." : totalCost <= target.budget ? "Within budget." : "Over budget.",
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
key: "coverage",
|
|
409
|
+
label: "Category coverage",
|
|
410
|
+
pass: coverageScore >= target.coverage,
|
|
411
|
+
actual: round(coverageScore, 3),
|
|
412
|
+
target: target.coverage,
|
|
413
|
+
message: coverageScore >= target.coverage ? "Coverage target met." : "Coverage target missed.",
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
key: "expiry",
|
|
417
|
+
label: "Expiry",
|
|
418
|
+
pass: expired.length === 0,
|
|
419
|
+
actual: expired.length,
|
|
420
|
+
target: 0,
|
|
421
|
+
message: expired.length === 0 ? "No expired items." : `${expired.length} expired item(s).`,
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
key: "metric",
|
|
425
|
+
label: config.metric.label,
|
|
426
|
+
pass: metricKnownCount === 0 || metricPassCount === metricKnownCount,
|
|
427
|
+
actual: { known: metricKnownCount, passing: metricPassCount, mean: metricMean },
|
|
428
|
+
target: config.metric.target,
|
|
429
|
+
message: metricKnownCount === 0 ? "No metric data." : `${metricPassCount}/${metricKnownCount} item(s) meet the target.`,
|
|
430
|
+
},
|
|
431
|
+
];
|
|
432
|
+
const metricVariance = metricValues.length
|
|
433
|
+
? average(metricValues.map((value) => (value - average(metricValues)) ** 2))
|
|
434
|
+
: 0;
|
|
435
|
+
const categoryValues = config.categories.map((category) => categoryCounts[category.id] ?? 0);
|
|
436
|
+
const categoryMean = average(categoryValues);
|
|
437
|
+
const categoryVariance = average(categoryValues.map((value) => (value - categoryMean) ** 2));
|
|
438
|
+
const uniqueTags = new Set(items.flatMap((item) => item.tags)).size;
|
|
439
|
+
const variance = {
|
|
440
|
+
metric: {
|
|
441
|
+
count: metricValues.length,
|
|
442
|
+
mean: metricMean,
|
|
443
|
+
variance: round(metricVariance),
|
|
444
|
+
stddev: round(Math.sqrt(metricVariance)),
|
|
445
|
+
min: metricValues.length ? round(Math.min(...metricValues)) : 0,
|
|
446
|
+
max: metricValues.length ? round(Math.max(...metricValues)) : 0,
|
|
447
|
+
},
|
|
448
|
+
categories: { mean: round(categoryMean), variance: round(categoryVariance) },
|
|
449
|
+
tagDiversity: items.length ? round(uniqueTags / items.length, 3) : 0,
|
|
450
|
+
};
|
|
451
|
+
const benchmarkRows = [
|
|
452
|
+
{
|
|
453
|
+
metric: "item_count",
|
|
454
|
+
value: items.length,
|
|
455
|
+
target: `${Math.ceil(target.maxItems * 0.8)}-${target.maxItems}`,
|
|
456
|
+
pass: items.length >= Math.ceil(target.maxItems * 0.8) && items.length <= target.maxItems,
|
|
457
|
+
},
|
|
458
|
+
{ metric: "readiness", value: readiness, target: ">=70", pass: readiness >= 70 },
|
|
459
|
+
{ metric: "coverage", value: Math.round(coverageScore * 100), target: `>=${Math.round(target.coverage * 100)}`, pass: coverageScore >= target.coverage },
|
|
460
|
+
{ metric: config.metric.id, value: metricMean, target: `${config.metric.direction === "lower" ? "<=" : ">="}${config.metric.target}`, pass: metricKnownCount > 0 && metricPassCount === metricKnownCount },
|
|
461
|
+
{ metric: "data_completeness", value: Math.round(dataCompleteness * 100), target: ">=80", pass: dataCompleteness >= 0.8 },
|
|
462
|
+
];
|
|
463
|
+
const benchmarkPassed = benchmarkRows.filter((row) => row.pass).length;
|
|
464
|
+
const benchmark = {
|
|
465
|
+
rows: benchmarkRows,
|
|
466
|
+
passed: benchmarkPassed,
|
|
467
|
+
total: benchmarkRows.length,
|
|
468
|
+
verdict: (benchmarkPassed >= 4 ? "strong" : benchmarkPassed >= 2 ? "mixed" : "thin"),
|
|
469
|
+
};
|
|
470
|
+
const options = [
|
|
471
|
+
{ name: "lean", maxItems: Math.max(1, Math.round(target.maxItems * 0.7)) },
|
|
472
|
+
{ name: "balanced", maxItems: target.maxItems },
|
|
473
|
+
{ name: "full", maxItems: Math.round(target.maxItems * 1.3) },
|
|
474
|
+
].map((option) => {
|
|
475
|
+
const scale = option.maxItems / Math.max(1, target.maxItems);
|
|
476
|
+
const expected = Object.fromEntries(config.categories.map((category) => [category.id, Math.round(category.baseline * scale)]));
|
|
477
|
+
const fit = Math.round(100 *
|
|
478
|
+
average(config.categories.map((category) => expected[category.id] === 0 ? 1 : Math.min(1, (categoryCounts[category.id] ?? 0) / expected[category.id]))));
|
|
479
|
+
return { ...option, expected, fit };
|
|
480
|
+
});
|
|
481
|
+
const tradeoffs = [
|
|
482
|
+
{
|
|
483
|
+
title: "Collection size vs coverage",
|
|
484
|
+
current: `${items.length} items`,
|
|
485
|
+
note: items.length < Math.ceil(target.maxItems * 0.8)
|
|
486
|
+
? "Small collection. Fill the baseline gaps before adding extras."
|
|
487
|
+
: items.length > target.maxItems
|
|
488
|
+
? "Large collection. Remove low-use items to sharpen the set."
|
|
489
|
+
: "Balanced size. Replace rather than add.",
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
title: "Cost vs use",
|
|
493
|
+
current: `${metricMean} mean ${config.metric.label.toLowerCase()}`,
|
|
494
|
+
note: metricKnownCount > 0 && metricPassCount === metricKnownCount
|
|
495
|
+
? "All measured items meet the target."
|
|
496
|
+
: "Some items miss the target; use them more or retire them.",
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
title: "Freshness vs stock",
|
|
500
|
+
current: `${expired.length} expired, ${expiring.length} expiring`,
|
|
501
|
+
note: expired.length ? "Clear expired items before buying more." : "No expired items; keep the expiry window visible.",
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
title: "Data quality vs speed",
|
|
505
|
+
current: `${Math.round(dataCompleteness * 100)}% complete`,
|
|
506
|
+
note: dataCompleteness < 0.8 ? "Fill cost, quantity, and dates before trusting the score." : "Data is strong enough for decisions.",
|
|
507
|
+
},
|
|
508
|
+
];
|
|
509
|
+
const checklistItems = [
|
|
510
|
+
...gaps.slice(0, 10).map((gap) => gap.message),
|
|
511
|
+
...risks.filter((risk) => risk.severity === "critical" || risk.severity === "high").slice(0, 6).map((risk) => risk.message),
|
|
512
|
+
...(target.budget === null ? ["Set a total budget."] : []),
|
|
513
|
+
...(dataCompleteness < 0.8 ? ["Fill missing cost, quantity, and date fields."] : []),
|
|
514
|
+
];
|
|
515
|
+
const checklist = [...new Set(checklistItems)].map((item) => ({ item, done: false }));
|
|
516
|
+
const quickCandidates = [
|
|
517
|
+
...gaps.filter((gap) => gap.missing <= 2).map((gap) => ({
|
|
518
|
+
action: gap.message,
|
|
519
|
+
impact: gap.severity === "high" ? 5 : 3,
|
|
520
|
+
effort: 1,
|
|
521
|
+
source: "gap",
|
|
522
|
+
})),
|
|
523
|
+
...loaded.issues.filter((issue) => issue.severity === "warning").slice(0, 8).map((issue) => ({
|
|
524
|
+
action: issue.message,
|
|
525
|
+
impact: 2,
|
|
526
|
+
effort: 1,
|
|
527
|
+
source: "data",
|
|
528
|
+
})),
|
|
529
|
+
...expired.slice(0, 5).map((item) => ({ action: `Remove or replace ${item.name}.`, impact: 5, effort: 1, source: "expiry" })),
|
|
530
|
+
...duplicates.slice(0, 5).map((group) => ({
|
|
531
|
+
action: `Remove or consolidate duplicate ${group.names[0]}.`,
|
|
532
|
+
impact: 3,
|
|
533
|
+
effort: 1,
|
|
534
|
+
source: "duplicate",
|
|
535
|
+
})),
|
|
536
|
+
];
|
|
537
|
+
const quickWins = rankFixes(quickCandidates).slice(0, 8);
|
|
538
|
+
const fixCandidates = [
|
|
539
|
+
...gaps.map((gap) => ({
|
|
540
|
+
action: gap.message,
|
|
541
|
+
impact: gap.severity === "high" ? 5 : gap.severity === "medium" ? 3 : 2,
|
|
542
|
+
effort: 2,
|
|
543
|
+
source: "gap",
|
|
544
|
+
})),
|
|
545
|
+
...risks.map((risk) => ({
|
|
546
|
+
action: risk.message,
|
|
547
|
+
impact: risk.impact,
|
|
548
|
+
effort: risk.severity === "critical" ? 1 : 2,
|
|
549
|
+
source: "risk",
|
|
550
|
+
})),
|
|
551
|
+
...loaded.issues.filter((issue) => issue.severity === "error").map((issue) => ({
|
|
552
|
+
action: issue.message,
|
|
553
|
+
impact: 4,
|
|
554
|
+
effort: 1,
|
|
555
|
+
source: "data-error",
|
|
556
|
+
})),
|
|
557
|
+
];
|
|
558
|
+
const fixes = rankFixes(fixCandidates).slice(0, 12);
|
|
559
|
+
const nextSteps = fixes.slice(0, 5);
|
|
560
|
+
const questions = [];
|
|
561
|
+
if (items.length === 0)
|
|
562
|
+
questions.push("Do you have a collection list to import?");
|
|
563
|
+
if (target.budget === null)
|
|
564
|
+
questions.push("What total budget should this collection stay under?");
|
|
565
|
+
if (dataCompleteness < 0.8)
|
|
566
|
+
questions.push("Can you fill in cost, quantity, and date fields?");
|
|
567
|
+
if (expired.length || expiring.length)
|
|
568
|
+
questions.push("Which expired or expiring items should be replaced first?");
|
|
569
|
+
if (duplicates.length)
|
|
570
|
+
questions.push("Which duplicate items can be removed?");
|
|
571
|
+
if (unknownCategories.length)
|
|
572
|
+
questions.push(`Should these categories be added: ${unknownCategories.join(", ")}?`);
|
|
573
|
+
if (questions.length === 0)
|
|
574
|
+
questions.push(`What is the one ${config.entity} job you need this collection to handle first?`);
|
|
575
|
+
const edgeCases = [];
|
|
576
|
+
if (items.length === 0)
|
|
577
|
+
edgeCases.push("Empty collection.");
|
|
578
|
+
if (items.length === 1)
|
|
579
|
+
edgeCases.push("Single item; coverage is not meaningful yet.");
|
|
580
|
+
if (new Set(items.map((item) => item.id)).size !== items.length)
|
|
581
|
+
edgeCases.push("Duplicate item ids.");
|
|
582
|
+
if (items.some((item) => item.cost === 0))
|
|
583
|
+
edgeCases.push("Zero-cost item.");
|
|
584
|
+
if (expired.length)
|
|
585
|
+
edgeCases.push("Expired items present.");
|
|
586
|
+
if (items.every((item) => item.expires === null))
|
|
587
|
+
edgeCases.push("No expiry dates; freshness is unknown.");
|
|
588
|
+
if (unknownCategories.length)
|
|
589
|
+
edgeCases.push("Unknown categories present.");
|
|
590
|
+
return {
|
|
591
|
+
target,
|
|
592
|
+
itemCount: items.length,
|
|
593
|
+
items,
|
|
594
|
+
categoryCounts,
|
|
595
|
+
unknownCategories,
|
|
596
|
+
totalCost,
|
|
597
|
+
knownCostCount,
|
|
598
|
+
totalQuantity,
|
|
599
|
+
knownQuantityCount,
|
|
600
|
+
expired,
|
|
601
|
+
expiring,
|
|
602
|
+
metricRows,
|
|
603
|
+
metricMean,
|
|
604
|
+
metricPassCount,
|
|
605
|
+
metricKnownCount,
|
|
606
|
+
categoryCoverage,
|
|
607
|
+
coverageScore,
|
|
608
|
+
conditionScore: conditionAvg,
|
|
609
|
+
dataCompleteness,
|
|
610
|
+
readiness,
|
|
611
|
+
confidence,
|
|
612
|
+
gaps,
|
|
613
|
+
risks,
|
|
614
|
+
constraints,
|
|
615
|
+
duplicates,
|
|
616
|
+
variance,
|
|
617
|
+
benchmark,
|
|
618
|
+
options,
|
|
619
|
+
tradeoffs,
|
|
620
|
+
checklist,
|
|
621
|
+
quickWins,
|
|
622
|
+
fixes,
|
|
623
|
+
nextSteps,
|
|
624
|
+
questions,
|
|
625
|
+
edgeCases,
|
|
626
|
+
dataIssues: loaded.issues,
|
|
627
|
+
summary: {
|
|
628
|
+
items: items.length,
|
|
629
|
+
readiness,
|
|
630
|
+
confidence,
|
|
631
|
+
totalCost,
|
|
632
|
+
metricMean,
|
|
633
|
+
gaps: gaps.length,
|
|
634
|
+
risks: risks.length,
|
|
635
|
+
duplicates: duplicates.length,
|
|
636
|
+
dataIssues: loaded.issues.length,
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
export function changeSet(before, after) {
|
|
641
|
+
const beforeMap = new Map(before.items.map((item) => [item.id, item]));
|
|
642
|
+
const afterMap = new Map(after.items.map((item) => [item.id, item]));
|
|
643
|
+
const added = after.items.filter((item) => !beforeMap.has(item.id));
|
|
644
|
+
const removed = before.items.filter((item) => !afterMap.has(item.id));
|
|
645
|
+
const changed = after.items
|
|
646
|
+
.filter((item) => beforeMap.has(item.id) && JSON.stringify(beforeMap.get(item.id)) !== JSON.stringify(item))
|
|
647
|
+
.map((item) => ({ before: beforeMap.get(item.id), after: item }));
|
|
648
|
+
return {
|
|
649
|
+
counts: {
|
|
650
|
+
before: before.items.length,
|
|
651
|
+
after: after.items.length,
|
|
652
|
+
added: added.length,
|
|
653
|
+
removed: removed.length,
|
|
654
|
+
changed: changed.length,
|
|
655
|
+
},
|
|
656
|
+
added,
|
|
657
|
+
removed,
|
|
658
|
+
changed,
|
|
659
|
+
};
|
|
660
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { createServer } from "./server.js";
|
|
4
|
+
async function main() {
|
|
5
|
+
const server = createServer();
|
|
6
|
+
const transport = new StdioServerTransport();
|
|
7
|
+
await server.connect(transport);
|
|
8
|
+
}
|
|
9
|
+
main().catch((error) => {
|
|
10
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
11
|
+
process.stderr.write(`${message}\n`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|