@dreb/coding-agent 2.15.2 → 2.16.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.
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/core/agent-session.d.ts +7 -0
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +40 -0
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/performance-tracker.d.ts +54 -0
- package/dist/core/performance-tracker.d.ts.map +1 -0
- package/dist/core/performance-tracker.js +298 -0
- package/dist/core/performance-tracker.js.map +1 -0
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +24 -2
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/rpc/rpc-client.d.ts +12 -0
- package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client.js +7 -0
- package/dist/modes/rpc/rpc-client.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts +9 -0
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +7 -0
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-types.d.ts +17 -0
- package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-types.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface PerformanceEntry {
|
|
2
|
+
timestamp: string;
|
|
3
|
+
sessionId: string;
|
|
4
|
+
provider: string;
|
|
5
|
+
modelId: string;
|
|
6
|
+
outputTokens: number;
|
|
7
|
+
durationMs: number;
|
|
8
|
+
tps: number;
|
|
9
|
+
}
|
|
10
|
+
export interface RollingAverage {
|
|
11
|
+
median: number;
|
|
12
|
+
mean: number;
|
|
13
|
+
count: number;
|
|
14
|
+
}
|
|
15
|
+
export type PerformanceDeltaDirection = "above" | "below" | "stable";
|
|
16
|
+
export interface PerformanceDelta {
|
|
17
|
+
baselineMedian: number;
|
|
18
|
+
recentMedian: number;
|
|
19
|
+
percentDelta: number;
|
|
20
|
+
direction: PerformanceDeltaDirection;
|
|
21
|
+
baselineCount: number;
|
|
22
|
+
recentCount: number;
|
|
23
|
+
}
|
|
24
|
+
export declare class PerformanceTracker {
|
|
25
|
+
private static readonly PRUNE_INTERVAL_MS;
|
|
26
|
+
private static readonly LOCK_TIMEOUT_MS;
|
|
27
|
+
private static readonly PRUNE_LOCK_TIMEOUT_MS;
|
|
28
|
+
private static readonly STALE_LOCK_MS;
|
|
29
|
+
private logPath;
|
|
30
|
+
private lockPath;
|
|
31
|
+
private entries;
|
|
32
|
+
private pruneTimer;
|
|
33
|
+
private disposed;
|
|
34
|
+
constructor(logPath?: string);
|
|
35
|
+
record(entry: PerformanceEntry): void;
|
|
36
|
+
getRollingAverage(provider: string, modelId: string, count?: number): RollingAverage;
|
|
37
|
+
getPerformanceDelta(provider: string, modelId: string, recentCount?: number, baselineCount?: number, stablePercent?: number): PerformanceDelta;
|
|
38
|
+
getAllRollingAverages(windowMs?: number): Array<{
|
|
39
|
+
provider: string;
|
|
40
|
+
modelId: string;
|
|
41
|
+
median: number;
|
|
42
|
+
mean: number;
|
|
43
|
+
count: number;
|
|
44
|
+
}>;
|
|
45
|
+
prune(ageMs?: number): void;
|
|
46
|
+
dispose(): void;
|
|
47
|
+
private ensureDir;
|
|
48
|
+
private schedulePrune;
|
|
49
|
+
private readEntries;
|
|
50
|
+
private withLogLock;
|
|
51
|
+
private acquireLock;
|
|
52
|
+
private removeStaleLock;
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=performance-tracker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"performance-tracker.d.ts","sourceRoot":"","sources":["../../src/core/performance-tracker.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,gBAAgB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,cAAc;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,MAAM,yBAAyB,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,CAAC;AAErE,MAAM,WAAW,gBAAgB;IAChC,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,yBAAyB,CAAC;IACrC,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,kBAAkB;IAC9B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,iBAAiB,CAAuB;IAChE,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAQ;IAC/C,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAAQ;IACrD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAiB;IAEtD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,OAAO,CAAqB;IACpC,OAAO,CAAC,UAAU,CAA+C;IACjE,OAAO,CAAC,QAAQ,CAAS;IAEzB,YAAY,OAAO,CAAC,EAAE,MAAM,EAU3B;IAED,MAAM,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAepC;IAED,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,SAAM,GAAG,cAAc,CAgBhF;IAED,mBAAmB,CAClB,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,WAAW,SAAK,EAChB,aAAa,SAAS,EACtB,aAAa,SAAI,GACf,gBAAgB,CAiClB;IAED,qBAAqB,CACpB,QAAQ,SAAsB,GAC5B,KAAK,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAyB3F;IAED,KAAK,CAAC,KAAK,SAA2B,GAAG,IAAI,CAsC5C;IAED,OAAO,IAAI,IAAI,CAMd;IAED,OAAO,CAAC,SAAS;IAWjB,OAAO,CAAC,aAAa;IAQrB,OAAO,CAAC,WAAW;IA+BnB,OAAO,CAAC,WAAW;IAcnB,OAAO,CAAC,WAAW;IAgBnB,OAAO,CAAC,eAAe;CAUvB","sourcesContent":["import {\n\tappendFileSync,\n\tcloseSync,\n\tmkdirSync,\n\topenSync,\n\treadFileSync,\n\trenameSync,\n\trmSync,\n\tstatSync,\n\twriteFileSync,\n} from \"fs\";\nimport { dirname, join } from \"path\";\nimport { getPerformanceLogPath } from \"../config.js\";\n\nexport interface PerformanceEntry {\n\ttimestamp: string;\n\tsessionId: string;\n\tprovider: string;\n\tmodelId: string;\n\toutputTokens: number;\n\tdurationMs: number;\n\ttps: number;\n}\n\nexport interface RollingAverage {\n\tmedian: number;\n\tmean: number;\n\tcount: number;\n}\n\nexport type PerformanceDeltaDirection = \"above\" | \"below\" | \"stable\";\n\nexport interface PerformanceDelta {\n\tbaselineMedian: number;\n\trecentMedian: number;\n\tpercentDelta: number;\n\tdirection: PerformanceDeltaDirection;\n\tbaselineCount: number;\n\trecentCount: number;\n}\n\nexport class PerformanceTracker {\n\tprivate static readonly PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;\n\tprivate static readonly LOCK_TIMEOUT_MS = 1000;\n\tprivate static readonly PRUNE_LOCK_TIMEOUT_MS = 5000;\n\tprivate static readonly STALE_LOCK_MS = 5 * 60 * 1000;\n\n\tprivate logPath: string;\n\tprivate lockPath: string;\n\tprivate entries: PerformanceEntry[];\n\tprivate pruneTimer: ReturnType<typeof setInterval> | null = null;\n\tprivate disposed = false;\n\n\tconstructor(logPath?: string) {\n\t\tthis.logPath = logPath ?? getPerformanceLogPath();\n\t\tthis.lockPath = `${this.logPath}.lock`;\n\t\ttry {\n\t\t\tthis.entries = this.readEntries();\n\t\t} catch {\n\t\t\tthis.entries = [];\n\t\t}\n\t\tthis.ensureDir();\n\t\tthis.schedulePrune();\n\t}\n\n\trecord(entry: PerformanceEntry): void {\n\t\tif (this.disposed) return;\n\t\ttry {\n\t\t\tconst wrote = this.withLogLock(() => {\n\t\t\t\tappendFileSync(this.logPath, `${JSON.stringify(entry)}\\n`, \"utf8\");\n\t\t\t\treturn true;\n\t\t\t});\n\t\t\tif (!wrote) {\n\t\t\t\tconsole.warn(\"[PerformanceTracker] Failed to write performance entry: could not acquire log lock\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.entries.push(entry);\n\t\t} catch (error) {\n\t\t\tconsole.warn(`[PerformanceTracker] Failed to write performance entry: ${error}`);\n\t\t}\n\t}\n\n\tgetRollingAverage(provider: string, modelId: string, count = 100): RollingAverage {\n\t\tconst values = this.entries\n\t\t\t.filter((e) => e.provider === provider && e.modelId === modelId)\n\t\t\t.sort((a, b) => entryTime(b) - entryTime(a))\n\t\t\t.slice(0, count)\n\t\t\t.map((e) => e.tps);\n\n\t\tif (values.length === 0) {\n\t\t\treturn { median: 0, mean: 0, count: 0 };\n\t\t}\n\n\t\treturn {\n\t\t\tmedian: computeMedian(values),\n\t\t\tmean: computeMean(values),\n\t\t\tcount: values.length,\n\t\t};\n\t}\n\n\tgetPerformanceDelta(\n\t\tprovider: string,\n\t\tmodelId: string,\n\t\trecentCount = 10,\n\t\tbaselineCount = 10_000,\n\t\tstablePercent = 1,\n\t): PerformanceDelta {\n\t\tconst modelEntries = this.entries\n\t\t\t.filter((e) => e.provider === provider && e.modelId === modelId)\n\t\t\t.sort((a, b) => entryTime(b) - entryTime(a));\n\n\t\tconst baselineSlice = baselineCount > 0 ? baselineCount : modelEntries.length;\n\t\tconst baselineValues = modelEntries.slice(0, baselineSlice).map((e) => e.tps);\n\t\tconst recentValues = modelEntries.slice(0, recentCount).map((e) => e.tps);\n\n\t\tconst baselineMedian = computeMedian(baselineValues);\n\t\tconst recentMedian = computeMedian(recentValues);\n\t\tif (baselineValues.length < 3 || recentValues.length < 3 || baselineMedian <= 0) {\n\t\t\treturn {\n\t\t\t\tbaselineMedian,\n\t\t\t\trecentMedian,\n\t\t\t\tpercentDelta: 0,\n\t\t\t\tdirection: \"stable\",\n\t\t\t\tbaselineCount: baselineValues.length,\n\t\t\t\trecentCount: recentValues.length,\n\t\t\t};\n\t\t}\n\n\t\tconst percentDelta = ((recentMedian - baselineMedian) / baselineMedian) * 100;\n\t\tconst direction = Math.abs(percentDelta) < stablePercent ? \"stable\" : percentDelta > 0 ? \"above\" : \"below\";\n\n\t\treturn {\n\t\t\tbaselineMedian,\n\t\t\trecentMedian,\n\t\t\tpercentDelta,\n\t\t\tdirection,\n\t\t\tbaselineCount: baselineValues.length,\n\t\t\trecentCount: recentValues.length,\n\t\t};\n\t}\n\n\tgetAllRollingAverages(\n\t\twindowMs = 24 * 60 * 60 * 1000,\n\t): Array<{ provider: string; modelId: string; median: number; mean: number; count: number }> {\n\t\tconst cutoff = Date.now() - windowMs;\n\t\tconst filtered = this.entries.filter((e) => entryTime(e) >= cutoff);\n\n\t\tconst groups = new Map<string, number[]>();\n\t\tfor (const entry of filtered) {\n\t\t\tconst key = `${entry.provider}\\0${entry.modelId}`;\n\t\t\tconst arr = groups.get(key) ?? [];\n\t\t\tarr.push(entry.tps);\n\t\t\tgroups.set(key, arr);\n\t\t}\n\n\t\tconst results: Array<{ provider: string; modelId: string; median: number; mean: number; count: number }> = [];\n\t\tfor (const [key, values] of groups) {\n\t\t\tconst [provider, modelId] = key.split(\"\\0\");\n\t\t\tresults.push({\n\t\t\t\tprovider,\n\t\t\t\tmodelId,\n\t\t\t\tmedian: computeMedian(values),\n\t\t\t\tmean: computeMean(values),\n\t\t\t\tcount: values.length,\n\t\t\t});\n\t\t}\n\n\t\treturn results;\n\t}\n\n\tprune(ageMs = 30 * 24 * 60 * 60 * 1000): void {\n\t\tif (this.disposed) return;\n\t\tlet tempPath: string | undefined;\n\t\ttry {\n\t\t\tconst pruned = this.withLogLock(() => {\n\t\t\t\tconst cutoff = Date.now() - ageMs;\n\t\t\t\tconst sourceEntries = this.readEntries();\n\t\t\t\tconst kept: PerformanceEntry[] = [];\n\t\t\t\tconst lines: string[] = [];\n\n\t\t\t\tfor (const entry of sourceEntries) {\n\t\t\t\t\tif (entryTime(entry) >= cutoff) {\n\t\t\t\t\t\tkept.push(entry);\n\t\t\t\t\t\tlines.push(JSON.stringify(entry));\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\ttempPath = join(dirname(this.logPath), `.performance-prune-${process.pid}-${Date.now()}.jsonl`);\n\t\t\t\twriteFileSync(tempPath, lines.length > 0 ? `${lines.join(\"\\n\")}\\n` : \"\", \"utf8\");\n\t\t\t\trenameSync(tempPath, this.logPath);\n\t\t\t\ttempPath = undefined;\n\t\t\t\tthis.entries = kept;\n\t\t\t\treturn true;\n\t\t\t}, PerformanceTracker.PRUNE_LOCK_TIMEOUT_MS);\n\t\t\tif (!pruned) {\n\t\t\t\tconsole.warn(\"[PerformanceTracker] Failed to prune performance log: could not acquire log lock\");\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.warn(`[PerformanceTracker] Failed to prune performance log: ${error}`);\n\t\t} finally {\n\t\t\tif (tempPath) {\n\t\t\t\ttry {\n\t\t\t\t\trmSync(tempPath, { force: true });\n\t\t\t\t} catch {\n\t\t\t\t\t// Best-effort cleanup only\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tdispose(): void {\n\t\tthis.disposed = true;\n\t\tif (this.pruneTimer) {\n\t\t\tclearInterval(this.pruneTimer);\n\t\t\tthis.pruneTimer = null;\n\t\t}\n\t}\n\n\tprivate ensureDir(): void {\n\t\ttry {\n\t\t\tmkdirSync(dirname(this.logPath), { recursive: true });\n\t\t} catch (error) {\n\t\t\tif (isFileExistsError(error)) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconsole.warn(`[PerformanceTracker] Failed to create log directory: ${error}`);\n\t\t}\n\t}\n\n\tprivate schedulePrune(): void {\n\t\tif (this.disposed) return;\n\t\tthis.pruneTimer = setInterval(() => {\n\t\t\tthis.prune();\n\t\t}, PerformanceTracker.PRUNE_INTERVAL_MS);\n\t\tthis.pruneTimer?.unref?.();\n\t}\n\n\tprivate readEntries(): PerformanceEntry[] {\n\t\ttry {\n\t\t\tconst content = readFileSync(this.logPath, \"utf8\");\n\t\t\tconst entries: PerformanceEntry[] = [];\n\t\t\tfor (const line of content.split(\"\\n\")) {\n\t\t\t\tif (!line.trim()) continue;\n\t\t\t\ttry {\n\t\t\t\t\tconst parsed = JSON.parse(line) as unknown;\n\t\t\t\t\tif (!isValidPerformanceEntry(parsed)) {\n\t\t\t\t\t\tconsole.warn(`[PerformanceTracker] Skipping invalid performance entry line: ${line}`);\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tconst time = entryTime(parsed);\n\t\t\t\t\tif (!Number.isFinite(time)) {\n\t\t\t\t\t\tconsole.warn(`[PerformanceTracker] Skipping entry with invalid timestamp: ${line}`);\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tentries.push(parsed);\n\t\t\t\t} catch {\n\t\t\t\t\t// Skip malformed lines\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn entries;\n\t\t} catch (error) {\n\t\t\tif (isENOENT(error)) {\n\t\t\t\treturn [];\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\tprivate withLogLock<T>(operation: () => T, timeoutMs = PerformanceTracker.LOCK_TIMEOUT_MS): T | undefined {\n\t\tconst fd = this.acquireLock(timeoutMs);\n\t\tif (fd === undefined) return undefined;\n\t\ttry {\n\t\t\treturn operation();\n\t\t} finally {\n\t\t\ttry {\n\t\t\t\tcloseSync(fd);\n\t\t\t} finally {\n\t\t\t\trmSync(this.lockPath, { force: true });\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate acquireLock(timeoutMs: number): number | undefined {\n\t\tconst start = performance.now();\n\t\twhile (performance.now() - start <= timeoutMs) {\n\t\t\ttry {\n\t\t\t\treturn openSync(this.lockPath, \"wx\");\n\t\t\t} catch (error) {\n\t\t\t\tif (!isFileExistsError(error)) {\n\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t\tthis.removeStaleLock();\n\t\t\t\tsleepSync(10);\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate removeStaleLock(): void {\n\t\ttry {\n\t\t\tconst lockAgeMs = Date.now() - statSync(this.lockPath).mtimeMs;\n\t\t\tif (lockAgeMs > PerformanceTracker.STALE_LOCK_MS) {\n\t\t\t\trmSync(this.lockPath, { force: true });\n\t\t\t}\n\t\t} catch {\n\t\t\t// Lock disappeared between open attempts\n\t\t}\n\t}\n}\n\nfunction entryTime(entry: PerformanceEntry): number {\n\tconst time = new Date(entry.timestamp).getTime();\n\treturn Number.isFinite(time) ? time : NaN;\n}\n\nfunction computeMedian(values: number[]): number {\n\tif (values.length === 0) return 0;\n\tconst sorted = [...values].sort((a, b) => a - b);\n\tconst mid = Math.floor(sorted.length / 2);\n\tif (sorted.length % 2 === 1) {\n\t\treturn sorted[mid];\n\t}\n\treturn (sorted[mid - 1] + sorted[mid]) / 2;\n}\n\nfunction computeMean(values: number[]): number {\n\tif (values.length === 0) return 0;\n\treturn values.reduce((a, b) => a + b, 0) / values.length;\n}\n\nfunction isFileExistsError(error: unknown): boolean {\n\treturn typeof error === \"object\" && error !== null && \"code\" in error && error.code === \"EEXIST\";\n}\n\nfunction isENOENT(error: unknown): boolean {\n\treturn typeof error === \"object\" && error !== null && \"code\" in error && error.code === \"ENOENT\";\n}\n\nfunction isValidPerformanceEntry(entry: unknown): entry is PerformanceEntry {\n\tif (typeof entry !== \"object\" || entry === null) return false;\n\tconst e = entry as Record<string, unknown>;\n\treturn (\n\t\ttypeof e.timestamp === \"string\" &&\n\t\ttypeof e.provider === \"string\" &&\n\t\ttypeof e.modelId === \"string\" &&\n\t\ttypeof e.outputTokens === \"number\" &&\n\t\tNumber.isFinite(e.outputTokens) &&\n\t\ttypeof e.durationMs === \"number\" &&\n\t\tNumber.isFinite(e.durationMs) &&\n\t\ttypeof e.tps === \"number\" &&\n\t\tNumber.isFinite(e.tps)\n\t);\n}\n\nfunction sleepSync(ms: number): void {\n\tAtomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);\n}\n"]}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "fs";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { getPerformanceLogPath } from "../config.js";
|
|
4
|
+
export class PerformanceTracker {
|
|
5
|
+
static PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
static LOCK_TIMEOUT_MS = 1000;
|
|
7
|
+
static PRUNE_LOCK_TIMEOUT_MS = 5000;
|
|
8
|
+
static STALE_LOCK_MS = 5 * 60 * 1000;
|
|
9
|
+
logPath;
|
|
10
|
+
lockPath;
|
|
11
|
+
entries;
|
|
12
|
+
pruneTimer = null;
|
|
13
|
+
disposed = false;
|
|
14
|
+
constructor(logPath) {
|
|
15
|
+
this.logPath = logPath ?? getPerformanceLogPath();
|
|
16
|
+
this.lockPath = `${this.logPath}.lock`;
|
|
17
|
+
try {
|
|
18
|
+
this.entries = this.readEntries();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
this.entries = [];
|
|
22
|
+
}
|
|
23
|
+
this.ensureDir();
|
|
24
|
+
this.schedulePrune();
|
|
25
|
+
}
|
|
26
|
+
record(entry) {
|
|
27
|
+
if (this.disposed)
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
const wrote = this.withLogLock(() => {
|
|
31
|
+
appendFileSync(this.logPath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
32
|
+
return true;
|
|
33
|
+
});
|
|
34
|
+
if (!wrote) {
|
|
35
|
+
console.warn("[PerformanceTracker] Failed to write performance entry: could not acquire log lock");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
this.entries.push(entry);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
console.warn(`[PerformanceTracker] Failed to write performance entry: ${error}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
getRollingAverage(provider, modelId, count = 100) {
|
|
45
|
+
const values = this.entries
|
|
46
|
+
.filter((e) => e.provider === provider && e.modelId === modelId)
|
|
47
|
+
.sort((a, b) => entryTime(b) - entryTime(a))
|
|
48
|
+
.slice(0, count)
|
|
49
|
+
.map((e) => e.tps);
|
|
50
|
+
if (values.length === 0) {
|
|
51
|
+
return { median: 0, mean: 0, count: 0 };
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
median: computeMedian(values),
|
|
55
|
+
mean: computeMean(values),
|
|
56
|
+
count: values.length,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
getPerformanceDelta(provider, modelId, recentCount = 10, baselineCount = 10_000, stablePercent = 1) {
|
|
60
|
+
const modelEntries = this.entries
|
|
61
|
+
.filter((e) => e.provider === provider && e.modelId === modelId)
|
|
62
|
+
.sort((a, b) => entryTime(b) - entryTime(a));
|
|
63
|
+
const baselineSlice = baselineCount > 0 ? baselineCount : modelEntries.length;
|
|
64
|
+
const baselineValues = modelEntries.slice(0, baselineSlice).map((e) => e.tps);
|
|
65
|
+
const recentValues = modelEntries.slice(0, recentCount).map((e) => e.tps);
|
|
66
|
+
const baselineMedian = computeMedian(baselineValues);
|
|
67
|
+
const recentMedian = computeMedian(recentValues);
|
|
68
|
+
if (baselineValues.length < 3 || recentValues.length < 3 || baselineMedian <= 0) {
|
|
69
|
+
return {
|
|
70
|
+
baselineMedian,
|
|
71
|
+
recentMedian,
|
|
72
|
+
percentDelta: 0,
|
|
73
|
+
direction: "stable",
|
|
74
|
+
baselineCount: baselineValues.length,
|
|
75
|
+
recentCount: recentValues.length,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const percentDelta = ((recentMedian - baselineMedian) / baselineMedian) * 100;
|
|
79
|
+
const direction = Math.abs(percentDelta) < stablePercent ? "stable" : percentDelta > 0 ? "above" : "below";
|
|
80
|
+
return {
|
|
81
|
+
baselineMedian,
|
|
82
|
+
recentMedian,
|
|
83
|
+
percentDelta,
|
|
84
|
+
direction,
|
|
85
|
+
baselineCount: baselineValues.length,
|
|
86
|
+
recentCount: recentValues.length,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
getAllRollingAverages(windowMs = 24 * 60 * 60 * 1000) {
|
|
90
|
+
const cutoff = Date.now() - windowMs;
|
|
91
|
+
const filtered = this.entries.filter((e) => entryTime(e) >= cutoff);
|
|
92
|
+
const groups = new Map();
|
|
93
|
+
for (const entry of filtered) {
|
|
94
|
+
const key = `${entry.provider}\0${entry.modelId}`;
|
|
95
|
+
const arr = groups.get(key) ?? [];
|
|
96
|
+
arr.push(entry.tps);
|
|
97
|
+
groups.set(key, arr);
|
|
98
|
+
}
|
|
99
|
+
const results = [];
|
|
100
|
+
for (const [key, values] of groups) {
|
|
101
|
+
const [provider, modelId] = key.split("\0");
|
|
102
|
+
results.push({
|
|
103
|
+
provider,
|
|
104
|
+
modelId,
|
|
105
|
+
median: computeMedian(values),
|
|
106
|
+
mean: computeMean(values),
|
|
107
|
+
count: values.length,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return results;
|
|
111
|
+
}
|
|
112
|
+
prune(ageMs = 30 * 24 * 60 * 60 * 1000) {
|
|
113
|
+
if (this.disposed)
|
|
114
|
+
return;
|
|
115
|
+
let tempPath;
|
|
116
|
+
try {
|
|
117
|
+
const pruned = this.withLogLock(() => {
|
|
118
|
+
const cutoff = Date.now() - ageMs;
|
|
119
|
+
const sourceEntries = this.readEntries();
|
|
120
|
+
const kept = [];
|
|
121
|
+
const lines = [];
|
|
122
|
+
for (const entry of sourceEntries) {
|
|
123
|
+
if (entryTime(entry) >= cutoff) {
|
|
124
|
+
kept.push(entry);
|
|
125
|
+
lines.push(JSON.stringify(entry));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
tempPath = join(dirname(this.logPath), `.performance-prune-${process.pid}-${Date.now()}.jsonl`);
|
|
129
|
+
writeFileSync(tempPath, lines.length > 0 ? `${lines.join("\n")}\n` : "", "utf8");
|
|
130
|
+
renameSync(tempPath, this.logPath);
|
|
131
|
+
tempPath = undefined;
|
|
132
|
+
this.entries = kept;
|
|
133
|
+
return true;
|
|
134
|
+
}, PerformanceTracker.PRUNE_LOCK_TIMEOUT_MS);
|
|
135
|
+
if (!pruned) {
|
|
136
|
+
console.warn("[PerformanceTracker] Failed to prune performance log: could not acquire log lock");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
console.warn(`[PerformanceTracker] Failed to prune performance log: ${error}`);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
if (tempPath) {
|
|
144
|
+
try {
|
|
145
|
+
rmSync(tempPath, { force: true });
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Best-effort cleanup only
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
dispose() {
|
|
154
|
+
this.disposed = true;
|
|
155
|
+
if (this.pruneTimer) {
|
|
156
|
+
clearInterval(this.pruneTimer);
|
|
157
|
+
this.pruneTimer = null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
ensureDir() {
|
|
161
|
+
try {
|
|
162
|
+
mkdirSync(dirname(this.logPath), { recursive: true });
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (isFileExistsError(error)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
console.warn(`[PerformanceTracker] Failed to create log directory: ${error}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
schedulePrune() {
|
|
172
|
+
if (this.disposed)
|
|
173
|
+
return;
|
|
174
|
+
this.pruneTimer = setInterval(() => {
|
|
175
|
+
this.prune();
|
|
176
|
+
}, PerformanceTracker.PRUNE_INTERVAL_MS);
|
|
177
|
+
this.pruneTimer?.unref?.();
|
|
178
|
+
}
|
|
179
|
+
readEntries() {
|
|
180
|
+
try {
|
|
181
|
+
const content = readFileSync(this.logPath, "utf8");
|
|
182
|
+
const entries = [];
|
|
183
|
+
for (const line of content.split("\n")) {
|
|
184
|
+
if (!line.trim())
|
|
185
|
+
continue;
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(line);
|
|
188
|
+
if (!isValidPerformanceEntry(parsed)) {
|
|
189
|
+
console.warn(`[PerformanceTracker] Skipping invalid performance entry line: ${line}`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const time = entryTime(parsed);
|
|
193
|
+
if (!Number.isFinite(time)) {
|
|
194
|
+
console.warn(`[PerformanceTracker] Skipping entry with invalid timestamp: ${line}`);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
entries.push(parsed);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// Skip malformed lines
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return entries;
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (isENOENT(error)) {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
withLogLock(operation, timeoutMs = PerformanceTracker.LOCK_TIMEOUT_MS) {
|
|
213
|
+
const fd = this.acquireLock(timeoutMs);
|
|
214
|
+
if (fd === undefined)
|
|
215
|
+
return undefined;
|
|
216
|
+
try {
|
|
217
|
+
return operation();
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
try {
|
|
221
|
+
closeSync(fd);
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
rmSync(this.lockPath, { force: true });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
acquireLock(timeoutMs) {
|
|
229
|
+
const start = performance.now();
|
|
230
|
+
while (performance.now() - start <= timeoutMs) {
|
|
231
|
+
try {
|
|
232
|
+
return openSync(this.lockPath, "wx");
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
if (!isFileExistsError(error)) {
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
this.removeStaleLock();
|
|
239
|
+
sleepSync(10);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
removeStaleLock() {
|
|
245
|
+
try {
|
|
246
|
+
const lockAgeMs = Date.now() - statSync(this.lockPath).mtimeMs;
|
|
247
|
+
if (lockAgeMs > PerformanceTracker.STALE_LOCK_MS) {
|
|
248
|
+
rmSync(this.lockPath, { force: true });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Lock disappeared between open attempts
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function entryTime(entry) {
|
|
257
|
+
const time = new Date(entry.timestamp).getTime();
|
|
258
|
+
return Number.isFinite(time) ? time : NaN;
|
|
259
|
+
}
|
|
260
|
+
function computeMedian(values) {
|
|
261
|
+
if (values.length === 0)
|
|
262
|
+
return 0;
|
|
263
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
264
|
+
const mid = Math.floor(sorted.length / 2);
|
|
265
|
+
if (sorted.length % 2 === 1) {
|
|
266
|
+
return sorted[mid];
|
|
267
|
+
}
|
|
268
|
+
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
269
|
+
}
|
|
270
|
+
function computeMean(values) {
|
|
271
|
+
if (values.length === 0)
|
|
272
|
+
return 0;
|
|
273
|
+
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
274
|
+
}
|
|
275
|
+
function isFileExistsError(error) {
|
|
276
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
|
|
277
|
+
}
|
|
278
|
+
function isENOENT(error) {
|
|
279
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
280
|
+
}
|
|
281
|
+
function isValidPerformanceEntry(entry) {
|
|
282
|
+
if (typeof entry !== "object" || entry === null)
|
|
283
|
+
return false;
|
|
284
|
+
const e = entry;
|
|
285
|
+
return (typeof e.timestamp === "string" &&
|
|
286
|
+
typeof e.provider === "string" &&
|
|
287
|
+
typeof e.modelId === "string" &&
|
|
288
|
+
typeof e.outputTokens === "number" &&
|
|
289
|
+
Number.isFinite(e.outputTokens) &&
|
|
290
|
+
typeof e.durationMs === "number" &&
|
|
291
|
+
Number.isFinite(e.durationMs) &&
|
|
292
|
+
typeof e.tps === "number" &&
|
|
293
|
+
Number.isFinite(e.tps));
|
|
294
|
+
}
|
|
295
|
+
function sleepSync(ms) {
|
|
296
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
297
|
+
}
|
|
298
|
+
//# sourceMappingURL=performance-tracker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"performance-tracker.js","sourceRoot":"","sources":["../../src/core/performance-tracker.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n\tappendFileSync,\n\tcloseSync,\n\tmkdirSync,\n\topenSync,\n\treadFileSync,\n\trenameSync,\n\trmSync,\n\tstatSync,\n\twriteFileSync,\n} from \"fs\";\nimport { dirname, join } from \"path\";\nimport { getPerformanceLogPath } from \"../config.js\";\n\nexport interface PerformanceEntry {\n\ttimestamp: string;\n\tsessionId: string;\n\tprovider: string;\n\tmodelId: string;\n\toutputTokens: number;\n\tdurationMs: number;\n\ttps: number;\n}\n\nexport interface RollingAverage {\n\tmedian: number;\n\tmean: number;\n\tcount: number;\n}\n\nexport type PerformanceDeltaDirection = \"above\" | \"below\" | \"stable\";\n\nexport interface PerformanceDelta {\n\tbaselineMedian: number;\n\trecentMedian: number;\n\tpercentDelta: number;\n\tdirection: PerformanceDeltaDirection;\n\tbaselineCount: number;\n\trecentCount: number;\n}\n\nexport class PerformanceTracker {\n\tprivate static readonly PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;\n\tprivate static readonly LOCK_TIMEOUT_MS = 1000;\n\tprivate static readonly PRUNE_LOCK_TIMEOUT_MS = 5000;\n\tprivate static readonly STALE_LOCK_MS = 5 * 60 * 1000;\n\n\tprivate logPath: string;\n\tprivate lockPath: string;\n\tprivate entries: PerformanceEntry[];\n\tprivate pruneTimer: ReturnType<typeof setInterval> | null = null;\n\tprivate disposed = false;\n\n\tconstructor(logPath?: string) {\n\t\tthis.logPath = logPath ?? getPerformanceLogPath();\n\t\tthis.lockPath = `${this.logPath}.lock`;\n\t\ttry {\n\t\t\tthis.entries = this.readEntries();\n\t\t} catch {\n\t\t\tthis.entries = [];\n\t\t}\n\t\tthis.ensureDir();\n\t\tthis.schedulePrune();\n\t}\n\n\trecord(entry: PerformanceEntry): void {\n\t\tif (this.disposed) return;\n\t\ttry {\n\t\t\tconst wrote = this.withLogLock(() => {\n\t\t\t\tappendFileSync(this.logPath, `${JSON.stringify(entry)}\\n`, \"utf8\");\n\t\t\t\treturn true;\n\t\t\t});\n\t\t\tif (!wrote) {\n\t\t\t\tconsole.warn(\"[PerformanceTracker] Failed to write performance entry: could not acquire log lock\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.entries.push(entry);\n\t\t} catch (error) {\n\t\t\tconsole.warn(`[PerformanceTracker] Failed to write performance entry: ${error}`);\n\t\t}\n\t}\n\n\tgetRollingAverage(provider: string, modelId: string, count = 100): RollingAverage {\n\t\tconst values = this.entries\n\t\t\t.filter((e) => e.provider === provider && e.modelId === modelId)\n\t\t\t.sort((a, b) => entryTime(b) - entryTime(a))\n\t\t\t.slice(0, count)\n\t\t\t.map((e) => e.tps);\n\n\t\tif (values.length === 0) {\n\t\t\treturn { median: 0, mean: 0, count: 0 };\n\t\t}\n\n\t\treturn {\n\t\t\tmedian: computeMedian(values),\n\t\t\tmean: computeMean(values),\n\t\t\tcount: values.length,\n\t\t};\n\t}\n\n\tgetPerformanceDelta(\n\t\tprovider: string,\n\t\tmodelId: string,\n\t\trecentCount = 10,\n\t\tbaselineCount = 10_000,\n\t\tstablePercent = 1,\n\t): PerformanceDelta {\n\t\tconst modelEntries = this.entries\n\t\t\t.filter((e) => e.provider === provider && e.modelId === modelId)\n\t\t\t.sort((a, b) => entryTime(b) - entryTime(a));\n\n\t\tconst baselineSlice = baselineCount > 0 ? baselineCount : modelEntries.length;\n\t\tconst baselineValues = modelEntries.slice(0, baselineSlice).map((e) => e.tps);\n\t\tconst recentValues = modelEntries.slice(0, recentCount).map((e) => e.tps);\n\n\t\tconst baselineMedian = computeMedian(baselineValues);\n\t\tconst recentMedian = computeMedian(recentValues);\n\t\tif (baselineValues.length < 3 || recentValues.length < 3 || baselineMedian <= 0) {\n\t\t\treturn {\n\t\t\t\tbaselineMedian,\n\t\t\t\trecentMedian,\n\t\t\t\tpercentDelta: 0,\n\t\t\t\tdirection: \"stable\",\n\t\t\t\tbaselineCount: baselineValues.length,\n\t\t\t\trecentCount: recentValues.length,\n\t\t\t};\n\t\t}\n\n\t\tconst percentDelta = ((recentMedian - baselineMedian) / baselineMedian) * 100;\n\t\tconst direction = Math.abs(percentDelta) < stablePercent ? \"stable\" : percentDelta > 0 ? \"above\" : \"below\";\n\n\t\treturn {\n\t\t\tbaselineMedian,\n\t\t\trecentMedian,\n\t\t\tpercentDelta,\n\t\t\tdirection,\n\t\t\tbaselineCount: baselineValues.length,\n\t\t\trecentCount: recentValues.length,\n\t\t};\n\t}\n\n\tgetAllRollingAverages(\n\t\twindowMs = 24 * 60 * 60 * 1000,\n\t): Array<{ provider: string; modelId: string; median: number; mean: number; count: number }> {\n\t\tconst cutoff = Date.now() - windowMs;\n\t\tconst filtered = this.entries.filter((e) => entryTime(e) >= cutoff);\n\n\t\tconst groups = new Map<string, number[]>();\n\t\tfor (const entry of filtered) {\n\t\t\tconst key = `${entry.provider}\\0${entry.modelId}`;\n\t\t\tconst arr = groups.get(key) ?? [];\n\t\t\tarr.push(entry.tps);\n\t\t\tgroups.set(key, arr);\n\t\t}\n\n\t\tconst results: Array<{ provider: string; modelId: string; median: number; mean: number; count: number }> = [];\n\t\tfor (const [key, values] of groups) {\n\t\t\tconst [provider, modelId] = key.split(\"\\0\");\n\t\t\tresults.push({\n\t\t\t\tprovider,\n\t\t\t\tmodelId,\n\t\t\t\tmedian: computeMedian(values),\n\t\t\t\tmean: computeMean(values),\n\t\t\t\tcount: values.length,\n\t\t\t});\n\t\t}\n\n\t\treturn results;\n\t}\n\n\tprune(ageMs = 30 * 24 * 60 * 60 * 1000): void {\n\t\tif (this.disposed) return;\n\t\tlet tempPath: string | undefined;\n\t\ttry {\n\t\t\tconst pruned = this.withLogLock(() => {\n\t\t\t\tconst cutoff = Date.now() - ageMs;\n\t\t\t\tconst sourceEntries = this.readEntries();\n\t\t\t\tconst kept: PerformanceEntry[] = [];\n\t\t\t\tconst lines: string[] = [];\n\n\t\t\t\tfor (const entry of sourceEntries) {\n\t\t\t\t\tif (entryTime(entry) >= cutoff) {\n\t\t\t\t\t\tkept.push(entry);\n\t\t\t\t\t\tlines.push(JSON.stringify(entry));\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\ttempPath = join(dirname(this.logPath), `.performance-prune-${process.pid}-${Date.now()}.jsonl`);\n\t\t\t\twriteFileSync(tempPath, lines.length > 0 ? `${lines.join(\"\\n\")}\\n` : \"\", \"utf8\");\n\t\t\t\trenameSync(tempPath, this.logPath);\n\t\t\t\ttempPath = undefined;\n\t\t\t\tthis.entries = kept;\n\t\t\t\treturn true;\n\t\t\t}, PerformanceTracker.PRUNE_LOCK_TIMEOUT_MS);\n\t\t\tif (!pruned) {\n\t\t\t\tconsole.warn(\"[PerformanceTracker] Failed to prune performance log: could not acquire log lock\");\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.warn(`[PerformanceTracker] Failed to prune performance log: ${error}`);\n\t\t} finally {\n\t\t\tif (tempPath) {\n\t\t\t\ttry {\n\t\t\t\t\trmSync(tempPath, { force: true });\n\t\t\t\t} catch {\n\t\t\t\t\t// Best-effort cleanup only\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tdispose(): void {\n\t\tthis.disposed = true;\n\t\tif (this.pruneTimer) {\n\t\t\tclearInterval(this.pruneTimer);\n\t\t\tthis.pruneTimer = null;\n\t\t}\n\t}\n\n\tprivate ensureDir(): void {\n\t\ttry {\n\t\t\tmkdirSync(dirname(this.logPath), { recursive: true });\n\t\t} catch (error) {\n\t\t\tif (isFileExistsError(error)) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconsole.warn(`[PerformanceTracker] Failed to create log directory: ${error}`);\n\t\t}\n\t}\n\n\tprivate schedulePrune(): void {\n\t\tif (this.disposed) return;\n\t\tthis.pruneTimer = setInterval(() => {\n\t\t\tthis.prune();\n\t\t}, PerformanceTracker.PRUNE_INTERVAL_MS);\n\t\tthis.pruneTimer?.unref?.();\n\t}\n\n\tprivate readEntries(): PerformanceEntry[] {\n\t\ttry {\n\t\t\tconst content = readFileSync(this.logPath, \"utf8\");\n\t\t\tconst entries: PerformanceEntry[] = [];\n\t\t\tfor (const line of content.split(\"\\n\")) {\n\t\t\t\tif (!line.trim()) continue;\n\t\t\t\ttry {\n\t\t\t\t\tconst parsed = JSON.parse(line) as unknown;\n\t\t\t\t\tif (!isValidPerformanceEntry(parsed)) {\n\t\t\t\t\t\tconsole.warn(`[PerformanceTracker] Skipping invalid performance entry line: ${line}`);\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tconst time = entryTime(parsed);\n\t\t\t\t\tif (!Number.isFinite(time)) {\n\t\t\t\t\t\tconsole.warn(`[PerformanceTracker] Skipping entry with invalid timestamp: ${line}`);\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tentries.push(parsed);\n\t\t\t\t} catch {\n\t\t\t\t\t// Skip malformed lines\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn entries;\n\t\t} catch (error) {\n\t\t\tif (isENOENT(error)) {\n\t\t\t\treturn [];\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\tprivate withLogLock<T>(operation: () => T, timeoutMs = PerformanceTracker.LOCK_TIMEOUT_MS): T | undefined {\n\t\tconst fd = this.acquireLock(timeoutMs);\n\t\tif (fd === undefined) return undefined;\n\t\ttry {\n\t\t\treturn operation();\n\t\t} finally {\n\t\t\ttry {\n\t\t\t\tcloseSync(fd);\n\t\t\t} finally {\n\t\t\t\trmSync(this.lockPath, { force: true });\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate acquireLock(timeoutMs: number): number | undefined {\n\t\tconst start = performance.now();\n\t\twhile (performance.now() - start <= timeoutMs) {\n\t\t\ttry {\n\t\t\t\treturn openSync(this.lockPath, \"wx\");\n\t\t\t} catch (error) {\n\t\t\t\tif (!isFileExistsError(error)) {\n\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t\tthis.removeStaleLock();\n\t\t\t\tsleepSync(10);\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate removeStaleLock(): void {\n\t\ttry {\n\t\t\tconst lockAgeMs = Date.now() - statSync(this.lockPath).mtimeMs;\n\t\t\tif (lockAgeMs > PerformanceTracker.STALE_LOCK_MS) {\n\t\t\t\trmSync(this.lockPath, { force: true });\n\t\t\t}\n\t\t} catch {\n\t\t\t// Lock disappeared between open attempts\n\t\t}\n\t}\n}\n\nfunction entryTime(entry: PerformanceEntry): number {\n\tconst time = new Date(entry.timestamp).getTime();\n\treturn Number.isFinite(time) ? time : NaN;\n}\n\nfunction computeMedian(values: number[]): number {\n\tif (values.length === 0) return 0;\n\tconst sorted = [...values].sort((a, b) => a - b);\n\tconst mid = Math.floor(sorted.length / 2);\n\tif (sorted.length % 2 === 1) {\n\t\treturn sorted[mid];\n\t}\n\treturn (sorted[mid - 1] + sorted[mid]) / 2;\n}\n\nfunction computeMean(values: number[]): number {\n\tif (values.length === 0) return 0;\n\treturn values.reduce((a, b) => a + b, 0) / values.length;\n}\n\nfunction isFileExistsError(error: unknown): boolean {\n\treturn typeof error === \"object\" && error !== null && \"code\" in error && error.code === \"EEXIST\";\n}\n\nfunction isENOENT(error: unknown): boolean {\n\treturn typeof error === \"object\" && error !== null && \"code\" in error && error.code === \"ENOENT\";\n}\n\nfunction isValidPerformanceEntry(entry: unknown): entry is PerformanceEntry {\n\tif (typeof entry !== \"object\" || entry === null) return false;\n\tconst e = entry as Record<string, unknown>;\n\treturn (\n\t\ttypeof e.timestamp === \"string\" &&\n\t\ttypeof e.provider === \"string\" &&\n\t\ttypeof e.modelId === \"string\" &&\n\t\ttypeof e.outputTokens === \"number\" &&\n\t\tNumber.isFinite(e.outputTokens) &&\n\t\ttypeof e.durationMs === \"number\" &&\n\t\tNumber.isFinite(e.durationMs) &&\n\t\ttypeof e.tps === \"number\" &&\n\t\tNumber.isFinite(e.tps)\n\t);\n}\n\nfunction sleepSync(ms: number): void {\n\tAtomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"footer.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/footer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAiC,MAAM,WAAW,CAAC;AAC1E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,uCAAuC,CAAC;AA0BxF;;;GAGG;AACH,qBAAa,eAAgB,YAAW,SAAS;IAI/C,OAAO,CAAC,OAAO;IACf,OAAO,CAAC,UAAU;IAJnB,OAAO,CAAC,kBAAkB,CAAQ;IAElC,YACS,OAAO,EAAE,YAAY,EACrB,UAAU,EAAE,0BAA0B,EAC3C;IAEJ,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAE5C;IAED;;;OAGG;IACH,UAAU,IAAI,IAAI,CAEjB;IAED;;;OAGG;IACH,OAAO,IAAI,IAAI,CAEd;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAoK9B;CACD","sourcesContent":["import { type Component, truncateToWidth, visibleWidth } from \"@dreb/tui\";\nimport type { AgentSession } from \"../../../core/agent-session.js\";\nimport type { ReadonlyFooterDataProvider } from \"../../../core/footer-data-provider.js\";\nimport { theme } from \"../theme/theme.js\";\n\n/**\n * Sanitize text for display in a single-line status.\n * Removes newlines, tabs, carriage returns, and other control characters.\n */\nfunction sanitizeStatusText(text: string): string {\n\t// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces\n\treturn text\n\t\t.replace(/[\\r\\n\\t]/g, \" \")\n\t\t.replace(/ +/g, \" \")\n\t\t.trim();\n}\n\n/**\n * Format token counts (similar to web-ui)\n */\nfunction formatTokens(count: number): string {\n\tif (count < 1000) return count.toString();\n\tif (count < 10000) return `${(count / 1000).toFixed(1)}k`;\n\tif (count < 1000000) return `${Math.round(count / 1000)}k`;\n\tif (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;\n\treturn `${Math.round(count / 1000000)}M`;\n}\n\n/**\n * Footer component that shows pwd, token stats, and context usage.\n * Computes token/context stats from session, gets git branch and extension statuses from provider.\n */\nexport class FooterComponent implements Component {\n\tprivate autoCompactEnabled = true;\n\n\tconstructor(\n\t\tprivate session: AgentSession,\n\t\tprivate footerData: ReadonlyFooterDataProvider,\n\t) {}\n\n\tsetAutoCompactEnabled(enabled: boolean): void {\n\t\tthis.autoCompactEnabled = enabled;\n\t}\n\n\t/**\n\t * No-op: git branch caching now handled by provider.\n\t * Kept for compatibility with existing call sites in interactive-mode.\n\t */\n\tinvalidate(): void {\n\t\t// No-op: git branch is cached/invalidated by provider\n\t}\n\n\t/**\n\t * Clean up resources.\n\t * Git watcher cleanup now handled by provider.\n\t */\n\tdispose(): void {\n\t\t// Git watcher cleanup handled by provider\n\t}\n\n\trender(width: number): string[] {\n\t\tconst state = this.session.state;\n\n\t\t// Calculate cumulative usage from ALL session entries (not just post-compaction messages)\n\t\tlet totalInput = 0;\n\t\tlet totalOutput = 0;\n\t\tlet totalCacheRead = 0;\n\t\tlet totalCacheWrite = 0;\n\t\tlet totalCost = 0;\n\n\t\tfor (const entry of this.session.sessionManager.getEntries()) {\n\t\t\tif (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\t\ttotalInput += entry.message.usage.input;\n\t\t\t\ttotalOutput += entry.message.usage.output;\n\t\t\t\ttotalCacheRead += entry.message.usage.cacheRead;\n\t\t\t\ttotalCacheWrite += entry.message.usage.cacheWrite;\n\t\t\t\ttotalCost += entry.message.usage.cost.total;\n\t\t\t}\n\t\t}\n\n\t\t// Calculate context usage from session (handles compaction correctly).\n\t\t// After compaction, tokens are unknown until the next LLM response.\n\t\tconst contextUsage = this.session.getContextUsage();\n\t\tconst contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;\n\t\tconst contextPercentValue = contextUsage?.percent ?? 0;\n\t\tconst contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : \"?\";\n\n\t\t// Replace home directory with ~\n\t\tlet pwd = process.cwd();\n\t\tconst home = process.env.HOME || process.env.USERPROFILE;\n\t\tif (home && pwd.startsWith(home)) {\n\t\t\tpwd = `~${pwd.slice(home.length)}`;\n\t\t}\n\n\t\t// Add git branch if available\n\t\tconst branch = this.footerData.getGitBranch();\n\t\tif (branch) {\n\t\t\tpwd = `${pwd} (${branch})`;\n\t\t}\n\n\t\t// Add session name if set\n\t\tconst sessionName = this.session.sessionManager.getSessionName();\n\t\tif (sessionName) {\n\t\t\tpwd = `${pwd} • ${sessionName}`;\n\t\t}\n\n\t\t// Build stats line as sections separated by ·\n\t\tconst tokenParts = [];\n\t\tif (totalInput) tokenParts.push(`↑${formatTokens(totalInput)}`);\n\t\tif (totalOutput) tokenParts.push(`↓${formatTokens(totalOutput)}`);\n\t\tif (totalCacheRead) tokenParts.push(`R${formatTokens(totalCacheRead)}`);\n\t\tif (totalCacheWrite) tokenParts.push(`W${formatTokens(totalCacheWrite)}`);\n\n\t\t// Show cost with \"(sub)\" indicator if using OAuth subscription\n\t\tlet costStr = \"\";\n\t\tconst usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;\n\t\tif (totalCost || usingSubscription) {\n\t\t\tcostStr = `$${totalCost.toFixed(3)}${usingSubscription ? \" (sub)\" : \"\"}`;\n\t\t\t// Append daily total when there's cross-session spend\n\t\t\tconst dailyCost = this.footerData.getDailyCost();\n\t\t\tif (dailyCost > totalCost) {\n\t\t\t\tcostStr += `, today: $${dailyCost.toFixed(2)}`;\n\t\t\t}\n\t\t}\n\n\t\t// Colorize context percentage based on usage\n\t\tlet contextPercentStr: string;\n\t\tconst autoIndicator = this.autoCompactEnabled ? \" (auto)\" : \"\";\n\t\tconst contextPercentDisplay =\n\t\t\tcontextPercent === \"?\"\n\t\t\t\t? `?/${formatTokens(contextWindow)}${autoIndicator}`\n\t\t\t\t: `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;\n\t\tif (contextPercentValue > 90) {\n\t\t\tcontextPercentStr = theme.fg(\"error\", contextPercentDisplay);\n\t\t} else if (contextPercentValue > 70) {\n\t\t\tcontextPercentStr = theme.fg(\"warning\", contextPercentDisplay);\n\t\t} else {\n\t\t\tcontextPercentStr = contextPercentDisplay;\n\t\t}\n\n\t\t// Join sections with · separator\n\t\tconst sections: string[] = [];\n\t\tif (tokenParts.length > 0) sections.push(tokenParts.join(\" \"));\n\t\tif (costStr) sections.push(costStr);\n\t\tsections.push(contextPercentStr);\n\n\t\tlet statsLeft = sections.join(\" · \");\n\n\t\t// Add model name on the right side, plus thinking level if model supports it\n\t\tconst modelName = state.model?.id || \"no-model\";\n\n\t\tlet statsLeftWidth = visibleWidth(statsLeft);\n\n\t\t// If statsLeft is too wide, truncate it\n\t\tif (statsLeftWidth > width) {\n\t\t\tstatsLeft = truncateToWidth(statsLeft, width, \"...\");\n\t\t\tstatsLeftWidth = visibleWidth(statsLeft);\n\t\t}\n\n\t\t// Calculate available space for padding (minimum 2 spaces between stats and model)\n\t\tconst minPadding = 2;\n\n\t\t// Add thinking level indicator if model supports reasoning\n\t\tlet rightSideWithoutProvider = modelName;\n\t\tif (state.model?.reasoning) {\n\t\t\tconst thinkingLevel = state.thinkingLevel || \"off\";\n\t\t\trightSideWithoutProvider =\n\t\t\t\tthinkingLevel === \"off\" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`;\n\t\t}\n\n\t\t// Prepend the provider in parentheses if there are multiple providers and there's enough room\n\t\tlet rightSide = rightSideWithoutProvider;\n\t\tif (this.footerData.getAvailableProviderCount() > 1 && state.model) {\n\t\t\trightSide = `(${state.model!.provider}) ${rightSideWithoutProvider}`;\n\t\t\tif (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) {\n\t\t\t\t// Too wide, fall back\n\t\t\t\trightSide = rightSideWithoutProvider;\n\t\t\t}\n\t\t}\n\n\t\tconst rightSideWidth = visibleWidth(rightSide);\n\t\tconst totalNeeded = statsLeftWidth + minPadding + rightSideWidth;\n\n\t\tlet statsLine: string;\n\t\tif (totalNeeded <= width) {\n\t\t\t// Both fit - add padding to right-align model\n\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - rightSideWidth);\n\t\t\tstatsLine = statsLeft + padding + rightSide;\n\t\t} else {\n\t\t\t// Need to truncate right side\n\t\t\tconst availableForRight = width - statsLeftWidth - minPadding;\n\t\t\tif (availableForRight > 0) {\n\t\t\t\tconst truncatedRight = truncateToWidth(rightSide, availableForRight, \"\");\n\t\t\t\tconst truncatedRightWidth = visibleWidth(truncatedRight);\n\t\t\t\tconst padding = \" \".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth));\n\t\t\t\tstatsLine = statsLeft + padding + truncatedRight;\n\t\t\t} else {\n\t\t\t\t// Not enough space for right side at all\n\t\t\t\tstatsLine = statsLeft;\n\t\t\t}\n\t\t}\n\n\t\t// Apply dim to each part separately. statsLeft may contain color codes (for context %)\n\t\t// that end with a reset, which would clear an outer dim wrapper. So we dim the parts\n\t\t// before and after the colored section independently.\n\t\tconst dimStatsLeft = theme.fg(\"dim\", statsLeft);\n\t\tconst remainder = statsLine.slice(statsLeft.length); // padding + rightSide\n\t\tconst dimRemainder = theme.fg(\"dim\", remainder);\n\n\t\tconst pwdLine = truncateToWidth(theme.fg(\"dim\", pwd), width, theme.fg(\"dim\", \"...\"));\n\t\tconst lines = [pwdLine, dimStatsLeft + dimRemainder];\n\n\t\t// Add extension statuses on a single line, sorted by key alphabetically\n\t\tconst extensionStatuses = this.footerData.getExtensionStatuses();\n\t\tif (extensionStatuses.size > 0) {\n\t\t\tconst sortedStatuses = Array.from(extensionStatuses.entries())\n\t\t\t\t.sort(([a], [b]) => a.localeCompare(b))\n\t\t\t\t.map(([, text]) => sanitizeStatusText(text));\n\t\t\tconst statusLine = sortedStatuses.join(\" \");\n\t\t\t// Truncate to terminal width with dim ellipsis for consistency with footer style\n\t\t\tlines.push(truncateToWidth(statusLine, width, theme.fg(\"dim\", \"...\")));\n\t\t}\n\n\t\treturn lines;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"footer.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/footer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAiC,MAAM,WAAW,CAAC;AAC1E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,uCAAuC,CAAC;AA0BxF;;;GAGG;AACH,qBAAa,eAAgB,YAAW,SAAS;IAI/C,OAAO,CAAC,OAAO;IACf,OAAO,CAAC,UAAU;IAJnB,OAAO,CAAC,kBAAkB,CAAQ;IAElC,YACS,OAAO,EAAE,YAAY,EACrB,UAAU,EAAE,0BAA0B,EAC3C;IAEJ,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAE5C;IAED;;;OAGG;IACH,UAAU,IAAI,IAAI,CAEjB;IAED;;;OAGG;IACH,OAAO,IAAI,IAAI,CAEd;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA4L9B;CACD","sourcesContent":["import { type Component, truncateToWidth, visibleWidth } from \"@dreb/tui\";\nimport type { AgentSession } from \"../../../core/agent-session.js\";\nimport type { ReadonlyFooterDataProvider } from \"../../../core/footer-data-provider.js\";\nimport { theme } from \"../theme/theme.js\";\n\n/**\n * Sanitize text for display in a single-line status.\n * Removes newlines, tabs, carriage returns, and other control characters.\n */\nfunction sanitizeStatusText(text: string): string {\n\t// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces\n\treturn text\n\t\t.replace(/[\\r\\n\\t]/g, \" \")\n\t\t.replace(/ +/g, \" \")\n\t\t.trim();\n}\n\n/**\n * Format token counts (similar to web-ui)\n */\nfunction formatTokens(count: number): string {\n\tif (count < 1000) return count.toString();\n\tif (count < 10000) return `${(count / 1000).toFixed(1)}k`;\n\tif (count < 1000000) return `${Math.round(count / 1000)}k`;\n\tif (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;\n\treturn `${Math.round(count / 1000000)}M`;\n}\n\n/**\n * Footer component that shows pwd, token stats, and context usage.\n * Computes token/context stats from session, gets git branch and extension statuses from provider.\n */\nexport class FooterComponent implements Component {\n\tprivate autoCompactEnabled = true;\n\n\tconstructor(\n\t\tprivate session: AgentSession,\n\t\tprivate footerData: ReadonlyFooterDataProvider,\n\t) {}\n\n\tsetAutoCompactEnabled(enabled: boolean): void {\n\t\tthis.autoCompactEnabled = enabled;\n\t}\n\n\t/**\n\t * No-op: git branch caching now handled by provider.\n\t * Kept for compatibility with existing call sites in interactive-mode.\n\t */\n\tinvalidate(): void {\n\t\t// No-op: git branch is cached/invalidated by provider\n\t}\n\n\t/**\n\t * Clean up resources.\n\t * Git watcher cleanup now handled by provider.\n\t */\n\tdispose(): void {\n\t\t// Git watcher cleanup handled by provider\n\t}\n\n\trender(width: number): string[] {\n\t\tconst state = this.session.state;\n\n\t\t// Calculate cumulative usage from ALL session entries (not just post-compaction messages)\n\t\tlet totalInput = 0;\n\t\tlet totalOutput = 0;\n\t\tlet totalCacheRead = 0;\n\t\tlet totalCacheWrite = 0;\n\t\tlet totalCost = 0;\n\n\t\tfor (const entry of this.session.sessionManager.getEntries()) {\n\t\t\tif (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\t\ttotalInput += entry.message.usage.input;\n\t\t\t\ttotalOutput += entry.message.usage.output;\n\t\t\t\ttotalCacheRead += entry.message.usage.cacheRead;\n\t\t\t\ttotalCacheWrite += entry.message.usage.cacheWrite;\n\t\t\t\ttotalCost += entry.message.usage.cost.total;\n\t\t\t}\n\t\t}\n\n\t\t// Calculate context usage from session (handles compaction correctly).\n\t\t// After compaction, tokens are unknown until the next LLM response.\n\t\tconst contextUsage = this.session.getContextUsage();\n\t\tconst contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;\n\t\tconst contextPercentValue = contextUsage?.percent ?? 0;\n\t\tconst contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : \"?\";\n\n\t\t// Replace home directory with ~\n\t\tlet pwd = process.cwd();\n\t\tconst home = process.env.HOME || process.env.USERPROFILE;\n\t\tif (home && pwd.startsWith(home)) {\n\t\t\tpwd = `~${pwd.slice(home.length)}`;\n\t\t}\n\n\t\t// Add git branch if available\n\t\tconst branch = this.footerData.getGitBranch();\n\t\tif (branch) {\n\t\t\tpwd = `${pwd} (${branch})`;\n\t\t}\n\n\t\t// Add session name if set\n\t\tconst sessionName = this.session.sessionManager.getSessionName();\n\t\tif (sessionName) {\n\t\t\tpwd = `${pwd} • ${sessionName}`;\n\t\t}\n\n\t\t// Build stats line as sections separated by ·\n\t\tconst tokenParts = [];\n\t\tif (totalInput) tokenParts.push(`↑${formatTokens(totalInput)}`);\n\t\tif (totalOutput) tokenParts.push(`↓${formatTokens(totalOutput)}`);\n\t\tif (totalCacheRead) tokenParts.push(`R${formatTokens(totalCacheRead)}`);\n\t\tif (totalCacheWrite) tokenParts.push(`W${formatTokens(totalCacheWrite)}`);\n\n\t\t// Show cost with \"(sub)\" indicator if using OAuth subscription\n\t\tlet costStr = \"\";\n\t\tconst usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;\n\t\tif (totalCost || usingSubscription) {\n\t\t\tcostStr = `$${totalCost.toFixed(3)}${usingSubscription ? \" (sub)\" : \"\"}`;\n\t\t\t// Append daily total when there's cross-session spend\n\t\t\tconst dailyCost = this.footerData.getDailyCost();\n\t\t\tif (dailyCost > totalCost) {\n\t\t\t\tcostStr += `, today: $${dailyCost.toFixed(2)}`;\n\t\t\t}\n\t\t}\n\n\t\t// Colorize context percentage based on usage\n\t\tlet contextPercentStr: string;\n\t\tconst autoIndicator = this.autoCompactEnabled ? \" (auto)\" : \"\";\n\t\tconst contextPercentDisplay =\n\t\t\tcontextPercent === \"?\"\n\t\t\t\t? `?/${formatTokens(contextWindow)}${autoIndicator}`\n\t\t\t\t: `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;\n\t\tif (contextPercentValue > 90) {\n\t\t\tcontextPercentStr = theme.fg(\"error\", contextPercentDisplay);\n\t\t} else if (contextPercentValue > 70) {\n\t\t\tcontextPercentStr = theme.fg(\"warning\", contextPercentDisplay);\n\t\t} else {\n\t\t\tcontextPercentStr = contextPercentDisplay;\n\t\t}\n\n\t\t// Join sections with · separator\n\t\tconst sections: string[] = [];\n\t\tif (tokenParts.length > 0) sections.push(tokenParts.join(\" \"));\n\t\tif (costStr) sections.push(costStr);\n\t\tsections.push(contextPercentStr);\n\n\t\tlet statsLeft = sections.join(\" · \");\n\n\t\t// Add model name on the right side, plus thinking level if model supports it\n\t\tconst modelName = state.model?.id || \"no-model\";\n\n\t\t// Query performance tracker for rolling TPS + trend\n\t\tconst perf = this.session.getPerformanceTracker();\n\t\tconst model = state.model;\n\t\tlet tpsSuffix = \"\";\n\t\tif (model) {\n\t\t\tconst rolling = perf.getRollingAverage(model.provider, model.id);\n\t\t\tif (rolling.count >= 3) {\n\t\t\t\tconst delta = perf.getPerformanceDelta(model.provider, model.id);\n\t\t\t\tconst trendStyle = {\n\t\t\t\t\tabove: { arrow: \"↑\", color: \"success\" },\n\t\t\t\t\tbelow: { arrow: \"↓\", color: \"warning\" },\n\t\t\t\t\tstable: { arrow: \"→\", color: \"dim\" },\n\t\t\t\t} as const;\n\t\t\t\tconst { arrow, color: arrowColor } = trendStyle[delta.direction];\n\t\t\t\tconst deltaPercent = delta.direction === \"stable\" ? 0 : Math.round(Math.abs(delta.percentDelta));\n\t\t\t\tconst medianDelta =\n\t\t\t\t\tdelta.recentCount >= 3 && delta.baselineCount >= 3\n\t\t\t\t\t\t? ` · ${deltaPercent}% ${theme.fg(arrowColor, arrow)}${theme.getFgAnsi(\"dim\")} median [${delta.baselineCount}]`\n\t\t\t\t\t\t: \"\";\n\t\t\t\ttpsSuffix = ` (~${Math.round(rolling.median)} tok/s [${rolling.count}]${medianDelta})`;\n\t\t\t}\n\t\t}\n\n\t\tlet statsLeftWidth = visibleWidth(statsLeft);\n\n\t\t// If statsLeft is too wide, truncate it\n\t\tif (statsLeftWidth > width) {\n\t\t\tstatsLeft = truncateToWidth(statsLeft, width, \"...\");\n\t\t\tstatsLeftWidth = visibleWidth(statsLeft);\n\t\t}\n\n\t\t// Calculate available space for padding (minimum 2 spaces between stats and model)\n\t\tconst minPadding = 2;\n\n\t\t// Add thinking level indicator if model supports reasoning\n\t\tconst displayModelName = modelName + tpsSuffix;\n\t\tlet rightSideWithoutProvider = displayModelName;\n\t\tif (state.model?.reasoning) {\n\t\t\tconst thinkingLevel = state.thinkingLevel || \"off\";\n\t\t\trightSideWithoutProvider =\n\t\t\t\tthinkingLevel === \"off\" ? `${displayModelName} • thinking off` : `${displayModelName} • ${thinkingLevel}`;\n\t\t}\n\n\t\t// Prepend the provider in parentheses if there are multiple providers and there's enough room\n\t\tlet rightSide = rightSideWithoutProvider;\n\t\tif (this.footerData.getAvailableProviderCount() > 1 && state.model) {\n\t\t\trightSide = `(${state.model!.provider}) ${rightSideWithoutProvider}`;\n\t\t\tif (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) {\n\t\t\t\t// Too wide, fall back\n\t\t\t\trightSide = rightSideWithoutProvider;\n\t\t\t}\n\t\t}\n\n\t\tconst rightSideWidth = visibleWidth(rightSide);\n\t\tconst totalNeeded = statsLeftWidth + minPadding + rightSideWidth;\n\n\t\tlet statsLine: string;\n\t\tif (totalNeeded <= width) {\n\t\t\t// Both fit - add padding to right-align model\n\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - rightSideWidth);\n\t\t\tstatsLine = statsLeft + padding + rightSide;\n\t\t} else {\n\t\t\t// Need to truncate right side\n\t\t\tconst availableForRight = width - statsLeftWidth - minPadding;\n\t\t\tif (availableForRight > 0) {\n\t\t\t\tconst truncatedRight = truncateToWidth(rightSide, availableForRight, \"\");\n\t\t\t\tconst truncatedRightWidth = visibleWidth(truncatedRight);\n\t\t\t\tconst padding = \" \".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth));\n\t\t\t\tstatsLine = statsLeft + padding + truncatedRight;\n\t\t\t} else {\n\t\t\t\t// Not enough space for right side at all\n\t\t\t\tstatsLine = statsLeft;\n\t\t\t}\n\t\t}\n\n\t\t// Apply dim to each part separately. statsLeft may contain color codes (for context %)\n\t\t// that end with a reset, which would clear an outer dim wrapper. So we dim the parts\n\t\t// before and after the colored section independently.\n\t\tconst dimStatsLeft = theme.fg(\"dim\", statsLeft);\n\t\tconst remainder = statsLine.slice(statsLeft.length); // padding + rightSide\n\t\tconst dimRemainder = theme.fg(\"dim\", remainder);\n\n\t\tconst pwdLine = truncateToWidth(theme.fg(\"dim\", pwd), width, theme.fg(\"dim\", \"...\"));\n\t\tconst lines = [pwdLine, dimStatsLeft + dimRemainder];\n\n\t\t// Add extension statuses on a single line, sorted by key alphabetically\n\t\tconst extensionStatuses = this.footerData.getExtensionStatuses();\n\t\tif (extensionStatuses.size > 0) {\n\t\t\tconst sortedStatuses = Array.from(extensionStatuses.entries())\n\t\t\t\t.sort(([a], [b]) => a.localeCompare(b))\n\t\t\t\t.map(([, text]) => sanitizeStatusText(text));\n\t\t\tconst statusLine = sortedStatuses.join(\" \");\n\t\t\t// Truncate to terminal width with dim ellipsis for consistency with footer style\n\t\t\tlines.push(truncateToWidth(statusLine, width, theme.fg(\"dim\", \"...\")));\n\t\t}\n\n\t\treturn lines;\n\t}\n}\n"]}
|
|
@@ -139,6 +139,27 @@ export class FooterComponent {
|
|
|
139
139
|
let statsLeft = sections.join(" · ");
|
|
140
140
|
// Add model name on the right side, plus thinking level if model supports it
|
|
141
141
|
const modelName = state.model?.id || "no-model";
|
|
142
|
+
// Query performance tracker for rolling TPS + trend
|
|
143
|
+
const perf = this.session.getPerformanceTracker();
|
|
144
|
+
const model = state.model;
|
|
145
|
+
let tpsSuffix = "";
|
|
146
|
+
if (model) {
|
|
147
|
+
const rolling = perf.getRollingAverage(model.provider, model.id);
|
|
148
|
+
if (rolling.count >= 3) {
|
|
149
|
+
const delta = perf.getPerformanceDelta(model.provider, model.id);
|
|
150
|
+
const trendStyle = {
|
|
151
|
+
above: { arrow: "↑", color: "success" },
|
|
152
|
+
below: { arrow: "↓", color: "warning" },
|
|
153
|
+
stable: { arrow: "→", color: "dim" },
|
|
154
|
+
};
|
|
155
|
+
const { arrow, color: arrowColor } = trendStyle[delta.direction];
|
|
156
|
+
const deltaPercent = delta.direction === "stable" ? 0 : Math.round(Math.abs(delta.percentDelta));
|
|
157
|
+
const medianDelta = delta.recentCount >= 3 && delta.baselineCount >= 3
|
|
158
|
+
? ` · ${deltaPercent}% ${theme.fg(arrowColor, arrow)}${theme.getFgAnsi("dim")} median [${delta.baselineCount}]`
|
|
159
|
+
: "";
|
|
160
|
+
tpsSuffix = ` (~${Math.round(rolling.median)} tok/s [${rolling.count}]${medianDelta})`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
142
163
|
let statsLeftWidth = visibleWidth(statsLeft);
|
|
143
164
|
// If statsLeft is too wide, truncate it
|
|
144
165
|
if (statsLeftWidth > width) {
|
|
@@ -148,11 +169,12 @@ export class FooterComponent {
|
|
|
148
169
|
// Calculate available space for padding (minimum 2 spaces between stats and model)
|
|
149
170
|
const minPadding = 2;
|
|
150
171
|
// Add thinking level indicator if model supports reasoning
|
|
151
|
-
|
|
172
|
+
const displayModelName = modelName + tpsSuffix;
|
|
173
|
+
let rightSideWithoutProvider = displayModelName;
|
|
152
174
|
if (state.model?.reasoning) {
|
|
153
175
|
const thinkingLevel = state.thinkingLevel || "off";
|
|
154
176
|
rightSideWithoutProvider =
|
|
155
|
-
thinkingLevel === "off" ? `${
|
|
177
|
+
thinkingLevel === "off" ? `${displayModelName} • thinking off` : `${displayModelName} • ${thinkingLevel}`;
|
|
156
178
|
}
|
|
157
179
|
// Prepend the provider in parentheses if there are multiple providers and there's enough room
|
|
158
180
|
let rightSide = rightSideWithoutProvider;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"footer.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/footer.ts"],"names":[],"mappings":"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","sourcesContent":["import { type Component, truncateToWidth, visibleWidth } from \"@dreb/tui\";\nimport type { AgentSession } from \"../../../core/agent-session.js\";\nimport type { ReadonlyFooterDataProvider } from \"../../../core/footer-data-provider.js\";\nimport { theme } from \"../theme/theme.js\";\n\n/**\n * Sanitize text for display in a single-line status.\n * Removes newlines, tabs, carriage returns, and other control characters.\n */\nfunction sanitizeStatusText(text: string): string {\n\t// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces\n\treturn text\n\t\t.replace(/[\\r\\n\\t]/g, \" \")\n\t\t.replace(/ +/g, \" \")\n\t\t.trim();\n}\n\n/**\n * Format token counts (similar to web-ui)\n */\nfunction formatTokens(count: number): string {\n\tif (count < 1000) return count.toString();\n\tif (count < 10000) return `${(count / 1000).toFixed(1)}k`;\n\tif (count < 1000000) return `${Math.round(count / 1000)}k`;\n\tif (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;\n\treturn `${Math.round(count / 1000000)}M`;\n}\n\n/**\n * Footer component that shows pwd, token stats, and context usage.\n * Computes token/context stats from session, gets git branch and extension statuses from provider.\n */\nexport class FooterComponent implements Component {\n\tprivate autoCompactEnabled = true;\n\n\tconstructor(\n\t\tprivate session: AgentSession,\n\t\tprivate footerData: ReadonlyFooterDataProvider,\n\t) {}\n\n\tsetAutoCompactEnabled(enabled: boolean): void {\n\t\tthis.autoCompactEnabled = enabled;\n\t}\n\n\t/**\n\t * No-op: git branch caching now handled by provider.\n\t * Kept for compatibility with existing call sites in interactive-mode.\n\t */\n\tinvalidate(): void {\n\t\t// No-op: git branch is cached/invalidated by provider\n\t}\n\n\t/**\n\t * Clean up resources.\n\t * Git watcher cleanup now handled by provider.\n\t */\n\tdispose(): void {\n\t\t// Git watcher cleanup handled by provider\n\t}\n\n\trender(width: number): string[] {\n\t\tconst state = this.session.state;\n\n\t\t// Calculate cumulative usage from ALL session entries (not just post-compaction messages)\n\t\tlet totalInput = 0;\n\t\tlet totalOutput = 0;\n\t\tlet totalCacheRead = 0;\n\t\tlet totalCacheWrite = 0;\n\t\tlet totalCost = 0;\n\n\t\tfor (const entry of this.session.sessionManager.getEntries()) {\n\t\t\tif (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\t\ttotalInput += entry.message.usage.input;\n\t\t\t\ttotalOutput += entry.message.usage.output;\n\t\t\t\ttotalCacheRead += entry.message.usage.cacheRead;\n\t\t\t\ttotalCacheWrite += entry.message.usage.cacheWrite;\n\t\t\t\ttotalCost += entry.message.usage.cost.total;\n\t\t\t}\n\t\t}\n\n\t\t// Calculate context usage from session (handles compaction correctly).\n\t\t// After compaction, tokens are unknown until the next LLM response.\n\t\tconst contextUsage = this.session.getContextUsage();\n\t\tconst contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;\n\t\tconst contextPercentValue = contextUsage?.percent ?? 0;\n\t\tconst contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : \"?\";\n\n\t\t// Replace home directory with ~\n\t\tlet pwd = process.cwd();\n\t\tconst home = process.env.HOME || process.env.USERPROFILE;\n\t\tif (home && pwd.startsWith(home)) {\n\t\t\tpwd = `~${pwd.slice(home.length)}`;\n\t\t}\n\n\t\t// Add git branch if available\n\t\tconst branch = this.footerData.getGitBranch();\n\t\tif (branch) {\n\t\t\tpwd = `${pwd} (${branch})`;\n\t\t}\n\n\t\t// Add session name if set\n\t\tconst sessionName = this.session.sessionManager.getSessionName();\n\t\tif (sessionName) {\n\t\t\tpwd = `${pwd} • ${sessionName}`;\n\t\t}\n\n\t\t// Build stats line as sections separated by ·\n\t\tconst tokenParts = [];\n\t\tif (totalInput) tokenParts.push(`↑${formatTokens(totalInput)}`);\n\t\tif (totalOutput) tokenParts.push(`↓${formatTokens(totalOutput)}`);\n\t\tif (totalCacheRead) tokenParts.push(`R${formatTokens(totalCacheRead)}`);\n\t\tif (totalCacheWrite) tokenParts.push(`W${formatTokens(totalCacheWrite)}`);\n\n\t\t// Show cost with \"(sub)\" indicator if using OAuth subscription\n\t\tlet costStr = \"\";\n\t\tconst usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;\n\t\tif (totalCost || usingSubscription) {\n\t\t\tcostStr = `$${totalCost.toFixed(3)}${usingSubscription ? \" (sub)\" : \"\"}`;\n\t\t\t// Append daily total when there's cross-session spend\n\t\t\tconst dailyCost = this.footerData.getDailyCost();\n\t\t\tif (dailyCost > totalCost) {\n\t\t\t\tcostStr += `, today: $${dailyCost.toFixed(2)}`;\n\t\t\t}\n\t\t}\n\n\t\t// Colorize context percentage based on usage\n\t\tlet contextPercentStr: string;\n\t\tconst autoIndicator = this.autoCompactEnabled ? \" (auto)\" : \"\";\n\t\tconst contextPercentDisplay =\n\t\t\tcontextPercent === \"?\"\n\t\t\t\t? `?/${formatTokens(contextWindow)}${autoIndicator}`\n\t\t\t\t: `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;\n\t\tif (contextPercentValue > 90) {\n\t\t\tcontextPercentStr = theme.fg(\"error\", contextPercentDisplay);\n\t\t} else if (contextPercentValue > 70) {\n\t\t\tcontextPercentStr = theme.fg(\"warning\", contextPercentDisplay);\n\t\t} else {\n\t\t\tcontextPercentStr = contextPercentDisplay;\n\t\t}\n\n\t\t// Join sections with · separator\n\t\tconst sections: string[] = [];\n\t\tif (tokenParts.length > 0) sections.push(tokenParts.join(\" \"));\n\t\tif (costStr) sections.push(costStr);\n\t\tsections.push(contextPercentStr);\n\n\t\tlet statsLeft = sections.join(\" · \");\n\n\t\t// Add model name on the right side, plus thinking level if model supports it\n\t\tconst modelName = state.model?.id || \"no-model\";\n\n\t\tlet statsLeftWidth = visibleWidth(statsLeft);\n\n\t\t// If statsLeft is too wide, truncate it\n\t\tif (statsLeftWidth > width) {\n\t\t\tstatsLeft = truncateToWidth(statsLeft, width, \"...\");\n\t\t\tstatsLeftWidth = visibleWidth(statsLeft);\n\t\t}\n\n\t\t// Calculate available space for padding (minimum 2 spaces between stats and model)\n\t\tconst minPadding = 2;\n\n\t\t// Add thinking level indicator if model supports reasoning\n\t\tlet rightSideWithoutProvider = modelName;\n\t\tif (state.model?.reasoning) {\n\t\t\tconst thinkingLevel = state.thinkingLevel || \"off\";\n\t\t\trightSideWithoutProvider =\n\t\t\t\tthinkingLevel === \"off\" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`;\n\t\t}\n\n\t\t// Prepend the provider in parentheses if there are multiple providers and there's enough room\n\t\tlet rightSide = rightSideWithoutProvider;\n\t\tif (this.footerData.getAvailableProviderCount() > 1 && state.model) {\n\t\t\trightSide = `(${state.model!.provider}) ${rightSideWithoutProvider}`;\n\t\t\tif (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) {\n\t\t\t\t// Too wide, fall back\n\t\t\t\trightSide = rightSideWithoutProvider;\n\t\t\t}\n\t\t}\n\n\t\tconst rightSideWidth = visibleWidth(rightSide);\n\t\tconst totalNeeded = statsLeftWidth + minPadding + rightSideWidth;\n\n\t\tlet statsLine: string;\n\t\tif (totalNeeded <= width) {\n\t\t\t// Both fit - add padding to right-align model\n\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - rightSideWidth);\n\t\t\tstatsLine = statsLeft + padding + rightSide;\n\t\t} else {\n\t\t\t// Need to truncate right side\n\t\t\tconst availableForRight = width - statsLeftWidth - minPadding;\n\t\t\tif (availableForRight > 0) {\n\t\t\t\tconst truncatedRight = truncateToWidth(rightSide, availableForRight, \"\");\n\t\t\t\tconst truncatedRightWidth = visibleWidth(truncatedRight);\n\t\t\t\tconst padding = \" \".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth));\n\t\t\t\tstatsLine = statsLeft + padding + truncatedRight;\n\t\t\t} else {\n\t\t\t\t// Not enough space for right side at all\n\t\t\t\tstatsLine = statsLeft;\n\t\t\t}\n\t\t}\n\n\t\t// Apply dim to each part separately. statsLeft may contain color codes (for context %)\n\t\t// that end with a reset, which would clear an outer dim wrapper. So we dim the parts\n\t\t// before and after the colored section independently.\n\t\tconst dimStatsLeft = theme.fg(\"dim\", statsLeft);\n\t\tconst remainder = statsLine.slice(statsLeft.length); // padding + rightSide\n\t\tconst dimRemainder = theme.fg(\"dim\", remainder);\n\n\t\tconst pwdLine = truncateToWidth(theme.fg(\"dim\", pwd), width, theme.fg(\"dim\", \"...\"));\n\t\tconst lines = [pwdLine, dimStatsLeft + dimRemainder];\n\n\t\t// Add extension statuses on a single line, sorted by key alphabetically\n\t\tconst extensionStatuses = this.footerData.getExtensionStatuses();\n\t\tif (extensionStatuses.size > 0) {\n\t\t\tconst sortedStatuses = Array.from(extensionStatuses.entries())\n\t\t\t\t.sort(([a], [b]) => a.localeCompare(b))\n\t\t\t\t.map(([, text]) => sanitizeStatusText(text));\n\t\t\tconst statusLine = sortedStatuses.join(\" \");\n\t\t\t// Truncate to terminal width with dim ellipsis for consistency with footer style\n\t\t\tlines.push(truncateToWidth(statusLine, width, theme.fg(\"dim\", \"...\")));\n\t\t}\n\n\t\treturn lines;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"footer.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/footer.ts"],"names":[],"mappings":"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","sourcesContent":["import { type Component, truncateToWidth, visibleWidth } from \"@dreb/tui\";\nimport type { AgentSession } from \"../../../core/agent-session.js\";\nimport type { ReadonlyFooterDataProvider } from \"../../../core/footer-data-provider.js\";\nimport { theme } from \"../theme/theme.js\";\n\n/**\n * Sanitize text for display in a single-line status.\n * Removes newlines, tabs, carriage returns, and other control characters.\n */\nfunction sanitizeStatusText(text: string): string {\n\t// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces\n\treturn text\n\t\t.replace(/[\\r\\n\\t]/g, \" \")\n\t\t.replace(/ +/g, \" \")\n\t\t.trim();\n}\n\n/**\n * Format token counts (similar to web-ui)\n */\nfunction formatTokens(count: number): string {\n\tif (count < 1000) return count.toString();\n\tif (count < 10000) return `${(count / 1000).toFixed(1)}k`;\n\tif (count < 1000000) return `${Math.round(count / 1000)}k`;\n\tif (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;\n\treturn `${Math.round(count / 1000000)}M`;\n}\n\n/**\n * Footer component that shows pwd, token stats, and context usage.\n * Computes token/context stats from session, gets git branch and extension statuses from provider.\n */\nexport class FooterComponent implements Component {\n\tprivate autoCompactEnabled = true;\n\n\tconstructor(\n\t\tprivate session: AgentSession,\n\t\tprivate footerData: ReadonlyFooterDataProvider,\n\t) {}\n\n\tsetAutoCompactEnabled(enabled: boolean): void {\n\t\tthis.autoCompactEnabled = enabled;\n\t}\n\n\t/**\n\t * No-op: git branch caching now handled by provider.\n\t * Kept for compatibility with existing call sites in interactive-mode.\n\t */\n\tinvalidate(): void {\n\t\t// No-op: git branch is cached/invalidated by provider\n\t}\n\n\t/**\n\t * Clean up resources.\n\t * Git watcher cleanup now handled by provider.\n\t */\n\tdispose(): void {\n\t\t// Git watcher cleanup handled by provider\n\t}\n\n\trender(width: number): string[] {\n\t\tconst state = this.session.state;\n\n\t\t// Calculate cumulative usage from ALL session entries (not just post-compaction messages)\n\t\tlet totalInput = 0;\n\t\tlet totalOutput = 0;\n\t\tlet totalCacheRead = 0;\n\t\tlet totalCacheWrite = 0;\n\t\tlet totalCost = 0;\n\n\t\tfor (const entry of this.session.sessionManager.getEntries()) {\n\t\t\tif (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\t\ttotalInput += entry.message.usage.input;\n\t\t\t\ttotalOutput += entry.message.usage.output;\n\t\t\t\ttotalCacheRead += entry.message.usage.cacheRead;\n\t\t\t\ttotalCacheWrite += entry.message.usage.cacheWrite;\n\t\t\t\ttotalCost += entry.message.usage.cost.total;\n\t\t\t}\n\t\t}\n\n\t\t// Calculate context usage from session (handles compaction correctly).\n\t\t// After compaction, tokens are unknown until the next LLM response.\n\t\tconst contextUsage = this.session.getContextUsage();\n\t\tconst contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;\n\t\tconst contextPercentValue = contextUsage?.percent ?? 0;\n\t\tconst contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : \"?\";\n\n\t\t// Replace home directory with ~\n\t\tlet pwd = process.cwd();\n\t\tconst home = process.env.HOME || process.env.USERPROFILE;\n\t\tif (home && pwd.startsWith(home)) {\n\t\t\tpwd = `~${pwd.slice(home.length)}`;\n\t\t}\n\n\t\t// Add git branch if available\n\t\tconst branch = this.footerData.getGitBranch();\n\t\tif (branch) {\n\t\t\tpwd = `${pwd} (${branch})`;\n\t\t}\n\n\t\t// Add session name if set\n\t\tconst sessionName = this.session.sessionManager.getSessionName();\n\t\tif (sessionName) {\n\t\t\tpwd = `${pwd} • ${sessionName}`;\n\t\t}\n\n\t\t// Build stats line as sections separated by ·\n\t\tconst tokenParts = [];\n\t\tif (totalInput) tokenParts.push(`↑${formatTokens(totalInput)}`);\n\t\tif (totalOutput) tokenParts.push(`↓${formatTokens(totalOutput)}`);\n\t\tif (totalCacheRead) tokenParts.push(`R${formatTokens(totalCacheRead)}`);\n\t\tif (totalCacheWrite) tokenParts.push(`W${formatTokens(totalCacheWrite)}`);\n\n\t\t// Show cost with \"(sub)\" indicator if using OAuth subscription\n\t\tlet costStr = \"\";\n\t\tconst usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;\n\t\tif (totalCost || usingSubscription) {\n\t\t\tcostStr = `$${totalCost.toFixed(3)}${usingSubscription ? \" (sub)\" : \"\"}`;\n\t\t\t// Append daily total when there's cross-session spend\n\t\t\tconst dailyCost = this.footerData.getDailyCost();\n\t\t\tif (dailyCost > totalCost) {\n\t\t\t\tcostStr += `, today: $${dailyCost.toFixed(2)}`;\n\t\t\t}\n\t\t}\n\n\t\t// Colorize context percentage based on usage\n\t\tlet contextPercentStr: string;\n\t\tconst autoIndicator = this.autoCompactEnabled ? \" (auto)\" : \"\";\n\t\tconst contextPercentDisplay =\n\t\t\tcontextPercent === \"?\"\n\t\t\t\t? `?/${formatTokens(contextWindow)}${autoIndicator}`\n\t\t\t\t: `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;\n\t\tif (contextPercentValue > 90) {\n\t\t\tcontextPercentStr = theme.fg(\"error\", contextPercentDisplay);\n\t\t} else if (contextPercentValue > 70) {\n\t\t\tcontextPercentStr = theme.fg(\"warning\", contextPercentDisplay);\n\t\t} else {\n\t\t\tcontextPercentStr = contextPercentDisplay;\n\t\t}\n\n\t\t// Join sections with · separator\n\t\tconst sections: string[] = [];\n\t\tif (tokenParts.length > 0) sections.push(tokenParts.join(\" \"));\n\t\tif (costStr) sections.push(costStr);\n\t\tsections.push(contextPercentStr);\n\n\t\tlet statsLeft = sections.join(\" · \");\n\n\t\t// Add model name on the right side, plus thinking level if model supports it\n\t\tconst modelName = state.model?.id || \"no-model\";\n\n\t\t// Query performance tracker for rolling TPS + trend\n\t\tconst perf = this.session.getPerformanceTracker();\n\t\tconst model = state.model;\n\t\tlet tpsSuffix = \"\";\n\t\tif (model) {\n\t\t\tconst rolling = perf.getRollingAverage(model.provider, model.id);\n\t\t\tif (rolling.count >= 3) {\n\t\t\t\tconst delta = perf.getPerformanceDelta(model.provider, model.id);\n\t\t\t\tconst trendStyle = {\n\t\t\t\t\tabove: { arrow: \"↑\", color: \"success\" },\n\t\t\t\t\tbelow: { arrow: \"↓\", color: \"warning\" },\n\t\t\t\t\tstable: { arrow: \"→\", color: \"dim\" },\n\t\t\t\t} as const;\n\t\t\t\tconst { arrow, color: arrowColor } = trendStyle[delta.direction];\n\t\t\t\tconst deltaPercent = delta.direction === \"stable\" ? 0 : Math.round(Math.abs(delta.percentDelta));\n\t\t\t\tconst medianDelta =\n\t\t\t\t\tdelta.recentCount >= 3 && delta.baselineCount >= 3\n\t\t\t\t\t\t? ` · ${deltaPercent}% ${theme.fg(arrowColor, arrow)}${theme.getFgAnsi(\"dim\")} median [${delta.baselineCount}]`\n\t\t\t\t\t\t: \"\";\n\t\t\t\ttpsSuffix = ` (~${Math.round(rolling.median)} tok/s [${rolling.count}]${medianDelta})`;\n\t\t\t}\n\t\t}\n\n\t\tlet statsLeftWidth = visibleWidth(statsLeft);\n\n\t\t// If statsLeft is too wide, truncate it\n\t\tif (statsLeftWidth > width) {\n\t\t\tstatsLeft = truncateToWidth(statsLeft, width, \"...\");\n\t\t\tstatsLeftWidth = visibleWidth(statsLeft);\n\t\t}\n\n\t\t// Calculate available space for padding (minimum 2 spaces between stats and model)\n\t\tconst minPadding = 2;\n\n\t\t// Add thinking level indicator if model supports reasoning\n\t\tconst displayModelName = modelName + tpsSuffix;\n\t\tlet rightSideWithoutProvider = displayModelName;\n\t\tif (state.model?.reasoning) {\n\t\t\tconst thinkingLevel = state.thinkingLevel || \"off\";\n\t\t\trightSideWithoutProvider =\n\t\t\t\tthinkingLevel === \"off\" ? `${displayModelName} • thinking off` : `${displayModelName} • ${thinkingLevel}`;\n\t\t}\n\n\t\t// Prepend the provider in parentheses if there are multiple providers and there's enough room\n\t\tlet rightSide = rightSideWithoutProvider;\n\t\tif (this.footerData.getAvailableProviderCount() > 1 && state.model) {\n\t\t\trightSide = `(${state.model!.provider}) ${rightSideWithoutProvider}`;\n\t\t\tif (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) {\n\t\t\t\t// Too wide, fall back\n\t\t\t\trightSide = rightSideWithoutProvider;\n\t\t\t}\n\t\t}\n\n\t\tconst rightSideWidth = visibleWidth(rightSide);\n\t\tconst totalNeeded = statsLeftWidth + minPadding + rightSideWidth;\n\n\t\tlet statsLine: string;\n\t\tif (totalNeeded <= width) {\n\t\t\t// Both fit - add padding to right-align model\n\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - rightSideWidth);\n\t\t\tstatsLine = statsLeft + padding + rightSide;\n\t\t} else {\n\t\t\t// Need to truncate right side\n\t\t\tconst availableForRight = width - statsLeftWidth - minPadding;\n\t\t\tif (availableForRight > 0) {\n\t\t\t\tconst truncatedRight = truncateToWidth(rightSide, availableForRight, \"\");\n\t\t\t\tconst truncatedRightWidth = visibleWidth(truncatedRight);\n\t\t\t\tconst padding = \" \".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth));\n\t\t\t\tstatsLine = statsLeft + padding + truncatedRight;\n\t\t\t} else {\n\t\t\t\t// Not enough space for right side at all\n\t\t\t\tstatsLine = statsLeft;\n\t\t\t}\n\t\t}\n\n\t\t// Apply dim to each part separately. statsLeft may contain color codes (for context %)\n\t\t// that end with a reset, which would clear an outer dim wrapper. So we dim the parts\n\t\t// before and after the colored section independently.\n\t\tconst dimStatsLeft = theme.fg(\"dim\", statsLeft);\n\t\tconst remainder = statsLine.slice(statsLeft.length); // padding + rightSide\n\t\tconst dimRemainder = theme.fg(\"dim\", remainder);\n\n\t\tconst pwdLine = truncateToWidth(theme.fg(\"dim\", pwd), width, theme.fg(\"dim\", \"...\"));\n\t\tconst lines = [pwdLine, dimStatsLeft + dimRemainder];\n\n\t\t// Add extension statuses on a single line, sorted by key alphabetically\n\t\tconst extensionStatuses = this.footerData.getExtensionStatuses();\n\t\tif (extensionStatuses.size > 0) {\n\t\t\tconst sortedStatuses = Array.from(extensionStatuses.entries())\n\t\t\t\t.sort(([a], [b]) => a.localeCompare(b))\n\t\t\t\t.map(([, text]) => sanitizeStatusText(text));\n\t\t\tconst statusLine = sortedStatuses.join(\" \");\n\t\t\t// Truncate to terminal width with dim ellipsis for consistency with footer style\n\t\t\tlines.push(truncateToWidth(statusLine, width, theme.fg(\"dim\", \"...\")));\n\t\t}\n\n\t\treturn lines;\n\t}\n}\n"]}
|
|
@@ -173,6 +173,18 @@ export declare class RpcClient {
|
|
|
173
173
|
* Get session statistics.
|
|
174
174
|
*/
|
|
175
175
|
getSessionStats(): Promise<SessionStats>;
|
|
176
|
+
/**
|
|
177
|
+
* Get performance statistics.
|
|
178
|
+
*/
|
|
179
|
+
getPerformanceStats(): Promise<{
|
|
180
|
+
models: Array<{
|
|
181
|
+
provider: string;
|
|
182
|
+
modelId: string;
|
|
183
|
+
median: number;
|
|
184
|
+
mean: number;
|
|
185
|
+
count: number;
|
|
186
|
+
}>;
|
|
187
|
+
}>;
|
|
176
188
|
/**
|
|
177
189
|
* Export session to HTML.
|
|
178
190
|
*/
|