spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: prototypes/tui-spike/shared/metrics.ts4 * Description: In-process spike instrumentation — input-echo latency, memory sampling, ANSI op counting.5 * Every reported number is measured; nothing is estimated or fabricated (Absolute Rule #4).6 *7 * Author: Simon-Pierre Boucher8 * Contact: contact@spboucher.ai9 */1011import fs from "node:fs";12import path from "node:path";13import { countMatches, stripAnsi, visibleWidth } from "./ansi";1415interface PendingEcho {16 t: bigint; // hrtime at stdin 'data' arrival17 needle: string; // plain text the echoing frame must contain (e.g. "❯ " + composer)18}1920export class SpikeMetrics {21 readonly t0 = Date.now();22 latNs: number[] = [];23 latTimeline: { t: number; ms: number }[] = []; // per-sample latency with wall time offset24 jamLog: { t: number; needle: string; frameComposer: string | null }[] = [];25 unresolvedDropped = 0;26 private pending: PendingEcho[] = [];2728 writes = 0;29 bytes = 0;30 eraseLine = 0; // CSI 2K31 eraseDown = 0; // CSI 0J / CSI J32 eraseScreen = 0; // CSI 2J (full-screen clear — must stay 0)33 cursorUp = 0; // CSI n A / CSI n F34 syncFrames = 0; // DEC 2026 begin markers3536 framesFull = 0; // full live-region repaints (candidate A only)37 framesPartial = 0; // damage-tracked repaints (candidate A only)38 linesRepainted = 0; // individual live rows rewritten (candidate A only)39 settledBlocks = 0;40 turns = 0;41 resizes = 0;42 overflowLines = 0; // emitted visible lines wider than the terminal (resize-integrity proxy)4344 mem: { t: number; rss: number; heap: number }[] = [];45 private memTimer: NodeJS.Timeout | null = null;4647 constructor(public candidate: string) {}4849 startMem(everyMs = 1000): void {50 this.memTimer = setInterval(() => {51 const m = process.memoryUsage();52 this.mem.push({ t: Date.now() - this.t0, rss: m.rss, heap: m.heapUsed });53 }, everyMs);54 this.memTimer.unref();55 }5657 /** Call from the raw stdin 'data' handler with the arrival hrtime and the expected echo text. */58 expectEcho(t: bigint, needle: string): void {59 this.pending.push({ t, needle });60 }6162 /** Called by the patched stdout for every write. */63 onWrite(chunk: string, cols: number): void {64 const now = process.hrtime.bigint();65 this.writes++;66 this.bytes += Buffer.byteLength(chunk);67 this.eraseLine += countMatches(chunk, /\x1b\[2K/g);68 this.eraseDown += countMatches(chunk, /\x1b\[0?J/g);69 this.eraseScreen += countMatches(chunk, /\x1b\[[23]J/g);70 this.cursorUp += countMatches(chunk, /\x1b\[\d*[AF]/g);71 this.syncFrames += countMatches(chunk, /\x1b\[\?2026h/g);7273 // Typed characters are append-only in the harness, so each pending needle is a74 // prefix of the next; resolving in FIFO order is exact.75 while (this.pending.length > 0 && chunk.includes(this.pending[0].needle)) {76 const p = this.pending.shift()!;77 const ns = Number(now - p.t);78 this.latNs.push(ns);79 this.latTimeline.push({ t: Date.now() - this.t0, ms: Math.round((ns / 1e6) * 1000) / 1000 });80 }81 while (this.pending.length > 0 && now - this.pending[0].t > 2_000_000_000n) {82 const p = this.pending.shift()!;83 this.unresolvedDropped++;84 if (this.jamLog.length < 20) {85 const m = chunk.match(/❯ [^\n\x1b]*/);86 this.jamLog.push({87 t: Date.now() - this.t0,88 needle: p.needle,89 frameComposer: m === null ? null : m[0],90 });91 }92 }9394 for (const line of stripAnsi(chunk).split(/\r\n|\r|\n/)) {95 if (visibleWidth(line) > cols) this.overflowLines++;96 }97 }9899 report(): Record<string, unknown> {100 const lat = this.latNs.map((n) => n / 1e6).sort((a, b) => a - b);101 const pct = (p: number) =>102 lat.length === 0 ? null : lat[Math.min(lat.length - 1, Math.floor(p * lat.length))];103 const durationMs = Date.now() - this.t0;104105 // RSS slope over the last two thirds of the run (least squares), MB/min.106 const tail = this.mem.slice(Math.floor(this.mem.length / 3));107 let slopeMBperMin: number | null = null;108 if (tail.length >= 5) {109 const n = tail.length;110 const mt = tail.reduce((s, x) => s + x.t, 0) / n;111 const mr = tail.reduce((s, x) => s + x.rss, 0) / n;112 let num = 0;113 let den = 0;114 for (const x of tail) {115 num += (x.t - mt) * (x.rss - mr);116 den += (x.t - mt) ** 2;117 }118 slopeMBperMin = den === 0 ? null : ((num / den) * 60_000) / 1e6;119 }120 const mb = (b: number) => Math.round((b / 1e6) * 10) / 10;121122 return {123 candidate: this.candidate,124 durationMs,125 inputLatencyMs: {126 samples: lat.length,127 p50: pct(0.5),128 p95: pct(0.95),129 max: lat.length ? lat[lat.length - 1] : null,130 unresolvedDropped: this.unresolvedDropped,131 },132 io: {133 writes: this.writes,134 bytes: this.bytes,135 writesPerSec: Math.round((this.writes / durationMs) * 1000 * 10) / 10,136 },137 ansiOps: {138 eraseLine: this.eraseLine,139 eraseDown: this.eraseDown,140 eraseScreenFullClears: this.eraseScreen,141 cursorUp: this.cursorUp,142 dec2026SyncFrames: this.syncFrames,143 },144 paint: {145 framesFull: this.framesFull,146 framesPartial: this.framesPartial,147 linesRepainted: this.linesRepainted,148 settledBlocks: this.settledBlocks,149 turns: this.turns,150 resizes: this.resizes,151 overflowLines: this.overflowLines,152 },153 latencyTimeline: this.latTimeline,154 jamLog: this.jamLog,155 memory: {156 timeline: this.mem.map((x) => ({ t: x.t, rssMB: mb(x.rss), heapMB: mb(x.heap) })),157 samples: this.mem.length,158 firstRssMB: this.mem.length ? mb(this.mem[0].rss) : null,159 lastRssMB: this.mem.length ? mb(this.mem[this.mem.length - 1].rss) : null,160 maxRssMB: this.mem.length ? mb(Math.max(...this.mem.map((x) => x.rss))) : null,161 lastHeapMB: this.mem.length ? mb(this.mem[this.mem.length - 1].heap) : null,162 rssSlopeMBperMin: slopeMBperMin === null ? null : Math.round(slopeMBperMin * 100) / 100,163 },164 };165 }166167 save(file?: string): string {168 const target =169 file && file.length > 0170 ? file171 : path.join(process.cwd(), "out", `metrics-${this.candidate}-${Date.now()}.json`);172 fs.mkdirSync(path.dirname(target), { recursive: true });173 fs.writeFileSync(target, JSON.stringify(this.report(), null, 2) + "\n");174 return target;175 }176}177178/** Monkey-patch a write stream so every write is accounted. Returns an unpatch function. */179export function patchStdout(180 stream: NodeJS.WriteStream,181 metrics: SpikeMetrics,182 getCols: () => number,183): () => void {184 const orig = stream.write.bind(stream);185 (stream as unknown as { write: unknown }).write = (186 chunk: string | Uint8Array,187 ...rest: unknown[]188 ) => {189 try {190 const s = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");191 metrics.onWrite(s, getCols());192 } catch {193 /* accounting must never break rendering */194 }195 return (orig as (c: unknown, ...r: unknown[]) => boolean)(chunk, ...rest);196 };197 return () => {198 (stream as unknown as { write: unknown }).write = orig;199 };200}201