kiku-agent 1.3.1 → 1.3.2

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 (119) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -12
  3. package/server/agent/eval/AGENTS.md +228 -0
  4. package/server/agent/eval/analyze.py +684 -0
  5. package/server/agent/eval/gates.json +15 -0
  6. package/server/agent/eval/modules/analysis/compare.js +198 -0
  7. package/server/agent/eval/modules/analysis/gates.js +72 -0
  8. package/server/agent/eval/modules/analysis/judge.js +70 -0
  9. package/server/agent/eval/modules/core/cli.js +453 -0
  10. package/server/agent/eval/modules/core/config.js +60 -0
  11. package/server/agent/eval/modules/core/store.js +236 -0
  12. package/server/agent/eval/modules/evaluation/cases.js +689 -0
  13. package/server/agent/eval/modules/evaluation/runner.js +298 -0
  14. package/server/agent/eval/modules/evaluation/scoring.js +351 -0
  15. package/server/agent/eval/modules/evaluation/worker.js +122 -0
  16. package/server/agent/eval/modules/reporting/benchmark.js +265 -0
  17. package/server/agent/eval/modules/reporting/benchmarkPng.js +487 -0
  18. package/server/agent/eval/modules/reporting/dashboard.js +1406 -0
  19. package/server/agent/eval/modules/server/evalApi.js +1099 -0
  20. package/server/agent/eval/modules/ui/evalUi.js +4796 -0
  21. package/server/agent/eval/modules/utils/smokes.js +119 -0
  22. package/server/agent/tests/agentBubbleSystem.test.js +65 -0
  23. package/server/agent/tests/agentFollowUps.test.js +127 -0
  24. package/server/agent/tests/apiRouter.test.js +108 -0
  25. package/server/agent/tests/architectureFixes.test.js +81 -0
  26. package/server/agent/tests/architectureRefactoring.test.js +194 -0
  27. package/server/agent/tests/autoResearch.test.js +64 -0
  28. package/server/agent/tests/bilibiliEmbed.test.js +251 -0
  29. package/server/agent/tests/catalogSync.test.js +130 -0
  30. package/server/agent/tests/citations.test.js +180 -0
  31. package/server/agent/tests/clarificationAnalytics.test.js +219 -0
  32. package/server/agent/tests/clarifyingQuestions.test.js +594 -0
  33. package/server/agent/tests/cliAutomation.test.js +121 -0
  34. package/server/agent/tests/cloudProviders.test.js +234 -0
  35. package/server/agent/tests/commandInput.test.js +24 -0
  36. package/server/agent/tests/commands.test.js +417 -0
  37. package/server/agent/tests/contextIntelligence.test.js +162 -0
  38. package/server/agent/tests/conversationSafety.test.js +53 -0
  39. package/server/agent/tests/decisionAndSearchEnhancements.test.js +135 -0
  40. package/server/agent/tests/dramaAnswerQuality.test.js +73 -0
  41. package/server/agent/tests/dynamicCatalogIngestion.test.js +285 -0
  42. package/server/agent/tests/dynamicRoutingAndSearch.test.js +92 -0
  43. package/server/agent/tests/edgeCasesScenarios.test.js +49 -0
  44. package/server/agent/tests/entertainmentMetrics.test.js +126 -0
  45. package/server/agent/tests/envBootstrap.test.js +87 -0
  46. package/server/agent/tests/envCommand.test.js +215 -0
  47. package/server/agent/tests/errorHandling.test.js +80 -0
  48. package/server/agent/tests/errors.test.js +13 -0
  49. package/server/agent/tests/evalAnalyze.test.js +54 -0
  50. package/server/agent/tests/evalCommand.test.js +54 -0
  51. package/server/agent/tests/evalCorpus.test.js +126 -0
  52. package/server/agent/tests/evalHarness.test.js +62 -0
  53. package/server/agent/tests/evalJudgeContext.test.js +38 -0
  54. package/server/agent/tests/evalMetrics.test.js +312 -0
  55. package/server/agent/tests/evalStore.test.js +190 -0
  56. package/server/agent/tests/evalStudio.test.js +179 -0
  57. package/server/agent/tests/feedbackLearning.test.js +186 -0
  58. package/server/agent/tests/firstRun.test.js +44 -0
  59. package/server/agent/tests/gemma4eOptimization.test.js +95 -0
  60. package/server/agent/tests/hardwareTiers.test.js +206 -0
  61. package/server/agent/tests/hybridWebSearch.test.js +192 -0
  62. package/server/agent/tests/judgeService.test.js +251 -0
  63. package/server/agent/tests/lanDiscovery.test.js +319 -0
  64. package/server/agent/tests/mcpClient.test.js +201 -0
  65. package/server/agent/tests/modelManager.test.js +109 -0
  66. package/server/agent/tests/multilingualScenarios.test.js +69 -0
  67. package/server/agent/tests/newCommands.test.js +347 -0
  68. package/server/agent/tests/newCommandsTui.test.js +141 -0
  69. package/server/agent/tests/newTools.test.js +113 -0
  70. package/server/agent/tests/newsIntelligence.test.js +178 -0
  71. package/server/agent/tests/nextGenArchitecture.test.js +99 -0
  72. package/server/agent/tests/offlineInput.test.js +217 -0
  73. package/server/agent/tests/packagingGuard.test.js +57 -0
  74. package/server/agent/tests/presentation.test.js +61 -0
  75. package/server/agent/tests/providerPersistence.test.js +39 -0
  76. package/server/agent/tests/queryBenchmarks.test.js +83 -0
  77. package/server/agent/tests/queryDetails.test.js +126 -0
  78. package/server/agent/tests/realWorldValidation.test.js +391 -0
  79. package/server/agent/tests/resilienceAndConcurrency.test.js +117 -0
  80. package/server/agent/tests/runtimeWatchdog.test.js +32 -0
  81. package/server/agent/tests/sanitizer.test.js +37 -0
  82. package/server/agent/tests/scenariosData.js +378 -0
  83. package/server/agent/tests/schemaRouting.test.js +54 -0
  84. package/server/agent/tests/securityAuditRemediation.test.js +214 -0
  85. package/server/agent/tests/securityHardening.test.js +146 -0
  86. package/server/agent/tests/securitySanitizer.test.js +38 -0
  87. package/server/agent/tests/sessionAndCopy.test.js +232 -0
  88. package/server/agent/tests/sidebarControls.test.js +101 -0
  89. package/server/agent/tests/statefulMemory.test.js +270 -0
  90. package/server/agent/tests/subagent.test.js +216 -0
  91. package/server/agent/tests/subagentProgress.test.js +109 -0
  92. package/server/agent/tests/subagentTextFallback.test.js +115 -0
  93. package/server/agent/tests/suggestions.test.js +199 -0
  94. package/server/agent/tests/suggestionsTui.test.js +251 -0
  95. package/server/agent/tests/telemetryAndProfiles.test.js +179 -0
  96. package/server/agent/tests/terminalImage.test.js +80 -0
  97. package/server/agent/tests/terminalMarkdown.test.js +166 -0
  98. package/server/agent/tests/terminalText.test.js +133 -0
  99. package/server/agent/tests/textProtocolLoop.test.js +126 -0
  100. package/server/agent/tests/textToolParsing.test.js +139 -0
  101. package/server/agent/tests/theme.test.js +26 -0
  102. package/server/agent/tests/toolRegistryParity.test.js +53 -0
  103. package/server/agent/tests/toolRoutingRegressions.test.js +119 -0
  104. package/server/agent/tests/tools.test.js +259 -0
  105. package/server/agent/tests/unifiedEndpoints.test.js +152 -0
  106. package/server/agent/tests/v110Features.test.js +262 -0
  107. package/server/agent/tests/versionBump.test.js +69 -0
  108. package/server/agent/tests/vramEstimator.test.js +520 -0
  109. package/server/agent/tests/webChatInspection.test.js +300 -0
  110. package/server/agent/tests/webChatMemory.test.js +112 -0
  111. package/server/agent/tests/webChatSecurity.test.js +169 -0
  112. package/server/agent/tests/webExtract.test.js +261 -0
  113. package/server/agent/tests/webSources.test.js +247 -0
  114. package/server/eval/judgeRubrics.js +110 -0
  115. package/server/eval/judgeService.js +290 -0
  116. package/server/eval/runBenchmark.js +182 -0
  117. package/server/mcp/examples/librechat.example.yaml +16 -0
  118. package/server/mcp/tests/connection.test.js +156 -0
  119. package/server/supabase_schema.sql +63 -0
