@mono-agent/agent-runtime 0.1.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.
Files changed (60) hide show
  1. package/ARCHITECTURE.md +219 -0
  2. package/LICENSE +674 -0
  3. package/README.md +430 -0
  4. package/package.json +46 -0
  5. package/src/agent/allowlists.js +49 -0
  6. package/src/agent/approval.js +211 -0
  7. package/src/agent/compaction.js +752 -0
  8. package/src/agent/index.js +40 -0
  9. package/src/agent/prompt/skill-index.js +66 -0
  10. package/src/agent/tool-bloat.js +164 -0
  11. package/src/agent/tools/bash.js +156 -0
  12. package/src/agent/tools/edit.js +15 -0
  13. package/src/agent/tools/glob.js +71 -0
  14. package/src/agent/tools/grep.js +84 -0
  15. package/src/agent/tools/index.js +17 -0
  16. package/src/agent/tools/pi-bridge.js +638 -0
  17. package/src/agent/tools/read.js +39 -0
  18. package/src/agent/tools/shared/constants.js +21 -0
  19. package/src/agent/tools/shared/dedup.js +31 -0
  20. package/src/agent/tools/shared/output-truncation.js +54 -0
  21. package/src/agent/tools/shared/path-resolver.js +156 -0
  22. package/src/agent/tools/shared/ripgrep.js +130 -0
  23. package/src/agent/tools/shared/runtime-context.js +69 -0
  24. package/src/agent/tools/web-fetch.js +59 -0
  25. package/src/agent/tools/web-search.js +21 -0
  26. package/src/agent/tools/write.js +14 -0
  27. package/src/agent/transcript.js +227 -0
  28. package/src/ai/backend.js +17 -0
  29. package/src/ai/cost.js +164 -0
  30. package/src/ai/failure.js +165 -0
  31. package/src/ai/file-change-stats.js +234 -0
  32. package/src/ai/index.js +16 -0
  33. package/src/ai/live-input-prompt.js +15 -0
  34. package/src/ai/observer.js +233 -0
  35. package/src/ai/providers/claude-cli.js +694 -0
  36. package/src/ai/providers/claude-sdk.js +864 -0
  37. package/src/ai/providers/claude-subagents.js +67 -0
  38. package/src/ai/providers/codex-app.js +1045 -0
  39. package/src/ai/providers/opencode-app.js +356 -0
  40. package/src/ai/providers/opencode-discovery.js +39 -0
  41. package/src/ai/providers/pi-events.js +62 -0
  42. package/src/ai/providers/pi-messages.js +68 -0
  43. package/src/ai/providers/pi-models.js +111 -0
  44. package/src/ai/providers/pi-sdk.js +1310 -0
  45. package/src/ai/registry.js +5 -0
  46. package/src/ai/runtime/capabilities-used.js +56 -0
  47. package/src/ai/runtime/capabilities.js +44 -0
  48. package/src/ai/runtime/context-windows.js +38 -0
  49. package/src/ai/runtime/fast-mode.js +8 -0
  50. package/src/ai/runtime/model-refs.js +144 -0
  51. package/src/ai/runtime/registry.js +57 -0
  52. package/src/ai/runtime/router.js +214 -0
  53. package/src/ai/runtime/sessions.js +126 -0
  54. package/src/ai/streaming/codex-events.js +139 -0
  55. package/src/ai/streaming/opencode-events.js +54 -0
  56. package/src/ai/types.js +70 -0
  57. package/src/index.js +23 -0
  58. package/src/pi-auth.js +80 -0
  59. package/src/runtime-brand.js +32 -0
  60. package/src/runtime.js +104 -0
