SPB Git

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%
7.1 KB · 201 lines typescript
Raw Blame History
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