@@ -0,0 +1,684 @@
1
+ import argparse
2
+ import ast
3
+ import hashlib
4
+ import json
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ from collections import Counter
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+
13
+ EVAL_DIR = Path(__file__).resolve().parent
14
+ DEFAULT_ROOT = EVAL_DIR.parents[2]
15
+ DEFAULT_REPORT = Path("artifacts/eval/live.json")
16
+ DEFAULT_GATES = Path("server/agent/eval/gates.json")
17
+
18
+ SKIP_DIRS = {"node_modules", ".git", "dist", ".vercel", ".next", "coverage", "__pycache__"}
19
+ SKIP_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".woff", ".woff2", ".ttf", ".otf", ".mp3", ".mp4"}
20
+ TEXT_SUFFIXES = {".js", ".jsx", ".ts", ".tsx", ".py", ".json", ".css", ".html", ".md", ".ps1", ".svg"}
21
+
22
+ CATALOG_PAIRS = [
23
+ ("src/data/profileData.js", "server/mcp/data/profile.js"),
24
+ ("src/data/worksData.js", "server/mcp/data/works.js"),
25
+ ("src/data/discographyData.js", "server/mcp/data/discography.js"),
26
+ ("src/data/brandsData.js", "server/mcp/data/brands.js"),
27
+ ("src/data/awardsData.js", "server/mcp/data/awards.js"),
28
+ ("src/data/timelineData.js", "server/mcp/data/timeline.js"),
29
+ ("src/data/galleryData.js", "server/mcp/data/gallery.js"),
30
+ ("src/data/industryIndexData.js", "server/mcp/data/industryIndex.js"),
31
+ ]
32
+
33
+ JS_SIGNAL_PATTERNS = {
34
+ "imports": r"^\s*import\s+.+\s+from\s+['\"]",
35
+ "exports": r"^\s*export\s+(const|let|var|function|class|default|async\s+function|\{)",
36
+ "functions": r"\bfunction\s+\w+\s*\(",
37
+ "arrow_functions": r"=>",
38
+ "classes": r"^\s*(export\s+)?(default\s+)?class\s+\w+",
39
+ "awaits": r"\bawait\s+\w+",
40
+ "fetch_calls": r"\bfetch\s*\(",
41
+ "react_components": r"\bfunction\s+[A-Z]\w*\s*\(",
42
+ }
43
+
44
+ EXPORT_SYMBOL_RE = re.compile(r"export\s+(?:const|let|var|function|async\s+function|class)\s+([A-Za-z_$][\w$]*)")
45
+ EXPORT_LIST_RE = re.compile(r"export\s*\{([^}]*)\}")
46
+
47
+
48
+ def percentile(sorted_values, pct):
49
+ if not sorted_values:
50
+ return None
51
+ if len(sorted_values) == 1:
52
+ return sorted_values[0]
53
+ rank = (pct / 100) * (len(sorted_values) - 1)
54
+ low = int(rank)
55
+ high = min(low + 1, len(sorted_values) - 1)
56
+ frac = rank - low
57
+ return sorted_values[low] + (sorted_values[high] - sorted_values[low]) * frac
58
+
59
+
60
+ def mean(values):
61
+ return round(sum(values) / len(values), 1) if values else None
62
+
63
+
64
+ def mean4(values):
65
+ clean = [v for v in values if isinstance(v, (int, float)) and v == v]
66
+ if not clean:
67
+ return None
68
+ return round(sum(clean) / len(clean), 4)
69
+
70
+
71
+ def binom(n, k):
72
+ n = int(n)
73
+ k = int(k)
74
+ if n < 0 or k < 0 or k > n:
75
+ return 0
76
+ k = min(k, n - k)
77
+ result = 1
78
+ for i in range(1, k + 1):
79
+ result = result * (n - k + i) / i
80
+ return int(round(result))
81
+
82
+
83
+ def traj_eta(l_star, l):
84
+ try:
85
+ a = float(l_star)
86
+ b = float(l)
87
+ except (TypeError, ValueError):
88
+ return None
89
+ if a <= 0 or b <= 0:
90
+ return None
91
+ return round(a / max(b, a), 4)
92
+
93
+
94
+ def estimate_cost(row):
95
+ trace = row.get("trace") or []
96
+ calls = sum(1 for t in trace if isinstance(t, dict) and t.get("event") == "start")
97
+ seconds = 0
98
+ duration = row.get("durationMs")
99
+ if isinstance(duration, (int, float)):
100
+ seconds = max(0, duration) / 1000
101
+ breakdown = row.get("latencyBreakdown") or {}
102
+ output_tokens = breakdown.get("outputTokens")
103
+ if not isinstance(output_tokens, (int, float)):
104
+ answer = row.get("answer")
105
+ output_tokens = round(len(answer) / 4) if isinstance(answer, str) else 0
106
+ cost = output_tokens * 0.00001 + calls * 0.002 + seconds * 0.001
107
+ return round(cost, 5)
108
+
109
+
110
+ def failure_mode(row):
111
+ if not isinstance(row, dict):
112
+ return None
113
+ health = row.get("trajectoryHealth") or {}
114
+ coverage = row.get("coverage") or {}
115
+ healthy = row.get("status") == "completed" and coverage.get("allMatched") is True and health.get("hasLoop") is not True
116
+ if healthy:
117
+ return None
118
+ trace = row.get("trace") or []
119
+ blob = json.dumps({"error": row.get("error"), "trace": trace[:40]}, ensure_ascii=False).lower()
120
+ err_text = str(row.get("error") or "").lower()
121
+ if "timeout" in err_text or "permission" in err_text or "denied" in err_text or "unauthorized" in err_text or "abort" in err_text:
122
+ return "permissionTimeout"
123
+ if row.get("status") == "error" and "timeout" in blob:
124
+ return "permissionTimeout"
125
+ expected = row.get("toolCorrectness") or {}
126
+ exp_list = expected.get("expected") or []
127
+ l_star = len(exp_list) if exp_list else int(coverage.get("total") or 1)
128
+ if l_star < 1:
129
+ l_star = 1
130
+ steps = sum(1 for t in trace if isinstance(t, dict) and t.get("event") == "start")
131
+ loop_count = health.get("loopCount") or 0
132
+ if health.get("isThrashing") is True or loop_count >= 2 or (steps >= 3 and steps > 1.5 * l_star):
133
+ return "replanning"
134
+ schema_hit = "schema" in blob or "invalid" in blob or "validation" in blob or any(isinstance(t, dict) and t.get("event") == "error" for t in trace)
135
+ tool_miss = expected.get("notApplicable") is not True and (float(expected.get("precision") if expected.get("precision") is not None else 1) < 0.5 or len(expected.get("missing") or []) > 0)
136
+ if schema_hit or tool_miss:
137
+ return "toolSchema"
138
+ answer = row.get("answer")
139
+ answer_len = len(answer) if isinstance(answer, str) else 0
140
+ duration = row.get("durationMs") or 0
141
+ if steps > 6 or answer_len > 4000 or duration > 60000:
142
+ return "contextOverflow"
143
+ if row.get("status") == "error":
144
+ return "permissionTimeout"
145
+ if coverage.get("allMatched") is False:
146
+ return "toolSchema"
147
+ return "contextOverflow"
148
+
149
+
150
+ def benchmark_summary(rows):
151
+ completed = [r for r in rows if isinstance(r, dict) and r.get("status") == "completed"]
152
+ coverage_rows = [r for r in completed if isinstance(r.get("coverage"), dict) and (r["coverage"].get("total") or 0) > 0]
153
+ passed = [r for r in coverage_rows if ((r.get("judgeResult", {}).get("score") == 1.0 or r.get("judgeResult", {}).get("verdict") == "PASS") if (r.get("judgeResult") and not r.get("judgeResult", {}).get("judgeError")) else r["coverage"].get("allMatched") is True)]
154
+ pass_at_1 = round(len(passed) / len(coverage_rows), 4) if coverage_rows else None
155
+ etas = []
156
+ for r in completed:
157
+ trace = r.get("trace") or []
158
+ steps = sum(1 for t in trace if isinstance(t, dict) and t.get("event") == "start")
159
+ if not steps:
160
+ steps = 1 if r.get("status") == "completed" else 0
161
+ if not steps:
162
+ continue
163
+ tc = r.get("toolCorrectness") or {}
164
+ exp = tc.get("expected") or []
165
+ cov = r.get("coverage") or {}
166
+ l_star = len(exp) if exp else int(cov.get("total") or 1)
167
+ if l_star < 1:
168
+ l_star = 1
169
+ value = traj_eta(l_star, steps)
170
+ if value is not None:
171
+ etas.append(value)
172
+ trajectory = mean4(etas)
173
+ f1s = []
174
+ for r in completed:
175
+ tc = r.get("toolCorrectness") or {}
176
+ if tc.get("notApplicable") is True:
177
+ continue
178
+ f1 = tc.get("f1")
179
+ if isinstance(f1, (int, float)):
180
+ f1s.append(f1)
181
+ tool_f1 = mean4(f1s)
182
+ supports = []
183
+ for r in completed:
184
+ g = r.get("groundedness")
185
+ if not isinstance(g, dict) or g.get("notApplicable") is True:
186
+ continue
187
+ ratio = g.get("supportRatio")
188
+ if isinstance(ratio, (int, float)):
189
+ supports.append(ratio)
190
+ elif g.get("isGrounded") is True:
191
+ supports.append(1)
192
+ elif g.get("isGrounded") is False:
193
+ supports.append(0)
194
+ faith = mean4(supports)
195
+ hallucination = round(1 - faith, 4) if faith is not None else None
196
+ costs = [estimate_cost(r) for r in completed]
197
+ cost_per_task = round(sum(costs) / len(costs), 4) if costs else None
198
+ counts = {"toolSchema": 0, "contextOverflow": 0, "replanning": 0, "permissionTimeout": 0}
199
+ for r in rows:
200
+ mode = failure_mode(r)
201
+ if mode in counts:
202
+ counts[mode] += 1
203
+ total_failures = sum(counts.values())
204
+ distribution = {}
205
+ for key in counts:
206
+ distribution[key] = {"count": counts[key], "rate": round(counts[key] / total_failures, 4) if total_failures else 0}
207
+ values = {"passAt1": pass_at_1, "trajectoryOptimality": trajectory, "toolF1": tool_f1, "hallucinationRate": hallucination, "costPerTask": cost_per_task}
208
+ targets = {"passAt1": {"min": 0.75}, "trajectoryOptimality": {"min": 0.8}, "toolF1": {"min": 0.9}, "hallucinationRate": {"max": 0.05}, "costPerTask": {"max": 0.04}}
209
+ status = {}
210
+ for key, value in values.items():
211
+ rule = targets.get(key, {})
212
+ if value is None:
213
+ status[key] = None
214
+ elif "min" in rule:
215
+ status[key] = value >= rule["min"]
216
+ elif "max" in rule:
217
+ status[key] = value <= rule["max"]
218
+ else:
219
+ status[key] = None
220
+ live = None
221
+ if pass_at_1 is not None and cost_per_task is not None:
222
+ live = {"label": "Live run", "successRate": round(pass_at_1 * 100, 1), "cost": cost_per_task, "kind": "live"}
223
+ pareto = [
224
+ {"label": "Agent Model v2", "successRate": 82.4, "cost": 0.027, "kind": "candidate"},
225
+ {"label": "Baseline v1", "successRate": 68.0, "cost": 0.045, "kind": "baseline"},
226
+ {"label": "Legacy Script", "successRate": 52.1, "cost": 0.012, "kind": "legacy"},
227
+ ]
228
+ if live is not None:
229
+ pareto.append(live)
230
+ return {"values": values, "status": status, "targets": targets, "failureDistribution": distribution, "failureTotal": total_failures, "pareto": pareto, "livePoint": live}
231
+
232
+
233
+ def sha256_file(path):
234
+ digest = hashlib.sha256()
235
+ with open(path, "rb") as handle:
236
+ for chunk in iter(lambda: handle.read(65536), b""):
237
+ digest.update(chunk)
238
+ return digest.hexdigest()
239
+
240
+
241
+ def is_skipped(path, root):
242
+ try:
243
+ rel = path.relative_to(root)
244
+ except ValueError:
245
+ return True
246
+ return any(part in SKIP_DIRS for part in rel.parts)
247
+
248
+
249
+ def iter_repo_files(root):
250
+ results = []
251
+ for path in root.rglob("*"):
252
+ if not path.is_file():
253
+ continue
254
+ if is_skipped(path, root):
255
+ continue
256
+ if path.suffix.lower() in SKIP_SUFFIXES:
257
+ continue
258
+ results.append(path)
259
+ return results
260
+
261
+
262
+ def count_lines(text):
263
+ lines = text.splitlines()
264
+ total = len(lines)
265
+ blank = sum(1 for line in lines if not line.strip())
266
+ return total, blank
267
+
268
+
269
+ def analyze_js_signals(text):
270
+ signals = {}
271
+ for name, pattern in JS_SIGNAL_PATTERNS.items():
272
+ signals[name] = len(re.findall(pattern, text, re.MULTILINE))
273
+ return signals
274
+
275
+
276
+ def analyze_python_ast(text):
277
+ try:
278
+ tree = ast.parse(text)
279
+ except SyntaxError:
280
+ return {"parseError": True, "functions": 0, "classes": 0, "imports": 0}
281
+ functions = sum(isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) for n in ast.walk(tree))
282
+ classes = sum(isinstance(n, ast.ClassDef) for n in ast.walk(tree))
283
+ imports = sum(isinstance(n, (ast.Import, ast.ImportFrom)) for n in ast.walk(tree))
284
+ return {"parseError": False, "functions": functions, "classes": classes, "imports": imports}
285
+
286
+
287
+ def extract_js_exports(text):
288
+ symbols = set(EXPORT_SYMBOL_RE.findall(text))
289
+ for block in EXPORT_LIST_RE.findall(text):
290
+ for item in block.split(","):
291
+ item = item.strip().split(" as ")[-1].strip()
292
+ if re.match(r"^[A-Za-z_$][\w$]*$", item):
293
+ symbols.add(item)
294
+ return sorted(symbols)
295
+
296
+
297
+ def analyze_codebase(root):
298
+ files = iter_repo_files(root)
299
+ by_suffix = Counter()
300
+ loc_by_suffix = Counter()
301
+ blank_by_suffix = Counter()
302
+ bytes_by_suffix = Counter()
303
+ js_signals_total = Counter()
304
+ python_stats_total = Counter()
305
+ largest = []
306
+ for path in files:
307
+ suffix = path.suffix.lower() or "<none>"
308
+ by_suffix[suffix] += 1
309
+ size = path.stat().st_size
310
+ bytes_by_suffix[suffix] += size
311
+ entry = {"path": str(path.relative_to(root)).replace("\\", "/"), "suffix": suffix, "bytes": size}
312
+ if suffix in TEXT_SUFFIXES:
313
+ try:
314
+ text = path.read_text(encoding="utf-8", errors="replace")
315
+ except OSError:
316
+ largest.append(entry)
317
+ continue
318
+ total, blank = count_lines(text)
319
+ entry["lines"] = total
320
+ entry["blank"] = blank
321
+ loc_by_suffix[suffix] += total
322
+ blank_by_suffix[suffix] += blank
323
+ if suffix in {".js", ".jsx"}:
324
+ signals = analyze_js_signals(text)
325
+ for key, value in signals.items():
326
+ js_signals_total[key] += value
327
+ entry["jsSignals"] = signals
328
+ if suffix == ".py":
329
+ py_stats = analyze_python_ast(text)
330
+ for key in ("functions", "classes", "imports"):
331
+ python_stats_total[key] += py_stats.get(key, 0)
332
+ entry["pythonStats"] = py_stats
333
+ largest.append(entry)
334
+ largest_sorted = sorted(largest, key=lambda e: e.get("bytes", 0), reverse=True)[:15]
335
+ total_lines = sum(loc_by_suffix.values())
336
+ total_bytes = sum(bytes_by_suffix.values())
337
+ return {
338
+ "root": str(root),
339
+ "fileCount": len(files),
340
+ "totalBytes": total_bytes,
341
+ "totalLines": total_lines,
342
+ "bySuffix": dict(sorted(by_suffix.items())),
343
+ "linesBySuffix": dict(sorted(loc_by_suffix.items())),
344
+ "blankLinesBySuffix": dict(sorted(blank_by_suffix.items())),
345
+ "bytesBySuffix": dict(sorted(bytes_by_suffix.items())),
346
+ "jsSignalsTotal": dict(js_signals_total),
347
+ "pythonStatsTotal": dict(python_stats_total),
348
+ "largestFiles": largest_sorted,
349
+ }
350
+
351
+
352
+ def analyze_catalog_drift(root):
353
+ pairs = []
354
+ drifted = 0
355
+ for src_rel, mcp_rel in CATALOG_PAIRS:
356
+ src = root / src_rel
357
+ mcp = root / mcp_rel
358
+ item = {"src": src_rel, "mcp": mcp_rel}
359
+ for key, path in (("src", src), ("mcp", mcp)):
360
+ if path.exists():
361
+ text = path.read_text(encoding="utf-8", errors="replace")
362
+ total, _ = count_lines(text)
363
+ item[key + "Exists"] = True
364
+ item[key + "Bytes"] = path.stat().st_size
365
+ item[key + "Lines"] = total
366
+ item[key + "Sha256"] = sha256_file(path)[:12]
367
+ item[key + "Exports"] = extract_js_exports(text)
368
+ else:
369
+ item[key + "Exists"] = False
370
+ src_exports = set(item.get("srcExports", []))
371
+ mcp_exports = set(item.get("mcpExports", []))
372
+ item["exportOverlap"] = sorted(src_exports & mcp_exports)
373
+ item["srcOnlyExports"] = sorted(src_exports - mcp_exports)
374
+ item["mcpOnlyExports"] = sorted(mcp_exports - src_exports)
375
+ item["drifted"] = bool(item["srcOnlyExports"] or item["mcpOnlyExports"])
376
+ if item["drifted"]:
377
+ drifted += 1
378
+ pairs.append(item)
379
+ return {"pairs": pairs, "pairCount": len(pairs), "driftedPairs": drifted}
380
+
381
+
382
+ def load_corpus_via_node(root):
383
+ script = (
384
+ "import('./server/agent/eval/modules/cases.js').then(m => {"
385
+ " const rows = m.allCases.map(c => ({"
386
+ " id: c.id, category: c.category, language: c.language,"
387
+ " groups: (c.groups || []).length,"
388
+ " expectedTools: c.expectedTools || [],"
389
+ " sources: c.sources || [] }));"
390
+ " console.log(JSON.stringify({ total: rows.length,"
391
+ " catalog: m.cases.length, security: m.securityCases.length, rows }));"
392
+ "}).catch(e => { console.error(String(e && e.message || e)); process.exit(1); })"
393
+ )
394
+ proc = subprocess.run(
395
+ ["node", "--input-type=module", "-e", script],
396
+ cwd=str(root),
397
+ capture_output=True,
398
+ text=True,
399
+ timeout=30,
400
+ )
401
+ if proc.returncode != 0:
402
+ return {"available": False, "error": (proc.stderr or proc.stdout).strip()[-2000:]}
403
+ payload = json.loads(proc.stdout.strip())
404
+ rows = payload.get("rows", [])
405
+ by_category = dict(Counter(r["category"] for r in rows))
406
+ by_language = dict(Counter(r["language"] for r in rows))
407
+ tool_demand = dict(Counter(t for r in rows for t in r.get("expectedTools", [])))
408
+ with_groups = sum(1 for r in rows if r.get("groups", 0) > 0)
409
+ return {
410
+ "available": True,
411
+ "total": payload.get("total", len(rows)),
412
+ "catalogCount": payload.get("catalog"),
413
+ "securityCount": payload.get("security"),
414
+ "byCategory": dict(sorted(by_category.items())),
415
+ "byLanguage": dict(sorted(by_language.items())),
416
+ "toolDemand": dict(sorted(tool_demand.items(), key=lambda kv: kv[1], reverse=True)),
417
+ "casesWithGroups": with_groups,
418
+ "caseIds": [r["id"] for r in rows],
419
+ }
420
+
421
+
422
+ def summarize_latencies(durations):
423
+ ordered = sorted(durations)
424
+ if not ordered:
425
+ return {"count": 0, "mean": None, "min": None, "p50": None, "p95": None, "max": None}
426
+ return {
427
+ "count": len(ordered),
428
+ "mean": mean(ordered),
429
+ "min": ordered[0],
430
+ "p50": round(percentile(ordered, 50), 1),
431
+ "p95": round(percentile(ordered, 95), 1),
432
+ "max": ordered[-1],
433
+ }
434
+
435
+
436
+ def analyze_report(report_path, root):
437
+ resolved = report_path if report_path.is_absolute() else root / report_path
438
+ if not resolved.exists():
439
+ return {"status": "missing", "path": str(report_path)}
440
+ try:
441
+ payload = json.loads(resolved.read_text(encoding="utf-8"))
442
+ except (OSError, json.JSONDecodeError) as exc:
443
+ return {"status": "unreadable", "path": str(report_path), "error": str(exc)[:500]}
444
+ if payload.get("status") == "blocked":
445
+ return {"status": "blocked", "path": str(report_path), "reason": payload.get("reason"), "planned": payload.get("planned")}
446
+ rows = payload.get("rows", [])
447
+ summary = payload.get("summary", {})
448
+ metadata = payload.get("metadata", {})
449
+ completed = [r for r in rows if r.get("status") == "completed"]
450
+ errors = [r for r in rows if r.get("status") != "completed"]
451
+ coverage_rows = [r for r in completed if r.get("coverage") and (r["coverage"].get("total") or 0) > 0]
452
+ coverage_pass = [r for r in coverage_rows if r["coverage"].get("allMatched") is True]
453
+ tool_rows = [r for r in completed if r.get("toolCorrectness") and not r["toolCorrectness"].get("notApplicable")]
454
+ tool_pass = [r for r in tool_rows if r["toolCorrectness"].get("isCorrect") is True]
455
+ loop_rows = [r for r in completed if r.get("trajectoryHealth", {}).get("hasLoop")]
456
+ grounded_rows = [r for r in completed if r.get("groundedness") and not r["groundedness"].get("notApplicable")]
457
+ grounded_pass = [r for r in grounded_rows if r["groundedness"].get("isGrounded") is True]
458
+ judged = [r for r in completed if r.get("judgment") or (r.get("judgeResult") and not r.get("judgeResult", {}).get("judgeError"))]
459
+ durations = [r["durationMs"] for r in completed if isinstance(r.get("durationMs"), (int, float))]
460
+ tool_histogram = Counter()
461
+ for row in completed:
462
+ trace = row.get("trace") or []
463
+ seen = {t.get("name") for t in trace if t.get("event") == "start" and t.get("name")}
464
+ for name in seen:
465
+ tool_histogram[name] += 1
466
+ per_category = {}
467
+ for row in rows:
468
+ cat = row.get("category") or "unknown"
469
+ bucket = per_category.setdefault(cat, {"total": 0, "completed": 0, "factsMatched": 0, "toolsCorrect": 0, "toolEligible": 0})
470
+ bucket["total"] += 1
471
+ if row.get("status") == "completed":
472
+ bucket["completed"] += 1
473
+ if (row.get("coverage") or {}).get("allMatched") is True:
474
+ bucket["factsMatched"] += 1
475
+ tc = row.get("toolCorrectness") or {}
476
+ if not tc.get("notApplicable"):
477
+ bucket["toolEligible"] += 1
478
+ if tc.get("isCorrect") is True:
479
+ bucket["toolsCorrect"] += 1
480
+ for bucket in per_category.values():
481
+ bucket["factRate"] = round(bucket["factsMatched"] / bucket["completed"], 4) if bucket["completed"] else None
482
+ bucket["toolRate"] = round(bucket["toolsCorrect"] / bucket["toolEligible"], 4) if bucket["toolEligible"] else None
483
+ per_language = {}
484
+ for row in rows:
485
+ lang = row.get("language") or "unknown"
486
+ bucket = per_language.setdefault(lang, {"total": 0, "completed": 0, "factsMatched": 0})
487
+ bucket["total"] += 1
488
+ if row.get("status") == "completed":
489
+ bucket["completed"] += 1
490
+ if (row.get("coverage") or {}).get("allMatched") is True:
491
+ bucket["factsMatched"] += 1
492
+ slowest = sorted(completed, key=lambda r: r.get("durationMs") or 0, reverse=True)[:10]
493
+ slowest = [{"id": r.get("id"), "category": r.get("category"), "durationMs": r.get("durationMs")} for r in slowest]
494
+ bench = benchmark_summary(rows)
495
+ return {
496
+ "status": "ok",
497
+ "path": str(report_path),
498
+ "model": metadata.get("model"),
499
+ "planned": summary.get("planned", metadata.get("caseIds", []) and len(metadata["caseIds"])),
500
+ "attempted": len(rows),
501
+ "completed": len(completed),
502
+ "errors": len(errors),
503
+ "errorIds": [{"id": r.get("id"), "error": str(r.get("error"))[:300]} for r in errors],
504
+ "completionRate": round(len(completed) / len(rows), 4) if rows else None,
505
+ "lexicalPass": len(coverage_pass),
506
+ "lexicalEligible": len(coverage_rows),
507
+ "lexicalRate": round(len(coverage_pass) / len(coverage_rows), 4) if coverage_rows else None,
508
+ "toolPass": len(tool_pass),
509
+ "toolEligible": len(tool_rows),
510
+ "toolRate": round(len(tool_pass) / len(tool_rows), 4) if tool_rows else None,
511
+ "loopCount": len(loop_rows),
512
+ "loopIds": [r.get("id") for r in loop_rows],
513
+ "loopFreeRate": round((len(completed) - len(loop_rows)) / len(completed), 4) if completed else None,
514
+ "groundedPass": len(grounded_pass),
515
+ "groundedEligible": len(grounded_rows),
516
+ "groundedRate": round(len(grounded_pass) / len(grounded_rows), 4) if grounded_rows else None,
517
+ "judged": len(judged),
518
+ "latencyMs": summarize_latencies(durations),
519
+ "toolHistogram": dict(sorted(tool_histogram.items(), key=lambda kv: kv[1], reverse=True)),
520
+ "perCategory": dict(sorted(per_category.items())),
521
+ "perLanguage": dict(sorted(per_language.items())),
522
+ "slowestCases": slowest,
523
+ "engineSummary": summary,
524
+ "benchmark": bench,
525
+ }
526
+
527
+
528
+ def evaluate_gates(report_section, gates_path, root):
529
+ resolved = gates_path if gates_path.is_absolute() else root / gates_path
530
+ if report_section.get("status") != "ok":
531
+ return {"available": False, "reason": "report-" + report_section.get("status", "unknown")}
532
+ if not resolved.exists():
533
+ return {"available": False, "reason": "gates-file-missing"}
534
+ gates = json.loads(resolved.read_text(encoding="utf-8"))
535
+ summary = report_section.get("engineSummary") or {}
536
+ bench_values = (report_section.get("benchmark") or {}).get("values") or {}
537
+ results = []
538
+ passed = True
539
+ for metric, rule in gates.items():
540
+ if metric == "perCategory":
541
+ continue
542
+ actual = summary.get(metric)
543
+ if actual is None:
544
+ actual = bench_values.get(metric)
545
+ ok = True
546
+ if isinstance(rule, dict) and "min" in rule:
547
+ ok = actual is not None and actual >= rule["min"]
548
+ if isinstance(rule, dict) and "max" in rule:
549
+ ok = ok and actual is not None and actual <= rule["max"]
550
+ if not ok:
551
+ passed = False
552
+ results.append({"metric": metric, "threshold": rule, "actual": actual, "pass": ok})
553
+ per_category_rules = gates.get("perCategory") or {}
554
+ per_category = report_section.get("perCategory") or {}
555
+ for cat, rules in per_category_rules.items():
556
+ bucket = per_category.get(cat, {})
557
+ for sub_metric, rule in rules.items():
558
+ actual = None
559
+ if sub_metric == "lexicalCoverageRate" and bucket.get("completed"):
560
+ actual = round(bucket["factsMatched"] / bucket["completed"], 4)
561
+ if sub_metric == "toolAccuracyRate" and bucket.get("toolEligible"):
562
+ actual = round(bucket["toolsCorrect"] / bucket["toolEligible"], 4)
563
+ ok = True
564
+ if isinstance(rule, dict) and "min" in rule:
565
+ ok = actual is not None and actual >= rule["min"]
566
+ if isinstance(rule, dict) and "max" in rule:
567
+ ok = ok and actual is not None and actual <= rule["max"]
568
+ if not ok:
569
+ passed = False
570
+ results.append({"metric": "perCategory." + cat + "." + sub_metric, "threshold": rule, "actual": actual, "pass": ok})
571
+ return {"available": True, "passed": passed, "results": results}
572
+
573
+
574
+ def build_markdown(result):
575
+ lines = []
576
+ lines.append("Kiku whole-system analysis")
577
+ lines.append("Generated: " + str(result.get("generatedAt")))
578
+ codebase = result.get("codebase", {})
579
+ lines.append("")
580
+ lines.append("Codebase: " + str(codebase.get("fileCount")) + " files, " + str(codebase.get("totalLines")) + " lines, " + str(codebase.get("totalBytes")) + " bytes")
581
+ for suffix in sorted((codebase.get("linesBySuffix") or {}).keys()):
582
+ count = (codebase.get("bySuffix") or {}).get(suffix, 0)
583
+ lines.append(" " + suffix + ": " + str(count) + " files / " + str(codebase["linesBySuffix"][suffix]) + " lines")
584
+ drift = result.get("catalogDrift", {})
585
+ lines.append("")
586
+ lines.append("Catalog drift: " + str(drift.get("driftedPairs")) + "/" + str(drift.get("pairCount")) + " pairs differ")
587
+ for pair in drift.get("pairs", []):
588
+ if pair.get("drifted"):
589
+ lines.append(" DRIFT " + pair["src"] + " vs " + pair["mcp"] + " srcOnly=" + ",".join(pair.get("srcOnlyExports", [])))
590
+ corpus = result.get("corpus", {})
591
+ lines.append("")
592
+ if corpus.get("available"):
593
+ lines.append("Corpus: " + str(corpus.get("total")) + " cases (" + str(corpus.get("catalogCount")) + " catalog + " + str(corpus.get("securityCount")) + " security)")
594
+ lines.append("Languages: " + json.dumps(corpus.get("byLanguage"), ensure_ascii=False))
595
+ lines.append("Top tools: " + json.dumps(dict(list((corpus.get("toolDemand") or {}).items())[:8]), ensure_ascii=False))
596
+ else:
597
+ lines.append("Corpus unavailable: " + str(corpus.get("error")))
598
+ report = result.get("report", {})
599
+ lines.append("")
600
+ lines.append("Report [" + str(report.get("status")) + "]: " + str(report.get("path")))
601
+ if report.get("status") == "ok":
602
+ lines.append("Completed " + str(report.get("completed")) + "/" + str(report.get("attempted")) + ", lexical " + str(report.get("lexicalPass")) + "/" + str(report.get("lexicalEligible")) + ", tools " + str(report.get("toolPass")) + "/" + str(report.get("toolEligible")))
603
+ lines.append("Loops: " + str(report.get("loopCount")) + ", p95 latency: " + str((report.get("latencyMs") or {}).get("p95")) + "ms")
604
+ lines.append("Slowest: " + json.dumps(report.get("slowestCases"), ensure_ascii=False))
605
+ bench = report.get("benchmark") or {}
606
+ values = bench.get("values") or {}
607
+ status = bench.get("status") or {}
608
+ if values:
609
+ lines.append("Benchmark ISO-IEC 42001 and NIST AI RMF:")
610
+ for key in ["passAt1", "trajectoryOptimality", "toolF1", "hallucinationRate", "costPerTask"]:
611
+ mark = "PASS" if status.get(key) is True else ("FAIL" if status.get(key) is False else "N-A")
612
+ lines.append(" " + mark + " " + key + " actual=" + str(values.get(key)))
613
+ lines.append("Failures: " + json.dumps(bench.get("failureDistribution"), ensure_ascii=False))
614
+ lines.append("Pareto: " + json.dumps(bench.get("pareto"), ensure_ascii=False))
615
+ gates = result.get("gates", {})
616
+ if gates.get("available"):
617
+ lines.append("")
618
+ lines.append("Gates passed: " + str(gates.get("passed")))
619
+ for item in gates.get("results", []):
620
+ lines.append(" " + ("PASS" if item["pass"] else "FAIL") + " " + item["metric"] + " actual=" + str(item["actual"]) + " threshold=" + json.dumps(item["threshold"], ensure_ascii=False))
621
+ cross = result.get("cross", {})
622
+ if cross:
623
+ lines.append("")
624
+ lines.append("Stale report ids: " + str(len(cross.get("staleReportIds", []))) + ", unrun corpus ids: " + str(len(cross.get("unrunCorpusIds", []))))
625
+ return "\n".join(lines) + "\n"
626
+
627
+
628
+ def parse_args(argv):
629
+ parser = argparse.ArgumentParser(description="Whole-system Python analyzer for the Kiku eval harness")
630
+ parser.add_argument("--mode", choices=["all", "codebase", "report"], default="all")
631
+ parser.add_argument("--root", default=str(DEFAULT_ROOT))
632
+ parser.add_argument("--report", default=str(DEFAULT_REPORT))
633
+ parser.add_argument("--gates", default=str(DEFAULT_GATES))
634
+ parser.add_argument("--out", default=None)
635
+ parser.add_argument("--markdown-out", default=None)
636
+ parser.add_argument("--skip-corpus", action="store_true")
637
+ return parser.parse_args(argv)
638
+
639
+
640
+ def main(argv=None):
641
+ args = parse_args(argv if argv is not None else sys.argv[1:])
642
+ root = Path(args.root).resolve()
643
+ result = {"generatedAt": datetime.now(timezone.utc).isoformat(), "mode": args.mode, "root": str(root)}
644
+ if args.mode in ("all", "codebase"):
645
+ result["codebase"] = analyze_codebase(root)
646
+ result["catalogDrift"] = analyze_catalog_drift(root)
647
+ if args.skip_corpus:
648
+ result["corpus"] = {"available": False, "error": "skipped-by-flag"}
649
+ else:
650
+ try:
651
+ result["corpus"] = load_corpus_via_node(root)
652
+ except Exception as exc:
653
+ result["corpus"] = {"available": False, "error": str(exc)[:500]}
654
+ if args.mode in ("all", "report"):
655
+ report_section = analyze_report(Path(args.report), root)
656
+ result["report"] = report_section
657
+ result["gates"] = evaluate_gates(report_section, Path(args.gates), root)
658
+ corpus_ids = set((result.get("corpus") or {}).get("caseIds") or [])
659
+ if report_section.get("status") == "ok" and corpus_ids:
660
+ try:
661
+ payload_path = Path(args.report) if Path(args.report).is_absolute() else root / args.report
662
+ payload = json.loads(Path(payload_path).read_text(encoding="utf-8"))
663
+ report_ids = {r.get("id") for r in payload.get("rows", []) if r.get("id")}
664
+ result["cross"] = {
665
+ "staleReportIds": sorted(report_ids - corpus_ids),
666
+ "unrunCorpusIds": sorted(corpus_ids - report_ids),
667
+ }
668
+ except (OSError, json.JSONDecodeError):
669
+ result["cross"] = {"staleReportIds": [], "unrunCorpusIds": sorted(corpus_ids)}
670
+ output_text = json.dumps(result, ensure_ascii=False, indent=2)
671
+ if args.out:
672
+ out_path = Path(args.out) if Path(args.out).is_absolute() else root / args.out
673
+ out_path.parent.mkdir(parents=True, exist_ok=True)
674
+ out_path.write_text(output_text, encoding="utf-8")
675
+ if args.markdown_out:
676
+ md_path = Path(args.markdown_out) if Path(args.markdown_out).is_absolute() else root / args.markdown_out
677
+ md_path.parent.mkdir(parents=True, exist_ok=True)
678
+ md_path.write_text(build_markdown(result), encoding="utf-8")
679
+ sys.stdout.write(output_text + "\n")
680
+ return 0
681
+
682
+
683
+ if __name__ == "__main__":
684
+ raise SystemExit(main())