@@ -0,0 +1,234 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ const FILE_CHANGE_SNAPSHOT_LIMIT_BYTES = 300_000;
5
+ const FILE_CHANGE_DIFF_LINE_LIMIT = 4000;
6
+ const FILE_CHANGE_HUNK_LINE_LIMIT = 2000;
7
+
8
+ function splitFileLines(text) {
9
+ if (!text) return [];
10
+ const lines = String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
11
+ if (lines[lines.length - 1] === "") lines.pop();
12
+ return lines;
13
+ }
14
+
15
+ export function readFileChangeSnapshot(path) {
16
+ try {
17
+ if (!path || !existsSync(path)) return { exists: false, line_count: 0 };
18
+ const stat = statSync(path);
19
+ if (!stat.isFile()) return { exists: false, line_count: 0, unavailable_reason: "not_file" };
20
+ if (stat.size > FILE_CHANGE_SNAPSHOT_LIMIT_BYTES) {
21
+ return { exists: true, size: stat.size, unavailable_reason: "too_large" };
22
+ }
23
+ const content = readFileSync(path, "utf8");
24
+ return {
25
+ exists: true,
26
+ size: stat.size,
27
+ content,
28
+ line_count: splitFileLines(content).length,
29
+ };
30
+ } catch (err) {
31
+ return { exists: false, line_count: 0, unavailable_reason: err?.code || "read_failed" };
32
+ }
33
+ }
34
+
35
+ function rollingLcsCount(beforeLines, afterLines) {
36
+ let previous = new Array(afterLines.length + 1).fill(0);
37
+ let current = new Array(afterLines.length + 1).fill(0);
38
+ for (let i = 1; i <= beforeLines.length; i += 1) {
39
+ for (let j = 1; j <= afterLines.length; j += 1) {
40
+ current[j] = beforeLines[i - 1] === afterLines[j - 1]
41
+ ? previous[j - 1] + 1
42
+ : Math.max(previous[j], current[j - 1]);
43
+ }
44
+ [previous, current] = [current, previous.fill(0)];
45
+ }
46
+ return previous[afterLines.length];
47
+ }
48
+
49
+ function fullLcsTableWithHunks(beforeLines, afterLines) {
50
+ const m = beforeLines.length;
51
+ const n = afterLines.length;
52
+ const stride = n + 1;
53
+ const dp = new Uint16Array((m + 1) * stride);
54
+ for (let i = 1; i <= m; i += 1) {
55
+ const row = i * stride;
56
+ const prev = (i - 1) * stride;
57
+ for (let j = 1; j <= n; j += 1) {
58
+ dp[row + j] = beforeLines[i - 1] === afterLines[j - 1]
59
+ ? dp[prev + (j - 1)] + 1
60
+ : Math.max(dp[prev + j], dp[row + (j - 1)]);
61
+ }
62
+ }
63
+ const common = dp[m * stride + n];
64
+ const changedAfter = [];
65
+ let i = m;
66
+ let j = n;
67
+ while (i > 0 && j > 0) {
68
+ if (beforeLines[i - 1] === afterLines[j - 1]) {
69
+ i -= 1; j -= 1;
70
+ } else if (dp[(i - 1) * stride + j] >= dp[i * stride + (j - 1)]) {
71
+ i -= 1;
72
+ } else {
73
+ changedAfter.push(j);
74
+ j -= 1;
75
+ }
76
+ }
77
+ while (j > 0) {
78
+ changedAfter.push(j);
79
+ j -= 1;
80
+ }
81
+ changedAfter.reverse();
82
+ return { common, hunks: positionsToRanges(changedAfter) };
83
+ }
84
+
85
+ function positionsToRanges(positions) {
86
+ if (!positions.length) return [];
87
+ const ranges = [];
88
+ let start = positions[0];
89
+ let end = positions[0];
90
+ for (let i = 1; i < positions.length; i += 1) {
91
+ if (positions[i] === end + 1) {
92
+ end = positions[i];
93
+ } else {
94
+ ranges.push({ start, end });
95
+ start = positions[i];
96
+ end = positions[i];
97
+ }
98
+ }
99
+ ranges.push({ start, end });
100
+ return ranges;
101
+ }
102
+
103
+ function lineDiffCounts(beforeContent, afterContent) {
104
+ const beforeLines = splitFileLines(beforeContent);
105
+ const afterLines = splitFileLines(afterContent);
106
+ const before = beforeLines.length;
107
+ const after = afterLines.length;
108
+ if (before > FILE_CHANGE_DIFF_LINE_LIMIT || after > FILE_CHANGE_DIFF_LINE_LIMIT) {
109
+ return {
110
+ before_lines: before,
111
+ after_lines: after,
112
+ unavailable_reason: "too_many_lines",
113
+ };
114
+ }
115
+
116
+ if (before <= FILE_CHANGE_HUNK_LINE_LIMIT && after <= FILE_CHANGE_HUNK_LINE_LIMIT) {
117
+ const { common, hunks } = fullLcsTableWithHunks(beforeLines, afterLines);
118
+ const added = after - common;
119
+ const removed = before - common;
120
+ return {
121
+ before_lines: before,
122
+ after_lines: after,
123
+ added_lines: added,
124
+ removed_lines: removed,
125
+ changed_lines: added + removed,
126
+ hunks,
127
+ };
128
+ }
129
+
130
+ const common = rollingLcsCount(beforeLines, afterLines);
131
+ const added = after - common;
132
+ const removed = before - common;
133
+ return {
134
+ before_lines: before,
135
+ after_lines: after,
136
+ added_lines: added,
137
+ removed_lines: removed,
138
+ changed_lines: added + removed,
139
+ };
140
+ }
141
+
142
+ export function statsForCompletedChange(change, before, after) {
143
+ const kind = change?.kind || "change";
144
+ if (before?.unavailable_reason || after?.unavailable_reason) {
145
+ return {
146
+ before_lines: before?.line_count,
147
+ after_lines: after?.line_count,
148
+ unavailable_reason: before?.unavailable_reason || after?.unavailable_reason,
149
+ };
150
+ }
151
+ if (kind === "add" && !before?.exists && after?.exists && typeof after.content === "string") {
152
+ const afterLines = splitFileLines(after.content).length;
153
+ const stats = { before_lines: 0, after_lines: afterLines, added_lines: afterLines, removed_lines: 0, changed_lines: afterLines };
154
+ if (afterLines > 0) stats.hunks = [{ start: 1, end: afterLines }];
155
+ return stats;
156
+ }
157
+ if (kind === "delete" && before?.exists && !after?.exists && typeof before.content === "string") {
158
+ const beforeLines = splitFileLines(before.content).length;
159
+ return { before_lines: beforeLines, after_lines: 0, added_lines: 0, removed_lines: beforeLines, changed_lines: beforeLines };
160
+ }
161
+ if (typeof before?.content === "string" && typeof after?.content === "string") {
162
+ return lineDiffCounts(before.content, after.content);
163
+ }
164
+ if (before?.exists || after?.exists) {
165
+ return {
166
+ before_lines: before?.line_count,
167
+ after_lines: after?.line_count,
168
+ unavailable_reason: "missing_snapshot",
169
+ };
170
+ }
171
+ return null;
172
+ }
173
+
174
+ export function fileChangeSummary(changes) {
175
+ const stats = changes.map((change) => change?.line_stats).filter(Boolean);
176
+ if (!stats.length) return null;
177
+ return {
178
+ files: changes.length,
179
+ added_lines: stats.reduce((sum, item) => sum + (Number(item.added_lines) || 0), 0),
180
+ removed_lines: stats.reduce((sum, item) => sum + (Number(item.removed_lines) || 0), 0),
181
+ changed_lines: stats.reduce((sum, item) => sum + (Number(item.changed_lines) || 0), 0),
182
+ unavailable_count: stats.filter((item) => item.unavailable_reason).length,
183
+ };
184
+ }
185
+
186
+ function snapshotKey(id, path) {
187
+ return `${id}:${path}`;
188
+ }
189
+
190
+ export function createFileChangePayload(raw, { cwd = process.cwd(), snapshots = new Map() } = {}) {
191
+ const item = raw?.item || {};
192
+ const id = item.id || "file_change";
193
+ const changes = (Array.isArray(item.changes) ? item.changes : []).map((change) => {
194
+ const resolvedPath = change?.path ? resolve(cwd, change.path) : "";
195
+ if (!resolvedPath) return change;
196
+ if (raw.type === "item.started") {
197
+ const before = readFileChangeSnapshot(resolvedPath);
198
+ snapshots.set(snapshotKey(id, resolvedPath), before);
199
+ return change;
200
+ }
201
+ const before = snapshots.get(snapshotKey(id, resolvedPath)) || null;
202
+ const after = readFileChangeSnapshot(resolvedPath);
203
+ snapshots.delete(snapshotKey(id, resolvedPath));
204
+ const lineStats = statsForCompletedChange(change, before, after);
205
+ return lineStats ? { ...change, line_stats: lineStats } : change;
206
+ });
207
+ const summary = fileChangeSummary(changes) || item.summary;
208
+ return {
209
+ changes,
210
+ status: item.status || (raw.type === "item.completed" ? "completed" : "in_progress"),
211
+ ...(summary ? { summary } : {}),
212
+ };
213
+ }
214
+
215
+ export function createFileEditToolUseEvent(id, payload) {
216
+ return {
217
+ type: "assistant",
218
+ message: { content: [{ type: "tool_use", id, name: "file_edit", input: payload }] },
219
+ };
220
+ }
221
+
222
+ export function createFileEditToolResultEvent(id, payload, { isError = false } = {}) {
223
+ return {
224
+ type: "user",
225
+ message: {
226
+ content: [{
227
+ type: "tool_result",
228
+ tool_use_id: id,
229
+ content: payload,
230
+ is_error: isError,
231
+ }],
232
+ },
233
+ };
234
+ }
@@ -0,0 +1,16 @@
1
+ // Public surface of the provider layer.
2
+
3
+ export * from "./registry.js";
4
+ export * from "./runtime/model-refs.js";
5
+ export * from "./runtime/registry.js";
6
+ export {
7
+ createSessionRegistry,
8
+ disposeAllProviderSessions,
9
+ disposeProviderSession,
10
+ } from "./runtime/sessions.js";
11
+ export { createMetricsObserver, createObserverHub } from "./observer.js";
12
+ export {
13
+ buildCapabilitiesUsed,
14
+ toolCompactionAppliedFromWarnings,
15
+ UNKNOWN_CAPABILITY,
16
+ } from "./runtime/capabilities-used.js";
@@ -0,0 +1,15 @@
1
+ // Live-input prompt fragment used by AI providers when injecting human
2
+ // guidance mid-run. Lives in src/ai/ because providers are the only
3
+ // consumers; the queue/normalize/supports helpers stay in core/live-input.js
4
+ // for the API + coordinator + worker callers that don't need this string.
5
+
6
+ export function formatLiveInputGuidance(text) {
7
+ return [
8
+ "Live guidance from the user:",
9
+ String(text || ""),
10
+ "",
11
+ "Apply this guidance before continuing. It may correct, narrow, or override your current approach.",
12
+ "Keep satisfying the original task and existing comments except where this live guidance conflicts with them.",
13
+ "When there is a conflict, the newest human live guidance wins. Do not discard the broader task unless the user explicitly asks to replace it.",
14
+ ].join("\n");
15
+ }
@@ -0,0 +1,233 @@
1
+ // Observer registry + streaming telemetry.
2
+ //
3
+ // The runtime supports multiple observers per call. An observer is anything
4
+ // with a `recordEvent(event)` (and optionally `recordMetric(metric)` and
5
+ // `flush()`) method. Observers register at runtime construction time via
6
+ // `host.observers`, or per-call via `options.observers`. The existing
7
+ // `options.onEvent` callback is still respected — internally it becomes a
8
+ // thin observer so the rest of the kernel can keep emitting events through
9
+ // one channel.
10
+ //
11
+ // A built-in `createMetricsObserver()` aggregates cumulative cost, cache
12
+ // hit rate, token totals, tool-call counts, error counts, and turn-latency
13
+ // percentiles. Hosts that want their own aggregation implement the same
14
+ // interface and register alongside (or instead of) the built-in one.
15
+ //
16
+ // All observers receive events synchronously on the hot path; if an
17
+ // observer needs to do I/O it must buffer internally (zeroclaw uses the
18
+ // same contract — fan-out is sync, batching is the observer's problem).
19
+
20
+ /**
21
+ * @typedef Observer
22
+ * @property {string=} name
23
+ * @property {(event: object) => void} recordEvent
24
+ * @property {(metric: object) => void=} recordMetric
25
+ * @property {() => (void | Promise<void>)=} flush
26
+ */
27
+
28
+ export function createObserverHub({ observers = [], onEvent = null } = {}) {
29
+ const list = [];
30
+ for (const observer of observers || []) addObserver(list, observer);
31
+ if (typeof onEvent === "function") {
32
+ list.push({
33
+ name: "host.onEvent",
34
+ recordEvent: (event) => { try { onEvent(event); } catch { /* host emit errors don't escape */ } },
35
+ });
36
+ }
37
+
38
+ function emit(event) {
39
+ if (!event) return;
40
+ for (const obs of list) {
41
+ try { obs.recordEvent(event); } catch { /* swallow */ }
42
+ }
43
+ }
44
+
45
+ function recordMetric(metric) {
46
+ if (!metric) return;
47
+ for (const obs of list) {
48
+ if (typeof obs.recordMetric === "function") {
49
+ try { obs.recordMetric(metric); } catch { /* swallow */ }
50
+ }
51
+ }
52
+ }
53
+
54
+ async function flush() {
55
+ for (const obs of list) {
56
+ if (typeof obs.flush === "function") {
57
+ try { await obs.flush(); } catch { /* swallow */ }
58
+ }
59
+ }
60
+ }
61
+
62
+ return {
63
+ emit,
64
+ recordMetric,
65
+ flush,
66
+ observers: () => list.slice(),
67
+ };
68
+ }
69
+
70
+ function addObserver(list, observer) {
71
+ if (!observer || typeof observer.recordEvent !== "function") return;
72
+ list.push(observer);
73
+ }
74
+
75
+ // Built-in aggregator. Pure in-memory; never throws.
76
+ //
77
+ // Snapshot shape:
78
+ // {
79
+ // events: { total, byType: { "tool_use": 12, ... } },
80
+ // tokens: { input, output, cacheReadTokens, cacheCreationTokens },
81
+ // cost: { cumulativeUsd },
82
+ // cache: { hits, misses, hitRatio }, // hitRatio in [0,1]; null if no signal
83
+ // tools: { callsByName: { ... }, errorsByName: { ... } },
84
+ // errors: { total, byKind: { ... } },
85
+ // turns: { count, latencyMsP50, latencyMsP95 },
86
+ // approvals: { pending, granted, denied },
87
+ // }
88
+ export function createMetricsObserver({ name = "metrics" } = {}) {
89
+ const state = {
90
+ eventsTotal: 0,
91
+ eventsByType: new Map(),
92
+ tokens: { input: 0, output: 0, cacheReadTokens: 0, cacheCreationTokens: 0 },
93
+ cumulativeCostUsd: 0,
94
+ cacheHits: 0,
95
+ cacheMisses: 0,
96
+ cacheReadTokensFromEvents: 0,
97
+ toolCallsByName: new Map(),
98
+ toolErrorsByName: new Map(),
99
+ errorTotal: 0,
100
+ errorsByKind: new Map(),
101
+ turnLatencies: [],
102
+ turnStartByModel: new Map(),
103
+ approvalPending: 0,
104
+ approvalGranted: 0,
105
+ approvalDenied: 0,
106
+ };
107
+
108
+ function tally(map, key, by = 1) {
109
+ if (!key) return;
110
+ map.set(key, (map.get(key) || 0) + by);
111
+ }
112
+
113
+ function recordEvent(event) {
114
+ if (!event || typeof event !== "object") return;
115
+ state.eventsTotal += 1;
116
+ const type = String(event.type || "unknown");
117
+ tally(state.eventsByType, type, 1);
118
+
119
+ if (type === "tool_use" || (type === "assistant" && Array.isArray(event.message?.content))) {
120
+ // Walk assistant content blocks for tool_use entries.
121
+ const blocks = type === "tool_use" ? [event] : (event.message?.content || []);
122
+ for (const block of blocks) {
123
+ if (block && block.type === "tool_use" && block.name) tally(state.toolCallsByName, block.name, 1);
124
+ }
125
+ }
126
+
127
+ if (type === "user" && Array.isArray(event.message?.content)) {
128
+ for (const block of event.message.content) {
129
+ if (block && block.type === "tool_result" && block.is_error) {
130
+ // tool_use_id alone doesn't carry the tool name; bridges set
131
+ // event.toolName when known. Fallback to "unknown".
132
+ tally(state.toolErrorsByName, event.toolName || "unknown", 1);
133
+ }
134
+ }
135
+ }
136
+
137
+ if (type === "runtime_warning" && event.warning_kind) {
138
+ // warnings are not errors but worth counting in `errorsByKind` only
139
+ // when they indicate a failure mode.
140
+ }
141
+
142
+ if (type === "error" || type === "cancelled") {
143
+ state.errorTotal += 1;
144
+ tally(state.errorsByKind, event.failureKind || event.reason || type, 1);
145
+ }
146
+
147
+ if (type === "cache_hit") {
148
+ state.cacheHits += 1;
149
+ if (Number.isFinite(Number(event.tokens))) state.cacheReadTokensFromEvents += Number(event.tokens);
150
+ }
151
+ if (type === "cache_miss") {
152
+ state.cacheMisses += 1;
153
+ }
154
+
155
+ if (type === "cost_accumulated") {
156
+ // bridges send the running total, not the delta, so use it as the
157
+ // current value rather than adding.
158
+ if (Number.isFinite(Number(event.cumulativeUsd))) {
159
+ state.cumulativeCostUsd = Number(event.cumulativeUsd);
160
+ }
161
+ if (event.tokens && typeof event.tokens === "object") {
162
+ if (Number.isFinite(Number(event.tokens.input))) state.tokens.input = Number(event.tokens.input);
163
+ if (Number.isFinite(Number(event.tokens.output))) state.tokens.output = Number(event.tokens.output);
164
+ if (Number.isFinite(Number(event.tokens.cacheReadTokens))) state.tokens.cacheReadTokens = Number(event.tokens.cacheReadTokens);
165
+ if (Number.isFinite(Number(event.tokens.cacheCreationTokens))) state.tokens.cacheCreationTokens = Number(event.tokens.cacheCreationTokens);
166
+ }
167
+ }
168
+
169
+ if (type === "provider_request_started" && event.model) {
170
+ state.turnStartByModel.set(event.model, (Number.isFinite(event.timestamp) ? event.timestamp : Date.now()));
171
+ }
172
+ if (type === "provider_request_completed" && event.model) {
173
+ const started = state.turnStartByModel.get(event.model);
174
+ if (started !== undefined) {
175
+ state.turnLatencies.push(Math.max(0, ((Number.isFinite(event.timestamp) ? event.timestamp : Date.now())) - started));
176
+ state.turnStartByModel.delete(event.model);
177
+ }
178
+ }
179
+ if (type === "turn_latency" && Number.isFinite(Number(event.durationMs))) {
180
+ state.turnLatencies.push(Number(event.durationMs));
181
+ }
182
+
183
+ if (type === "tool_approval_pending") state.approvalPending += 1;
184
+ if (type === "tool_approval_granted") state.approvalGranted += 1;
185
+ if (type === "tool_approval_denied") state.approvalDenied += 1;
186
+ }
187
+
188
+ function recordMetric() { /* future hook */ }
189
+
190
+ function snapshot() {
191
+ const cacheTotal = state.cacheHits + state.cacheMisses;
192
+ const hitRatio = cacheTotal > 0 ? state.cacheHits / cacheTotal : null;
193
+ return {
194
+ events: { total: state.eventsTotal, byType: Object.fromEntries(state.eventsByType) },
195
+ tokens: { ...state.tokens },
196
+ cost: { cumulativeUsd: state.cumulativeCostUsd },
197
+ cache: {
198
+ hits: state.cacheHits,
199
+ misses: state.cacheMisses,
200
+ hitRatio,
201
+ readTokensFromEvents: state.cacheReadTokensFromEvents,
202
+ },
203
+ tools: {
204
+ callsByName: Object.fromEntries(state.toolCallsByName),
205
+ errorsByName: Object.fromEntries(state.toolErrorsByName),
206
+ },
207
+ errors: { total: state.errorTotal, byKind: Object.fromEntries(state.errorsByKind) },
208
+ turns: percentilesFor(state.turnLatencies),
209
+ approvals: { pending: state.approvalPending, granted: state.approvalGranted, denied: state.approvalDenied },
210
+ };
211
+ }
212
+
213
+ return { name, recordEvent, recordMetric, snapshot };
214
+ }
215
+
216
+ function percentilesFor(samples) {
217
+ const arr = Array.isArray(samples) ? samples.filter((n) => Number.isFinite(n)).slice().sort((a, b) => a - b) : [];
218
+ if (!arr.length) return { count: 0, latencyMsP50: null, latencyMsP95: null };
219
+ return {
220
+ count: arr.length,
221
+ latencyMsP50: percentile(arr, 0.5),
222
+ latencyMsP95: percentile(arr, 0.95),
223
+ };
224
+ }
225
+
226
+ function percentile(sortedArr, q) {
227
+ if (!sortedArr.length) return null;
228
+ const rank = q * (sortedArr.length - 1);
229
+ const lo = Math.floor(rank);
230
+ const hi = Math.ceil(rank);
231
+ if (lo === hi) return sortedArr[lo];
232
+ return sortedArr[lo] + (sortedArr[hi] - sortedArr[lo]) * (rank - lo);
233
+ }