@openlines/opl-pi-sht 0.1.14

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 (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/configs/opl-browser.json.sample +8 -0
  4. package/configs/opl-footer.json +48 -0
  5. package/configs/opl-footer.json.sample +48 -0
  6. package/configs/opl-input.json +22 -0
  7. package/configs/opl-input.json.sample +22 -0
  8. package/configs/opl-modes.json +291 -0
  9. package/configs/opl-modes.json.sample +285 -0
  10. package/configs/opl-simplebench.json +8 -0
  11. package/configs/opl-simplebench.json.sample +8 -0
  12. package/configs/opl-todo.json +12 -0
  13. package/configs/opl-todo.json.sample +12 -0
  14. package/configs/opl-webaccess.json +31 -0
  15. package/configs/opl-webaccess.json.sample +31 -0
  16. package/extensions/opl-browser/README.md +83 -0
  17. package/extensions/opl-browser/browser.ts +185 -0
  18. package/extensions/opl-browser/config.ts +37 -0
  19. package/extensions/opl-browser/index.ts +89 -0
  20. package/extensions/opl-browser/package-lock.json +60 -0
  21. package/extensions/opl-browser/package.json +10 -0
  22. package/extensions/opl-ctxtrim/README.md +78 -0
  23. package/extensions/opl-ctxtrim/index.ts +151 -0
  24. package/extensions/opl-footer/README.md +193 -0
  25. package/extensions/opl-footer/config.ts +91 -0
  26. package/extensions/opl-footer/git-status.ts +170 -0
  27. package/extensions/opl-footer/icons.ts +49 -0
  28. package/extensions/opl-footer/index.ts +368 -0
  29. package/extensions/opl-footer/segments/caveman.ts +25 -0
  30. package/extensions/opl-footer/segments/chat-mode.ts +24 -0
  31. package/extensions/opl-footer/segments/context.ts +92 -0
  32. package/extensions/opl-footer/segments/cost.ts +25 -0
  33. package/extensions/opl-footer/segments/git.ts +47 -0
  34. package/extensions/opl-footer/segments/helpers.ts +18 -0
  35. package/extensions/opl-footer/segments/index.ts +51 -0
  36. package/extensions/opl-footer/segments/mode-switcher.ts +24 -0
  37. package/extensions/opl-footer/segments/model.ts +22 -0
  38. package/extensions/opl-footer/segments/path.ts +34 -0
  39. package/extensions/opl-footer/segments/pi.ts +11 -0
  40. package/extensions/opl-footer/segments/plan-mode.ts +24 -0
  41. package/extensions/opl-footer/segments/separator.ts +9 -0
  42. package/extensions/opl-footer/segments/session-stats.ts +82 -0
  43. package/extensions/opl-footer/segments/thinking.ts +51 -0
  44. package/extensions/opl-footer/segments/tokens.ts +66 -0
  45. package/extensions/opl-footer/theme.ts +113 -0
  46. package/extensions/opl-footer/types.ts +172 -0
  47. package/extensions/opl-init/README.md +23 -0
  48. package/extensions/opl-init/index.ts +451 -0
  49. package/extensions/opl-input/README.md +83 -0
  50. package/extensions/opl-input/config.ts +137 -0
  51. package/extensions/opl-input/index.ts +284 -0
  52. package/extensions/opl-input/mode-style.ts +38 -0
  53. package/extensions/opl-input/utils.ts +378 -0
  54. package/extensions/opl-modes/README.md +132 -0
  55. package/extensions/opl-modes/config.ts +473 -0
  56. package/extensions/opl-modes/index.ts +1081 -0
  57. package/extensions/opl-modes/menus.ts +60 -0
  58. package/extensions/opl-modes/state.ts +152 -0
  59. package/extensions/opl-modes/types.ts +164 -0
  60. package/extensions/opl-modes/utils.ts +138 -0
  61. package/extensions/opl-questionnaire/README.md +26 -0
  62. package/extensions/opl-questionnaire/index.ts +445 -0
  63. package/extensions/opl-questionnaire/types.ts +55 -0
  64. package/extensions/opl-simplebench/README.md +242 -0
  65. package/extensions/opl-simplebench/artifact.ts +41 -0
  66. package/extensions/opl-simplebench/benchmark.ts +873 -0
  67. package/extensions/opl-simplebench/coding.ts +282 -0
  68. package/extensions/opl-simplebench/index.ts +203 -0
  69. package/extensions/opl-simplebench/llama-server.ts +290 -0
  70. package/extensions/opl-simplebench/metrics.ts +73 -0
  71. package/extensions/opl-simplebench/report.ts +46 -0
  72. package/extensions/opl-simplebench/research.ts +200 -0
  73. package/extensions/opl-simplebench/scoring.ts +48 -0
  74. package/extensions/opl-simplebench/tests.ts +39 -0
  75. package/extensions/opl-simplebench/types.ts +6 -0
  76. package/extensions/opl-simplebench/util/config.ts +170 -0
  77. package/extensions/opl-simplebench/util/debug.ts +32 -0
  78. package/extensions/opl-simplebench/util/format.ts +402 -0
  79. package/extensions/opl-simplebench/util/providers.ts +841 -0
  80. package/extensions/opl-todo/README.md +43 -0
  81. package/extensions/opl-todo/config.ts +71 -0
  82. package/extensions/opl-todo/index.ts +415 -0
  83. package/extensions/opl-webaccess/README.md +47 -0
  84. package/extensions/opl-webaccess/config.ts +55 -0
  85. package/extensions/opl-webaccess/extract.ts +83 -0
  86. package/extensions/opl-webaccess/index.ts +232 -0
  87. package/extensions/opl-webaccess/package-lock.json +345 -0
  88. package/extensions/opl-webaccess/package.json +18 -0
  89. package/extensions/opl-webaccess/pdf.ts +8 -0
  90. package/extensions/opl-webaccess/providers/ddgs.ts +72 -0
  91. package/extensions/opl-webaccess/providers/exa.ts +74 -0
  92. package/extensions/opl-webaccess/providers/gemini.ts +60 -0
  93. package/extensions/opl-webaccess/providers/searxng.ts +86 -0
  94. package/extensions/opl-webaccess/providers/tavily.ts +60 -0
  95. package/extensions/opl-webaccess/search.ts +36 -0
  96. package/extensions/opl-webaccess/storage.ts +51 -0
  97. package/extensions/opl-webaccess/turndown.d.ts +1 -0
  98. package/extensions/opl-webaccess/types.ts +26 -0
  99. package/extensions/opl-webaccess/utils.ts +28 -0
  100. package/package.json +55 -0
@@ -0,0 +1,290 @@
1
+ import { CONFIG } from "./util/config";
2
+
3
+ // ── llama-server metadata (opt-in via --llama-server) ──────────────────────
4
+ // Queries the direct llama-server management base URL for /props configuration
5
+ // and /metrics counters. All values unavailable from the running build are null.
6
+ // /metrics counters are server-wide cumulative telemetry, not per-request.
7
+
8
+ export interface ModelConfig {
9
+ model: string | null;
10
+ ctx: number | null;
11
+ ngl: number | null;
12
+ "flash-attn": boolean | null;
13
+ threads: number | null;
14
+ batch: number | null;
15
+ slots: number | null;
16
+ "kv-k/v": string | null;
17
+ temp: number | null;
18
+ "top-k": number | null;
19
+ "top-p": number | null;
20
+ "min-p": number | null;
21
+ repeat: number | null;
22
+ "spec-type": string | null;
23
+ "n-max": number | null;
24
+ "draft-kv": string | null;
25
+ }
26
+
27
+ export interface LlamaServerInfo {
28
+ modelConfig: ModelConfig;
29
+ reasoning: string | null;
30
+ }
31
+
32
+ export interface ModelStats {
33
+ prefill: number | null;
34
+ gen: number | null;
35
+ "session-avg": number | null;
36
+ reasoning: string | null;
37
+ "draft-accepted-p": number | null;
38
+ "draft-accepted-tok": number | null;
39
+ }
40
+
41
+ export interface ServerStats {
42
+ modelConfig: ModelConfig;
43
+ modelStats: ModelStats;
44
+ errors: string[];
45
+ }
46
+
47
+ const NULL_CONFIG: ModelConfig = {
48
+ model: null, ctx: null, ngl: null, "flash-attn": null, threads: null, batch: null,
49
+ slots: null, "kv-k/v": null, temp: null, "top-k": null, "top-p": null,
50
+ "min-p": null, repeat: null, "spec-type": null, "n-max": null, "draft-kv": null,
51
+ };
52
+
53
+ function num(value: unknown): number | null {
54
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
55
+ }
56
+
57
+ function str(value: unknown): string | null {
58
+ return typeof value === "string" ? value : null;
59
+ }
60
+
61
+ function bool(value: unknown): boolean | null {
62
+ return typeof value === "boolean" ? value : null;
63
+ }
64
+
65
+ function firstNumber(obj: Record<string, unknown>, keys: string[]): number | null {
66
+ for (const key of keys) { const v = num(obj[key]); if (v !== null) return v; }
67
+ return null;
68
+ }
69
+
70
+ function round2(value: number): number {
71
+ return Math.round(value * 100) / 100;
72
+ }
73
+
74
+ // Sampling params arrive as float32 widened to float64 (e.g. 0.6 -> 0.6000000238).
75
+ function round4(value: number | null): number | null {
76
+ return value === null ? null : Math.round(value * 10000) / 10000;
77
+ }
78
+
79
+ export function normalizeLlamaServerProps(raw: unknown): LlamaServerInfo {
80
+ const root = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
81
+ const dgs = (root.default_generation_settings && typeof root.default_generation_settings === "object"
82
+ ? root.default_generation_settings : {}) as Record<string, unknown>;
83
+ const params = (dgs.params && typeof dgs.params === "object" ? dgs.params : {}) as Record<string, unknown>;
84
+ return {
85
+ modelConfig: {
86
+ model: str(root.model_alias) ?? str(root.model),
87
+ ctx: num(dgs.n_ctx),
88
+ // Only populated by builds that expose these directly; never inferred.
89
+ ngl: firstNumber(root, ["n_gpu_layers", "ngl"]),
90
+ "flash-attn": bool(root.flash_attn),
91
+ threads: firstNumber(root, ["n_threads", "threads"]),
92
+ batch: firstNumber(root, ["n_batch", "batch"]),
93
+ slots: num(root.total_slots),
94
+ "kv-k/v": str(root.kv_cache_type) ?? str(root["cache_type_k"]),
95
+ temp: round4(num(params.temperature)),
96
+ "top-k": num(params.top_k),
97
+ "top-p": round4(num(params.top_p)),
98
+ "min-p": round4(num(params.min_p)),
99
+ repeat: round4(num(params.repeat_penalty)),
100
+ "spec-type": str(params["speculative.types"]),
101
+ "n-max": firstNumber(root, ["n_max", "n_ctx_max"]),
102
+ "draft-kv": str(root.draft_cache_type),
103
+ },
104
+ reasoning: str(params.reasoning_format),
105
+ };
106
+ }
107
+
108
+ export function parsePrometheusMetrics(text: string): Record<string, number> {
109
+ const out: Record<string, number> = {};
110
+ for (const line of text.split("\n")) {
111
+ const trimmed = line.trim();
112
+ if (!trimmed || trimmed.startsWith("#")) continue;
113
+ // Skip labelled series (e.g. foo{position="0"}); we only need scalars.
114
+ const match = /^([a-zA-Z_:][a-zA-Z0-9_:]*)\s+([-+0-9.eE]+)$/.exec(trimmed);
115
+ if (!match) continue;
116
+ const value = Number(match[2]);
117
+ // llama.cpp namespaces every metric as `llamacpp:<name>`; key on the bare name.
118
+ if (Number.isFinite(value)) out[match[1].replace(/^llamacpp:/, "")] = value;
119
+ }
120
+ return out;
121
+ }
122
+
123
+ export function diffPrometheusMetrics(before: Record<string, number>, after: Record<string, number>): Record<string, number> {
124
+ const out: Record<string, number> = {};
125
+ for (const key of Object.keys(after)) out[key] = after[key] - (before[key] ?? 0);
126
+ return out;
127
+ }
128
+
129
+ function rate(tokens: number | undefined, seconds: number | undefined): number | null {
130
+ if (typeof tokens !== "number" || typeof seconds !== "number" || seconds <= 0) return null;
131
+ return round2(tokens / seconds);
132
+ }
133
+
134
+ export function buildServerStats(
135
+ props: LlamaServerInfo | null,
136
+ before: Record<string, number>,
137
+ after: Record<string, number>,
138
+ errors: string[],
139
+ ): ServerStats {
140
+ const d = diffPrometheusMetrics(before, after);
141
+ const promptTok = d.prompt_tokens_total;
142
+ const promptSec = d.prompt_seconds_total;
143
+ const genTok = d.tokens_predicted_total;
144
+ const genSec = d.tokens_predicted_seconds_total;
145
+ const draftTok = d.spec_decode_num_draft_tokens_total;
146
+ const acceptedTok = d.spec_decode_num_accepted_tokens_total;
147
+ const drafts = d.spec_decode_num_drafts_total;
148
+ const sessionTok = typeof promptTok === "number" && typeof genTok === "number" ? promptTok + genTok : undefined;
149
+ const sessionSec = typeof promptSec === "number" && typeof genSec === "number" ? promptSec + genSec : undefined;
150
+ return {
151
+ modelConfig: props ? props.modelConfig : { ...NULL_CONFIG },
152
+ modelStats: {
153
+ prefill: rate(promptTok, promptSec),
154
+ gen: rate(genTok, genSec),
155
+ "session-avg": rate(sessionTok, sessionSec),
156
+ reasoning: props ? props.reasoning : null,
157
+ "draft-accepted-p": typeof draftTok === "number" && draftTok > 0 && typeof acceptedTok === "number"
158
+ ? round2((acceptedTok / draftTok) * 100) : null,
159
+ "draft-accepted-tok": typeof drafts === "number" && drafts > 0 && typeof acceptedTok === "number"
160
+ ? round2(acceptedTok / drafts) : null,
161
+ },
162
+ errors,
163
+ };
164
+ }
165
+
166
+ // Strip a trailing /v1 (or /v1/) so /props and /metrics resolve against the root.
167
+ export function managementBaseUrl(baseUrl: string): string {
168
+ return baseUrl.replace(/\/v1\/?$/, "").replace(/\/$/, "");
169
+ }
170
+
171
+ async function fetchText(url: string): Promise<string> {
172
+ const controller = new AbortController();
173
+ const timeout = setTimeout(() => controller.abort(), CONFIG.TOOL_TEST_TIMEOUT_MS);
174
+ try {
175
+ const res = await fetch(url, { signal: controller.signal });
176
+ if (!res.ok) throw new Error(`${url} returned ${res.status}`);
177
+ return await res.text();
178
+ } finally {
179
+ clearTimeout(timeout);
180
+ }
181
+ }
182
+
183
+ export async function fetchLlamaServerProps(baseUrl: string): Promise<{ info: LlamaServerInfo | null; error: string | null }> {
184
+ try {
185
+ const text = await fetchText(`${managementBaseUrl(baseUrl)}/props`);
186
+ return { info: normalizeLlamaServerProps(JSON.parse(text)), error: null };
187
+ } catch (e: any) {
188
+ return { info: null, error: `props: ${e?.message || String(e)}` };
189
+ }
190
+ }
191
+
192
+ export async function fetchLlamaServerMetrics(baseUrl: string): Promise<{ metrics: Record<string, number>; error: string | null }> {
193
+ try {
194
+ const text = await fetchText(`${managementBaseUrl(baseUrl)}/metrics`);
195
+ return { metrics: parsePrometheusMetrics(text), error: null };
196
+ } catch (e: any) {
197
+ return { metrics: {}, error: `metrics: ${e?.message || String(e)}` };
198
+ }
199
+ }
200
+
201
+ export interface LlamagputopInfo {
202
+ modelIds: string[];
203
+ modelConfig: Partial<ModelConfig>;
204
+ modelStats: Partial<ModelStats>;
205
+ }
206
+
207
+ function configValue(key: keyof ModelConfig, value: unknown): ModelConfig[typeof key] | undefined {
208
+ if (key === "flash-attn") {
209
+ if (typeof value === "boolean") return value;
210
+ if (typeof value === "string") {
211
+ if (["on", "enabled", "true", "1"].includes(value.toLowerCase())) return true;
212
+ if (["off", "disabled", "false", "0"].includes(value.toLowerCase())) return false;
213
+ }
214
+ return undefined;
215
+ }
216
+ if (key === "batch" && typeof value === "string") {
217
+ const n = Number(value.split("/")[0]);
218
+ return Number.isFinite(n) ? n : undefined;
219
+ }
220
+ if (["ctx", "ngl", "threads", "slots", "temp", "top-k", "top-p", "min-p", "repeat", "n-max"].includes(key)) {
221
+ return num(value) ?? undefined;
222
+ }
223
+ return typeof value === "string" ? value : undefined;
224
+ }
225
+
226
+ const CONFIG_KEYS = Object.keys(NULL_CONFIG) as (keyof ModelConfig)[];
227
+
228
+ export function normalizeLlamagputopStats(raw: unknown): { info: LlamagputopInfo | null; error: string | null } {
229
+ const root = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
230
+ const llama = (root.llama && typeof root.llama === "object" ? root.llama : {}) as Record<string, unknown>;
231
+ const modelIds = [root.model, root.model_id, root.modelId, root.model_alias, llama.model, llama.model_id, llama.modelId]
232
+ .filter((value): value is string => typeof value === "string" && value.trim().length > 0);
233
+ const modelConfig: Partial<ModelConfig> = modelIds[0] ? { model: modelIds[0] } : {};
234
+ const rawConfig = (root.modelConfig && typeof root.modelConfig === "object" ? root.modelConfig : {}) as Record<string, unknown>;
235
+ for (const key of CONFIG_KEYS) {
236
+ const value = configValue(key, rawConfig[key]);
237
+ if (value !== undefined) modelConfig[key] = value as never;
238
+ }
239
+ const modelStats: Partial<ModelStats> = {};
240
+ const rawStats = (root.modelStats && typeof root.modelStats === "object" ? root.modelStats : {}) as Record<string, unknown>;
241
+ for (const key of ["prefill", "gen", "session-avg", "draft-accepted-p", "draft-accepted-tok"] as const) {
242
+ let value = num(rawStats[key]);
243
+ if (key === "draft-accepted-p" && value !== null && value <= 1) value *= 100;
244
+ if (value !== null) modelStats[key] = round2(value);
245
+ }
246
+ const reasoning = str(rawStats.reasoning) ?? str(llama.reasoning_format);
247
+ if (reasoning !== null) modelStats.reasoning = reasoning;
248
+ return { info: { modelIds, modelConfig, modelStats }, error: null };
249
+ }
250
+
251
+ export function applyLlamagputop(config: ModelConfig, stats: Partial<ModelConfig>): ModelConfig {
252
+ const out = { ...config };
253
+ for (const key of Object.keys(stats) as (keyof ModelConfig)[]) if ((key === "model" || key === "spec-type" || out[key] === null) && stats[key] !== null && stats[key] !== undefined) out[key] = stats[key] as never;
254
+ return out;
255
+ }
256
+
257
+ export function applyLlamagputopModelStats(stats: ModelStats, incoming: Partial<ModelStats>): ModelStats {
258
+ const out = { ...stats };
259
+ for (const key of Object.keys(incoming) as (keyof ModelStats)[]) if ((key === "reasoning" || out[key] === null) && incoming[key] !== null && incoming[key] !== undefined) out[key] = incoming[key] as never;
260
+ return out;
261
+ }
262
+
263
+ export function statsUrl(url: string): string {
264
+ const value = url.trim().replace(/\/$/, "");
265
+ return value.endsWith("/stats") ? value : `${value}/stats`;
266
+ }
267
+
268
+ export function healthUrl(url: string): string {
269
+ return statsUrl(url).replace(/\/stats$/, "/health");
270
+ }
271
+
272
+ export async function fetchLlamagputopStats(url: string): Promise<{ info: LlamagputopInfo | null; error: string | null; unavailable: boolean }> {
273
+ if (!url) return { info: null, error: "stats: no url", unavailable: true };
274
+ try {
275
+ const text = await fetchText(statsUrl(url));
276
+ const result = normalizeLlamagputopStats(JSON.parse(text));
277
+ return { ...result, unavailable: false };
278
+ } catch (e: any) {
279
+ return { info: null, error: `stats: ${e?.message || String(e)}`, unavailable: true };
280
+ }
281
+ }
282
+
283
+ export async function fetchLlamagputopHealth(url: string): Promise<string | null> {
284
+ try {
285
+ await fetchText(healthUrl(url));
286
+ return null;
287
+ } catch (e: any) {
288
+ return `health: ${e?.message || String(e)}`;
289
+ }
290
+ }
@@ -0,0 +1,73 @@
1
+ import type { RequestMetrics, TestRecord } from "./types";
2
+
3
+ export function usageFromRaw(raw: any): Pick<RequestMetrics, "inputTokens" | "outputTokens" | "totalTokens" | "outputTokensPerSecond"> {
4
+ const usage = raw?.usage;
5
+ const inputTokens = usage?.prompt_tokens ?? usage?.inputTokens ?? raw?.prompt_eval_count ?? null;
6
+ const outputTokens = usage?.completion_tokens ?? usage?.outputTokens ?? raw?.eval_count ?? null;
7
+ const totalTokens = usage?.total_tokens ?? usage?.totalTokens ?? (inputTokens !== null && outputTokens !== null ? inputTokens + outputTokens : null);
8
+ const evalDurationNs = raw?.eval_duration;
9
+ const outputTokensPerSecond = outputTokens !== null && typeof evalDurationNs === "number" && evalDurationNs > 0
10
+ ? outputTokens / (evalDurationNs / 1_000_000_000)
11
+ : null;
12
+ return { inputTokens, outputTokens, totalTokens, outputTokensPerSecond };
13
+ }
14
+
15
+ export function metricsFromChat(result: { elapsedMs: number; raw?: unknown; requestCount?: number; retryCount?: number; startedAt?: string; finishedAt?: string; timeToFirstTokenMs?: number | null; toolCalls?: any[] }): RequestMetrics {
16
+ return {
17
+ requestCount: result.requestCount ?? 1,
18
+ retryCount: result.retryCount ?? 0,
19
+ wallTimeMs: result.elapsedMs,
20
+ timeToAnswerMs: result.elapsedMs,
21
+ timeToFirstTokenMs: result.timeToFirstTokenMs ?? null,
22
+ startedAt: result.startedAt ?? null,
23
+ finishedAt: result.finishedAt ?? null,
24
+ ...usageFromRaw(result.raw),
25
+ toolCalls: (result.toolCalls ?? []).map((call: any) => ({
26
+ name: call.function?.name ?? call.name ?? "?",
27
+ arguments: call.function?.arguments ?? call.arguments,
28
+ })),
29
+ };
30
+ }
31
+
32
+ export function emptyMetrics(): RequestMetrics {
33
+ return { requestCount: 1, retryCount: 0, wallTimeMs: 0, timeToAnswerMs: 0, timeToFirstTokenMs: null, startedAt: null, finishedAt: null, inputTokens: null, outputTokens: null, totalTokens: null, outputTokensPerSecond: null, toolCalls: [] };
34
+ }
35
+
36
+ export function mergeRequestMetrics(metrics: RequestMetrics[]): RequestMetrics {
37
+ const knownTotal = (key: "inputTokens" | "outputTokens" | "totalTokens") => metrics.length && metrics.every(metric => metric[key] !== null)
38
+ ? metrics.reduce((sum, metric) => sum + (metric[key] as number), 0) : null;
39
+ const outputSeconds = metrics.reduce((sum, metric) => sum + (metric.outputTokens && metric.outputTokensPerSecond ? metric.outputTokens / metric.outputTokensPerSecond : 0), 0);
40
+ const outputTokens = knownTotal("outputTokens");
41
+ return {
42
+ requestCount: metrics.reduce((sum, metric) => sum + metric.requestCount, 0),
43
+ retryCount: metrics.reduce((sum, metric) => sum + metric.retryCount, 0),
44
+ wallTimeMs: metrics.reduce((sum, metric) => sum + metric.wallTimeMs, 0),
45
+ timeToAnswerMs: metrics.reduce((sum, metric) => sum + metric.timeToAnswerMs, 0),
46
+ timeToFirstTokenMs: metrics.find(metric => metric.timeToFirstTokenMs !== null)?.timeToFirstTokenMs ?? null,
47
+ startedAt: metrics.find(metric => metric.startedAt !== null)?.startedAt ?? null,
48
+ finishedAt: [...metrics].reverse().find(metric => metric.finishedAt !== null)?.finishedAt ?? null,
49
+ inputTokens: knownTotal("inputTokens"), outputTokens, totalTokens: knownTotal("totalTokens"),
50
+ outputTokensPerSecond: outputTokens !== null && outputSeconds > 0 ? outputTokens / outputSeconds : null,
51
+ toolCalls: metrics.flatMap(metric => metric.toolCalls),
52
+ };
53
+ }
54
+
55
+ export function aggregateMetrics(tests: TestRecord[]) {
56
+ const values = tests.map(t => t.metrics.wallTimeMs).sort((a, b) => a - b);
57
+ const knownTotal = (key: "inputTokens" | "outputTokens" | "totalTokens") => {
58
+ const known = tests.map(t => t.metrics[key]).filter((value): value is number => value !== null);
59
+ return known.length === tests.length ? known.reduce((sum, value) => sum + value, 0) : null;
60
+ };
61
+ const toolCallsByName: Record<string, number> = {};
62
+ for (const test of tests) for (const call of test.metrics.toolCalls) toolCallsByName[call.name] = (toolCallsByName[call.name] ?? 0) + 1;
63
+ const outputRateNumerator = tests.reduce((sum, test) => sum + (test.metrics.outputTokens ?? 0), 0);
64
+ const outputRateDenominator = tests.reduce((sum, test) => sum + (test.metrics.outputTokensPerSecond && test.metrics.outputTokens ? test.metrics.outputTokens / test.metrics.outputTokensPerSecond : 0), 0);
65
+ const percentile = values.length ? values[Math.min(values.length - 1, Math.ceil(values.length * .95) - 1)] : 0;
66
+ return {
67
+ requests: { total: tests.reduce((sum, test) => sum + test.metrics.requestCount, 0), successful: tests.filter(test => !test.error).length, failed: tests.filter(test => !!test.error).length, retries: tests.reduce((sum, test) => sum + test.metrics.retryCount, 0) },
68
+ toolCalls: { total: tests.reduce((sum, test) => sum + test.metrics.toolCalls.length, 0), byName: toolCallsByName },
69
+ inputTokens: knownTotal("inputTokens"), outputTokens: knownTotal("outputTokens"), totalTokens: knownTotal("totalTokens"),
70
+ outputTokensPerSecond: outputRateDenominator > 0 ? outputRateNumerator / outputRateDenominator : null,
71
+ latency: { averageMs: values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0, medianMs: values.length ? values[Math.floor(values.length / 2)] : 0, p95Ms: percentile, minMs: values[0] ?? 0, maxMs: values.at(-1) ?? 0 },
72
+ };
73
+ }
@@ -0,0 +1,46 @@
1
+ import { fail, info, ok, section, warn, msHuman } from "./util/format";
2
+
3
+ export const branding = " ⚡ OPL Simplebench";
4
+
5
+ export function formatInstructionScore(instructions: { pass: boolean; score: string; schemaValid: boolean }): string {
6
+ const message = `Instruction following: ${instructions.score} (schema ${instructions.schemaValid ? "valid" : "invalid"})`;
7
+ return instructions.pass ? ok(message) : fail(message);
8
+ }
9
+
10
+ export function formatTestSummary(tests: Array<{ name: string; pass: boolean; score: string }>, totalMs: number): string[] {
11
+ return [section("SUMMARY"), ...tests.map(t => t.pass ? ok(`${t.name}: ${t.score}`) : fail(`${t.name}: ${t.score}`)), info(`Total time: ${msHuman(totalMs)}`), info(`Score: ${tests.filter(t => t.pass).length}/${tests.length} tests passed`)];
12
+ }
13
+ /** Legacy-compatible category recommendation. New runner uses recommendation() above. */
14
+ export function formatRecommendation(model: string, passed: number, total: number): string[] {
15
+ const label = passed === total ? "STRONG" : passed >= total - 1 ? "GOOD" : passed >= total - 2 ? "USABLE" : "WEAK";
16
+ return [section("RECOMMENDATION"), label === "WEAK" ? fail(`${model} is ${label}`) : ok(`${model} is ${label}`)];
17
+ }
18
+
19
+ export function recommendation(reasoning: string, instructionsPass: boolean, toolsPass: boolean, coding?: { passed: number; total: number; efficiency?: { strong: number; moderate: number; weak: number; fail: number } }) {
20
+ const reasoningPass = reasoning === "STRONG" || reasoning === "MODERATE";
21
+ // Efficiency-weighted coding pass: STRONG=1.0, MODERATE=0.7, WEAK=0.4, FAIL=0
22
+ const codingPass = coding ? (() => {
23
+ if (!coding.efficiency) return coding.passed >= Math.ceil(coding.total / 2);
24
+ const weighted = coding.efficiency.strong * 1.0 + coding.efficiency.moderate * 0.7 + coding.efficiency.weak * 0.4;
25
+ return coding.total > 0 && weighted / coding.total >= 0.5;
26
+ })() : undefined;
27
+ const passed = Number(reasoningPass) + Number(instructionsPass) + Number(toolsPass) + (codingPass !== undefined ? Number(codingPass) : 0);
28
+ const total = coding ? 4 : 3;
29
+ const allCodingStrong = coding?.efficiency ? coding.efficiency.strong === coding.total : coding?.passed === coding?.total;
30
+ const label = coding
31
+ ? passed === 4 ? reasoning === "STRONG" && allCodingStrong ? "STRONG" : "GOOD" : passed === 3 ? "USABLE" : passed === 2 ? "LIMITED" : passed === 1 ? "LIMITED" : "WEAK"
32
+ : passed === 3 ? reasoning === "STRONG" ? "STRONG" : "GOOD" : passed === 2 ? "USABLE" : passed === 1 ? "LIMITED" : "WEAK";
33
+ return { label, passed, total };
34
+ }
35
+
36
+ export function codingRecommendation(passed: number, total: number) {
37
+ const ratio = total > 0 ? passed / total : 0;
38
+ const label = total > 0 && passed === total ? "STRONG" : ratio >= 0.5 ? "GOOD" : passed > 0 ? "USABLE" : "WEAK";
39
+ return { label, passed, total };
40
+ }
41
+
42
+ export function renderSummary(model: string, reasoning: { score: string; passed: number; total: number }, instructions: { score: string; passed: boolean; output: string }, tools: { score: string; passed: boolean; calls: string[]; response: string }, artifact: string | null, metrics: any, coding?: { passed: number; total: number }): string {
43
+ const overall = recommendation(reasoning.score, instructions.passed, tools.passed, coding);
44
+ const render = (score: string, text: string) => score === "STRONG" || score === "MODERATE" ? ok(text) : score === "WEAK" ? warn(text) : fail(text);
45
+ return [section(`SIMPLEBENCH: ${model}`), render(reasoning.score, `Closed-answer contract: ${reasoning.score} (${reasoning.passed}/${reasoning.total})`), render(instructions.score, `Instructions: ${instructions.score}`), info(`Instruction output: ${instructions.output}`), render(tools.score, `Tool usage: ${tools.score} (${tools.calls.join(", ") || "none"})`), info(`Tool response: ${tools.response}`), ...(coding ? [render(coding.passed >= Math.ceil(coding.total / 2) ? "MODERATE" : "FAIL", `Coding Lite: ${coding.passed}/${coding.total}`)] : []), section("METRICS"), info(`Requests: ${metrics.requests}; tool calls: ${metrics.toolCalls}; latency avg/p95: ${Math.round(metrics.latency.averageMs)}ms/${metrics.latency.p95Ms}ms`), section("RECOMMENDATION"), render(overall.label, `${model} is ${overall.label} (${overall.passed}/${overall.total} capability categories passed)`), info(artifact ? `Artifact: ${artifact}` : "Artifact: disabled (--no-artifact)")].join("\n");
46
+ }
@@ -0,0 +1,200 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ import { buildToolResultMessages, type ChatFn, type ChatMessage } from "./util/config";
6
+ import { mergeRequestMetrics, metricsFromChat } from "./metrics";
7
+ import type { RequestMetrics } from "./types";
8
+
9
+ export interface ResearchSource { id?: string; title: string; url: string; snippet: string; facts?: string[]; }
10
+
11
+ export interface GroundedResearchFixture {
12
+ id: string;
13
+ topic: string;
14
+ sources: Array<{ id: "S1" | "S2" | "S3"; title: string; url: string; facts: string[] }>;
15
+ claims: Array<{ text: string; sourceId: "S1" | "S2" | "S3" }>;
16
+ }
17
+
18
+ export const GROUNDED_URBAN_TREES_FIXTURE: GroundedResearchFixture = {
19
+ id: "urban-trees-1",
20
+ topic: "benefits of urban trees",
21
+ sources: [
22
+ { id: "S1", title: "Urban shade study", url: "https://research.fixture/S1", facts: ["Tree canopy reduces direct solar exposure on streets.", "Shaded pavement has lower surface temperatures."] },
23
+ { id: "S2", title: "Urban stormwater study", url: "https://research.fixture/S2", facts: ["Tree canopies intercept some rainfall before it reaches the ground."] },
24
+ { id: "S3", title: "Urban biodiversity study", url: "https://research.fixture/S3", facts: ["Urban trees provide habitat for birds."] },
25
+ ],
26
+ claims: [
27
+ { text: "Tree canopy reduces direct solar exposure on streets.", sourceId: "S1" },
28
+ { text: "Shaded pavement has lower surface temperatures.", sourceId: "S1" },
29
+ { text: "Tree canopies intercept some rainfall before it reaches the ground.", sourceId: "S2" },
30
+ ],
31
+ };
32
+
33
+ export async function searchConfiguredResearch(query: string, config: { researchSearchProvider?: "ddgs" | "searxng"; researchSearchUrl?: string; researchMaxResults?: number }): Promise<ResearchSource[]> {
34
+ const base = config.researchSearchUrl?.replace(/\/$/, "");
35
+ if (!base) throw new Error("researchSearchUrl is not configured in opl-simplebench.json");
36
+ const limit = config.researchMaxResults ?? 5;
37
+ const provider = config.researchSearchProvider ?? "ddgs";
38
+ const params = provider === "ddgs"
39
+ ? new URLSearchParams({ query, max_results: String(limit) })
40
+ : new URLSearchParams({ q: query, format: "json", categories: "general", safesearch: "0" });
41
+ const endpoint = provider === "ddgs" ? `${base}/search/text?${params}` : `${base}/search?${params}`;
42
+ const response = await fetch(endpoint);
43
+ if (!response.ok) throw new Error(`${provider} search returned ${response.status}`);
44
+ const data = await response.json() as any;
45
+ const results = Array.isArray(data.results) ? data.results : [];
46
+ return results.slice(0, limit).map((item: any) => ({ title: String(item.title ?? ""), url: String(item.href ?? item.url ?? ""), snippet: String(item.body ?? item.content ?? "") })).filter((item: ResearchSource) => item.url);
47
+ }
48
+ export interface ResearchArtifactResult {
49
+ id: "research-artifact" | "research-grounded";
50
+ passed: boolean;
51
+ score: "STRONG" | "MODERATE" | "WEAK" | "FAIL" | "ERROR";
52
+ error: string | null;
53
+ toolCalls: string[];
54
+ files: Record<string, string>;
55
+ metrics: RequestMetrics;
56
+ }
57
+
58
+ const MINIMALIST_UI_SKILL = "Use semantic HTML, a restrained palette, readable typography, no gradients, and no heavy shadows. Do not use any CSS box-shadow declaration.";
59
+ export const RESEARCH_TASK_PROMPT = `Research benefits of urban trees with the provided tools.
60
+
61
+ You MUST:
62
+ 1. Call web_search for real sources before writing.
63
+ 2. Call read_skill and apply its minimalist UI guidance to page.html.
64
+ 3. Write research.md with a concise synthesis and a ## Sources section containing Markdown links to returned source URLs.
65
+ 4. Write page.html as a minimal, responsive editorial page: semantic HTML with <main>, a viewport meta tag, restrained colors, readable type, no gradients, and no heavy shadows. Do not use any CSS box-shadow declaration.
66
+ Do not answer only in chat: create both files.`;
67
+ export const GROUNDED_RESEARCH_TASK_PROMPT = `Research benefits of urban trees with the provided tools.
68
+
69
+ You MUST:
70
+ 1. Call web_search and use only its source cards.
71
+ 2. Call read_skill and apply its minimalist UI guidance to page.html.
72
+ 3. Write research.md with a ## Findings section containing every provided required fact as a Markdown bullet followed by its inline source ID, such as [S1]. Under ## Sources, use exactly - [S1](https://research.fixture/S1) for S1 and the equivalent Markdown-link syntax for each cited source.
73
+ 4. Write page.html as a minimal, responsive editorial page: semantic HTML with <main>, a viewport meta tag, restrained colors, readable type, no gradients, and no heavy shadows. Do not use any CSS box-shadow declaration.
74
+ Do not answer only in chat: create both files.`;
75
+ const RESEARCH_TOOLS = [
76
+ { type: "function", function: { name: "web_search", description: "Search the web for sources.", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } } },
77
+ { type: "function", function: { name: "read_skill", description: "Read the benchmark-local minimalist UI design guidance.", parameters: { type: "object", properties: {} } } },
78
+ { type: "function", function: { name: "write_file", description: "Write research.md or page.html in the task directory.", parameters: { type: "object", properties: { path: { type: "string", enum: ["research.md", "page.html"] }, content: { type: "string" } }, required: ["path", "content"] } } },
79
+ ];
80
+
81
+ function parseArgs(value: unknown): Record<string, unknown> {
82
+ try { return typeof value === "string" ? JSON.parse(value) : (value as Record<string, unknown>) ?? {}; }
83
+ catch { return {}; }
84
+ }
85
+
86
+ function normalizeClaim(value: string): string {
87
+ return value.trim().replace(/[.!?]+$/, "").trim().toLowerCase();
88
+ }
89
+
90
+ export function verifyGroundedResearch(markdown: string, fixture: GroundedResearchFixture): { passed: boolean; missingClaims: string[]; wrongCitations: string[]; unknownCitations: string[] } {
91
+ const knownSources = new Map(fixture.sources.map(source => [source.id, source]));
92
+ const findings = markdown.match(/##\s+Findings\s*\n([\s\S]*?)(?=\n##\s|$)/i)?.[1] ?? "";
93
+ const sourceSection = markdown.match(/##\s+Sources\s*\n([\s\S]*?)(?=\n##\s|$)/i)?.[1] ?? "";
94
+ const citedClaims = [...findings.matchAll(/^\s*[-*]\s+(.+?)\s+\[([A-Za-z0-9]+)\]\s*$/gm)];
95
+ const unknownCitations = citedClaims.map(match => match[2]).filter(id => !knownSources.has(id));
96
+ const missingClaims: string[] = [];
97
+ const wrongCitations: string[] = [];
98
+ for (const claim of fixture.claims) {
99
+ const match = citedClaims.find(item => normalizeClaim(item[1]) === normalizeClaim(claim.text));
100
+ if (!match) missingClaims.push(claim.text);
101
+ else if (match[2] !== claim.sourceId) wrongCitations.push(claim.text);
102
+ }
103
+ for (const id of new Set(citedClaims.map(match => match[2]).filter(id => knownSources.has(id)))) {
104
+ const source = knownSources.get(id)!;
105
+ if (!new RegExp(`\\[${id}\\]\\(${source.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\)`).test(sourceSection)) wrongCitations.push(id);
106
+ }
107
+ return { passed: !missingClaims.length && !wrongCitations.length && !unknownCitations.length, missingClaims, wrongCitations, unknownCitations };
108
+ }
109
+
110
+ export function isMinimalistResearchHtml(html: string): boolean {
111
+ const shadows = html.match(/box-shadow\s*:\s*[^;}"']+/gi) ?? [];
112
+ return /<html[\s>]/i.test(html)
113
+ && /<main[\s>]/i.test(html)
114
+ && /name=["']viewport["']/i.test(html)
115
+ && !/(linear-gradient|radial-gradient)/i.test(html)
116
+ && shadows.every(shadow => /:\s*none\s*(?:!important)?\s*$/i.test(shadow));
117
+ }
118
+
119
+ function verify(files: Record<string, string>, tools: string[], sources: ResearchSource[]): { score: ResearchArtifactResult["score"]; passed: boolean; error: string | null } {
120
+ const markdown = files["research.md"] ?? "";
121
+ const html = files["page.html"] ?? "";
122
+ const searched = tools.includes("web_search");
123
+ const skillRead = tools.includes("read_skill");
124
+ const citesSource = /##\s+Sources/i.test(markdown) && sources.some(source => markdown.includes(source.url));
125
+ const htmlPasses = isMinimalistResearchHtml(html);
126
+ const complete = searched && skillRead && !!markdown && !!html && citesSource && htmlPasses;
127
+ if (complete) return { score: "STRONG", passed: true, error: null };
128
+ if (searched && (!!markdown || !!html)) return { score: "MODERATE", passed: false, error: "missing required research, skill, citation, or minimalist HTML evidence" };
129
+ return { score: "FAIL", passed: false, error: "research artifact task incomplete" };
130
+ }
131
+
132
+ export async function runResearchArtifactTask(chatFn: ChatFn, model: string, options: { search: (query: string) => Promise<ResearchSource[]>; maxTurns?: number; onProgress?: (message: string) => void; grounded?: boolean } ): Promise<ResearchArtifactResult> {
133
+ const grounded = options.grounded === true;
134
+ const id = grounded ? "research-grounded" as const : "research-artifact" as const;
135
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "simplebench-research-"));
136
+ const tools: string[] = [];
137
+ const requestMetrics: RequestMetrics[] = [];
138
+ let sources: ResearchSource[] = [];
139
+ let error: string | null = null;
140
+ const messages: ChatMessage[] = [
141
+ { role: "system", content: "Complete a research artifact task using benchmark-local tools. Follow every requirement in the user task." },
142
+ { role: "user", content: grounded ? GROUNDED_RESEARCH_TASK_PROMPT : RESEARCH_TASK_PROMPT },
143
+ ];
144
+ try {
145
+ for (let turn = 0; turn < (options.maxTurns ?? 8); turn += 1) {
146
+ options.onProgress?.(`research-artifact: agent turn ${turn + 1}/${options.maxTurns ?? 8}...`);
147
+ const response = await chatFn(model, messages, { tools: RESEARCH_TOOLS });
148
+ requestMetrics.push(metricsFromChat(response));
149
+ const calls = response.toolCalls ?? [];
150
+ if (!calls.length) break;
151
+ const results: string[] = [];
152
+ for (const call of calls) {
153
+ const fn = call.function ?? call;
154
+ const name = fn.name;
155
+ const args = parseArgs(fn.arguments);
156
+ tools.push(name);
157
+ options.onProgress?.(`research-artifact: ${name}`);
158
+ try {
159
+ if (name === "web_search") {
160
+ sources = await options.search(String(args.query ?? ""));
161
+ results.push(JSON.stringify(sources));
162
+ } else if (name === "read_skill") {
163
+ results.push(MINIMALIST_UI_SKILL);
164
+ } else if (name === "write_file") {
165
+ const relative = String(args.path ?? "");
166
+ if (relative !== "research.md" && relative !== "page.html") throw new Error("only research.md and page.html are allowed");
167
+ fs.writeFileSync(path.join(root, relative), String(args.content ?? ""));
168
+ results.push("written");
169
+ } else throw new Error(`unknown tool: ${name}`);
170
+ } catch (e: any) { results.push(JSON.stringify({ error: e?.message || String(e) })); }
171
+ }
172
+ messages.push(...buildToolResultMessages(response.content, calls, results));
173
+ }
174
+ } catch (e: any) { error = e?.message || String(e); }
175
+ const files: Record<string, string> = {};
176
+ for (const name of ["research.md", "page.html"]) {
177
+ const file = path.join(root, name);
178
+ if (fs.existsSync(file)) files[name] = fs.readFileSync(file, "utf8");
179
+ }
180
+ fs.rmSync(root, { recursive: true, force: true });
181
+ const verified = error
182
+ ? { score: "ERROR" as const, passed: false, error }
183
+ : grounded
184
+ ? (() => {
185
+ const citations = verifyGroundedResearch(files["research.md"] ?? "", GROUNDED_URBAN_TREES_FIXTURE);
186
+ const html = files["page.html"] ?? "";
187
+ const complete = tools.includes("web_search") && tools.includes("read_skill") && citations.passed && isMinimalistResearchHtml(html);
188
+ return { score: complete ? "STRONG" as const : "FAIL" as const, passed: complete, error: complete ? null : "missing grounded claim, citation, source, skill, or minimalist HTML evidence" };
189
+ })()
190
+ : verify(files, tools, sources);
191
+ return { id, ...verified, toolCalls: tools, files, metrics: mergeRequestMetrics(requestMetrics) };
192
+ }
193
+
194
+ export function runGroundedResearchTask(chatFn: ChatFn, model: string, options: { maxTurns?: number; onProgress?: (message: string) => void } = {}): Promise<ResearchArtifactResult> {
195
+ return runResearchArtifactTask(chatFn, model, {
196
+ ...options,
197
+ grounded: true,
198
+ search: async () => GROUNDED_URBAN_TREES_FIXTURE.sources.map(source => ({ id: source.id, title: source.title, url: source.url, snippet: source.facts.join(" "), facts: source.facts })),
199
+ });
200
+ }
@@ -0,0 +1,48 @@
1
+ export function normalizeAnswer(value: string): string {
2
+ return value.trim().replace(/[.!?]+$/, "").trim().toLowerCase();
3
+ }
4
+
5
+ const NUMERIC_ANSWER = /^\d+$/;
6
+
7
+ function lastNumericToken(line: string): string | null {
8
+ const tokens = line.match(/\d+/g);
9
+ return tokens && tokens.length ? tokens[tokens.length - 1] : null;
10
+ }
11
+
12
+ export function extractAnswer(response: string, expected?: string): { answer: string; method: string } {
13
+ const lines = response.trimEnd().split("\n").map(line => line.trim()).filter(Boolean);
14
+ // Numeric contracts: scan backward for a line whose last numeric token is the
15
+ // expected value. This accepts naked "8" / "8 days" answers and skips trailing
16
+ // bare-number remnants that thinking templates leak after the real final line.
17
+ if (expected && NUMERIC_ANSWER.test(expected)) {
18
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
19
+ const token = lastNumericToken(lines[i]);
20
+ if (token === expected) {
21
+ return { answer: token, method: i === lines.length - 1 ? "final-line" : "expected-last-numeric" };
22
+ }
23
+ }
24
+ }
25
+ const line = lines.at(-1);
26
+ return line ? { answer: normalizeAnswer(line), method: "final-line" } : { answer: "?", method: "none" };
27
+ }
28
+
29
+ export function scoreReasoning(response: string, expected: string) {
30
+ const extracted = extractAnswer(response, expected);
31
+ const correct = extracted.answer === normalizeAnswer(expected);
32
+ return {
33
+ answer: extracted.answer,
34
+ extractionMethod: extracted.method,
35
+ matchedWords: [],
36
+ score: correct ? "STRONG" : "FAIL",
37
+ pass: correct,
38
+ };
39
+ }
40
+
41
+ export function averageScore(scores: string[]): string {
42
+ const weights: Record<string, number> = { STRONG: 3, MODERATE: 2, WEAK: 1, FAIL: 0, ERROR: 0 };
43
+ const average = scores.reduce((sum, score) => sum + (weights[score] || 0), 0) / scores.length;
44
+ if (average >= 2.5) return "STRONG";
45
+ if (average >= 1.5) return "MODERATE";
46
+ if (average >= 0.5) return "WEAK";
47
+ return "FAIL";
48
+ }