@ymys4ever/opencode-throughput-v2 4.0.0 → 4.0.2
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/index.js +698 -0
- package/dist/tui.js +663 -0
- package/package.json +9 -9
- package/src/benchmark.ts +0 -112
- package/src/core/events.ts +0 -141
- package/src/core/log-codec.ts +0 -90
- package/src/core/ownership.ts +0 -13
- package/src/core/stats.ts +0 -105
- package/src/core/step-tracker.ts +0 -204
- package/src/format/entry.ts +0 -39
- package/src/format/perf.ts +0 -14
- package/src/index.ts +0 -19
- package/src/io/log-store.ts +0 -55
- package/src/io/perf-file.ts +0 -30
- package/src/plugins/throughput.ts +0 -124
- package/src/runtime/pipeline.ts +0 -163
- package/src/tui/controller.ts +0 -191
- package/src/tui.tsx +0 -36
- package/src/types.ts +0 -44
- package/src/ui/sidebar.ts +0 -90
- package/src/ui/throughput-widget.tsx +0 -95
package/src/format/entry.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import type { LogEntry } from "../types.js"
|
|
2
|
-
|
|
3
|
-
/** Format a millisecond duration; null (no sample) renders as "N/A". */
|
|
4
|
-
export function formatMs(ms: number | null): string {
|
|
5
|
-
if (ms == null) return "N/A"
|
|
6
|
-
if (ms >= 60000) return (ms / 60000).toFixed(1) + "m"
|
|
7
|
-
if (ms >= 1000) return (ms / 1000).toFixed(1) + "s"
|
|
8
|
-
return ms.toFixed(0) + "ms"
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** Shared implementation for compact number rendering (k/M suffixes). */
|
|
12
|
-
export function formatTokenCount(n: number): string {
|
|
13
|
-
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M"
|
|
14
|
-
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k"
|
|
15
|
-
return n.toFixed(n >= 100 ? 0 : 1)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Format a numeric metric; null (no sample) renders as "N/A". */
|
|
19
|
-
export function formatNum(n: number | null): string {
|
|
20
|
-
if (n == null) return "N/A"
|
|
21
|
-
return formatTokenCount(n)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Single project-wide truncation: overlong strings become (max - 1) chars + "…". */
|
|
25
|
-
export function truncate(s: string, max: number): string {
|
|
26
|
-
return s.length > max ? s.slice(0, max - 1) + "…" : s
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Toast line: model | TTFT | tok/s | latency | ↑in ↓out. */
|
|
30
|
-
export function buildLogMsg(entry: LogEntry): string {
|
|
31
|
-
const parts = [
|
|
32
|
-
truncate(entry.modelID, 28),
|
|
33
|
-
formatMs(entry.ttft_ms) + " TTFT",
|
|
34
|
-
formatNum(entry.tps) + " tok/s",
|
|
35
|
-
formatMs(entry.latency_ms),
|
|
36
|
-
"\u2191" + formatNum(entry.inputTokens) + " \u2193" + formatNum(entry.outputTokens),
|
|
37
|
-
]
|
|
38
|
-
return parts.join(" | ")
|
|
39
|
-
}
|
package/src/format/perf.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import type { LogEntry } from "../types.js"
|
|
2
|
-
import { formatMs, formatNum } from "./entry.js"
|
|
3
|
-
|
|
4
|
-
/** Markdown perf-file line (frozen format; trailing newline included). */
|
|
5
|
-
export function formatPerfLine(entry: LogEntry): string {
|
|
6
|
-
const finishSuffix = entry.finish && entry.finish !== "stop" ? " | " + entry.finish : ""
|
|
7
|
-
return (
|
|
8
|
-
`${entry.ts} | ${entry.modelID} | TTFT ${formatMs(entry.ttft_ms)}` +
|
|
9
|
-
` | TPS ${formatNum(entry.tps)} tok/s | Latency ${formatMs(entry.latency_ms)}` +
|
|
10
|
-
` | \u2191${formatNum(entry.inputTokens)} \u2193${formatNum(entry.outputTokens)}` +
|
|
11
|
-
` \u2193r${formatNum(entry.reasoningTokens)} | Cost $${entry.cost.toFixed(4)}` +
|
|
12
|
-
`${finishSuffix}\n`
|
|
13
|
-
)
|
|
14
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { aggregateAt } from "./core/stats.js"
|
|
2
|
-
import type { LogEntry, ModelStats } from "./types.js"
|
|
3
|
-
|
|
4
|
-
export { createThroughputPlugin, ThroughputPlugin } from "./plugins/throughput.js"
|
|
5
|
-
export { default } from "./plugins/throughput.js"
|
|
6
|
-
export type { ThroughputPluginOptions } from "./plugins/throughput.js"
|
|
7
|
-
export { createLogStore } from "./io/log-store.js"
|
|
8
|
-
export type { LogStore } from "./io/log-store.js"
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Deprecated facade over aggregateAt — the public API is frozen, semantics
|
|
12
|
-
* unchanged. The library's single default-clock point lives here on purpose:
|
|
13
|
-
* index.ts is outside the src/core purity guard (test/guard.test.ts).
|
|
14
|
-
*/
|
|
15
|
-
export function aggregate(entries: readonly LogEntry[]): Map<string, ModelStats> {
|
|
16
|
-
return aggregateAt(entries, Date.now())
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export type { LogEntry, ModelStats }
|
package/src/io/log-store.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import type { LogEntry } from "../types.js"
|
|
2
|
-
import { decodeLogContent, encodeLogEntry } from "../core/log-codec.js"
|
|
3
|
-
import fs from "fs"
|
|
4
|
-
import os from "os"
|
|
5
|
-
import path from "path"
|
|
6
|
-
|
|
7
|
-
export interface LogStore {
|
|
8
|
-
/**
|
|
9
|
-
* Fault-tolerant append: dir creation memoized; sync appendFileSync; IO
|
|
10
|
-
* errors go to onError (default console.warn), never thrown.
|
|
11
|
-
*/
|
|
12
|
-
append(entry: LogEntry): void
|
|
13
|
-
/**
|
|
14
|
-
* ENOENT -> empty; decode-tolerant (skips corrupt lines, counts them);
|
|
15
|
-
* other IO errors (e.g. EACCES) are thrown to caller.
|
|
16
|
-
*/
|
|
17
|
-
read(): { entries: LogEntry[]; skipped: number }
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function createLogStore(opts?: {
|
|
21
|
-
file?: string
|
|
22
|
-
onError?: (e: unknown) => void
|
|
23
|
-
}): LogStore {
|
|
24
|
-
const file = opts?.file ?? path.join(os.homedir(), ".opencode", "throughput.jsonl")
|
|
25
|
-
const onError = opts?.onError ?? ((e: unknown) => console.warn(e))
|
|
26
|
-
|
|
27
|
-
// Memoized on success only: a failed mkdir retries on the next append.
|
|
28
|
-
let dirReady = false
|
|
29
|
-
const ensureDir = (): void => {
|
|
30
|
-
if (dirReady) return
|
|
31
|
-
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
32
|
-
dirReady = true
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return {
|
|
36
|
-
append(entry) {
|
|
37
|
-
try {
|
|
38
|
-
ensureDir()
|
|
39
|
-
fs.appendFileSync(file, encodeLogEntry(entry), "utf-8")
|
|
40
|
-
} catch (e) {
|
|
41
|
-
onError(e)
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
|
-
read() {
|
|
45
|
-
let content: string
|
|
46
|
-
try {
|
|
47
|
-
content = fs.readFileSync(file, "utf-8")
|
|
48
|
-
} catch (e) {
|
|
49
|
-
if ((e as NodeJS.ErrnoException).code === "ENOENT") return { entries: [], skipped: 0 }
|
|
50
|
-
throw e
|
|
51
|
-
}
|
|
52
|
-
return decodeLogContent(content)
|
|
53
|
-
},
|
|
54
|
-
}
|
|
55
|
-
}
|
package/src/io/perf-file.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import type { LogEntry } from "../types.js"
|
|
2
|
-
import { formatPerfLine } from "../format/perf.js"
|
|
3
|
-
import fs from "fs"
|
|
4
|
-
import path from "path"
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Appends formatPerfLine(entry) to <directory>/.opencode/throughput.md.
|
|
8
|
-
* Fully fault-tolerant: any IO error is reported to onError (default: silent)
|
|
9
|
-
* and never thrown. Dir creation is memoized on success.
|
|
10
|
-
*/
|
|
11
|
-
export function createPerfFileWriter(
|
|
12
|
-
directory: string,
|
|
13
|
-
onError?: (e: unknown) => void
|
|
14
|
-
): (entry: LogEntry) => void {
|
|
15
|
-
const file = path.join(directory, ".opencode", "throughput.md")
|
|
16
|
-
const handleError = onError ?? (() => {})
|
|
17
|
-
|
|
18
|
-
let dirReady = false
|
|
19
|
-
return (entry: LogEntry) => {
|
|
20
|
-
try {
|
|
21
|
-
if (!dirReady) {
|
|
22
|
-
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
23
|
-
dirReady = true
|
|
24
|
-
}
|
|
25
|
-
fs.appendFileSync(file, formatPerfLine(entry), "utf-8")
|
|
26
|
-
} catch (e) {
|
|
27
|
-
handleError(e)
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { Plugin } from "@opencode/plugin"
|
|
2
|
-
import type { LogEntry } from "../types.js"
|
|
3
|
-
import { StepTracker } from "../core/step-tracker.js"
|
|
4
|
-
import { createPipeline, makeSafeOnError } from "../runtime/pipeline.js"
|
|
5
|
-
import { makeOwns } from "../core/ownership.js"
|
|
6
|
-
import { createLogStore, type LogStore } from "../io/log-store.js"
|
|
7
|
-
import { createPerfFileWriter } from "../io/perf-file.js"
|
|
8
|
-
import { executeBenchmark } from "../benchmark.js"
|
|
9
|
-
|
|
10
|
-
export type ThroughputPluginOptions = {
|
|
11
|
-
/** Injected persistence; defaults to the global ~/.opencode/throughput.jsonl store. */
|
|
12
|
-
logStore?: LogStore
|
|
13
|
-
/** Diagnostics sink for per-event failures (default: console.error). Never kills the subscription. */
|
|
14
|
-
onError?: (e: unknown) => void
|
|
15
|
-
/** Clock injection for tests. */
|
|
16
|
-
now?: () => number
|
|
17
|
-
/** Grace window for late terminal events after session.idle before an entry is recorded as finish:"aborted" (default 5000). */
|
|
18
|
-
abortTtlMs?: number
|
|
19
|
-
/** Fallback eviction window for active steps that receive neither a terminal event nor session.idle, in ms (default 600000 = 10 min; 0 or negative disables). */
|
|
20
|
-
activeTtlMs?: number
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function createThroughputPlugin(options: ThroughputPluginOptions = {}) {
|
|
24
|
-
return Plugin.define({
|
|
25
|
-
id: "opencode-throughput",
|
|
26
|
-
async setup(ctx) {
|
|
27
|
-
const onError = options.onError ?? ((e: unknown) => console.error(e))
|
|
28
|
-
const tracker = new StepTracker({ activeTtlMs: options.activeTtlMs })
|
|
29
|
-
const logStore = options.logStore ?? createLogStore({ onError })
|
|
30
|
-
const writePerf = createPerfFileWriter(ctx.location.directory, onError)
|
|
31
|
-
const owns = makeOwns(ctx.location.directory)
|
|
32
|
-
const now = options.now ?? Date.now
|
|
33
|
-
const abortTtlMs = options.abortTtlMs ?? 5000
|
|
34
|
-
|
|
35
|
-
await ctx.tool.transform((editor) => {
|
|
36
|
-
editor.add({
|
|
37
|
-
name: "benchmark",
|
|
38
|
-
description:
|
|
39
|
-
"Query model performance benchmarks including latency, throughput (TTFT, TPS), token usage, and cost. Shows stats aggregated by model.",
|
|
40
|
-
input: {
|
|
41
|
-
type: "object",
|
|
42
|
-
properties: {
|
|
43
|
-
model: {
|
|
44
|
-
type: "string",
|
|
45
|
-
description:
|
|
46
|
-
"Filter by model name (partial match, e.g. 'claude' or 'gpt'). Omit for all models.",
|
|
47
|
-
},
|
|
48
|
-
last: {
|
|
49
|
-
type: "integer",
|
|
50
|
-
minimum: 1,
|
|
51
|
-
description: "Only show the last N entries (default: show all)",
|
|
52
|
-
},
|
|
53
|
-
},
|
|
54
|
-
additionalProperties: false,
|
|
55
|
-
},
|
|
56
|
-
async execute(args) {
|
|
57
|
-
return await executeBenchmark(args, { logStore })
|
|
58
|
-
},
|
|
59
|
-
})
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
const controller = new AbortController()
|
|
63
|
-
|
|
64
|
-
// Per-sink isolation: each sink gets its own boundary, so a failing
|
|
65
|
-
// append never skips the perf file (and vice versa). Default sinks
|
|
66
|
-
// never throw; injected sinks may.
|
|
67
|
-
function record(entry: LogEntry) {
|
|
68
|
-
try {
|
|
69
|
-
logStore.append(entry)
|
|
70
|
-
} catch (e) {
|
|
71
|
-
onError(e)
|
|
72
|
-
}
|
|
73
|
-
try {
|
|
74
|
-
writePerf(entry)
|
|
75
|
-
} catch (e) {
|
|
76
|
-
onError(e)
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const pipeline = createPipeline({
|
|
81
|
-
tracker,
|
|
82
|
-
owns,
|
|
83
|
-
now,
|
|
84
|
-
abortTtlMs,
|
|
85
|
-
onEntry: record,
|
|
86
|
-
onError,
|
|
87
|
-
timers: {
|
|
88
|
-
set: (cb, ms) => setTimeout(cb, ms),
|
|
89
|
-
clear: (h) => clearTimeout(h as Parameters<typeof clearTimeout>[0]),
|
|
90
|
-
},
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
const task = (async () => {
|
|
94
|
-
// dispatch() is designed never to throw (it owns the per-event error
|
|
95
|
-
// boundary); task.catch below remains the last resort if the
|
|
96
|
-
// subscription itself dies.
|
|
97
|
-
for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
|
|
98
|
-
pipeline.dispatch(ev)
|
|
99
|
-
}
|
|
100
|
-
})()
|
|
101
|
-
// Last resort if the subscription itself dies (e.g. the host adapter
|
|
102
|
-
// fails to encode an event before it reaches the plugin). Report with
|
|
103
|
-
// plugin identity, keep the original as `cause`, and stay fail-stop:
|
|
104
|
-
// no resubscribe. A throwing onError here is swallowed by safeOnError.
|
|
105
|
-
const reportSubscriptionDeath = makeSafeOnError(onError)
|
|
106
|
-
task.catch((e: unknown) => {
|
|
107
|
-
reportSubscriptionDeath(
|
|
108
|
-
new Error(`throughput: event subscription died: ${e instanceof Error ? e.message : String(e)}`, {
|
|
109
|
-
cause: e,
|
|
110
|
-
})
|
|
111
|
-
)
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
return () => {
|
|
115
|
-
pipeline.dispose()
|
|
116
|
-
controller.abort()
|
|
117
|
-
}
|
|
118
|
-
},
|
|
119
|
-
})
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Back-compat default instance (path & exports unchanged). */
|
|
123
|
-
export const ThroughputPlugin = createThroughputPlugin()
|
|
124
|
-
export default ThroughputPlugin
|
package/src/runtime/pipeline.ts
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import type { StepTracker } from "../core/step-tracker.js"
|
|
2
|
-
import { buildAbortedEntry } from "../core/step-tracker.js"
|
|
3
|
-
import { normalizeEvent, applyThroughputEvent } from "../core/events.js"
|
|
4
|
-
import type { LocationLike } from "../core/ownership.js"
|
|
5
|
-
import type { LogEntry } from "../types.js"
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Timer port: a minimal injectable subset of the host timer API. Wrapping
|
|
9
|
-
* calls (arrow functions at the assembly layer) avoids `this`-binding issues
|
|
10
|
-
* with bare host references.
|
|
11
|
-
*/
|
|
12
|
-
export type TimerPort = {
|
|
13
|
-
set: (cb: () => void, ms: number) => unknown
|
|
14
|
-
clear: (handle: unknown) => void
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export type PipelineDeps = {
|
|
18
|
-
tracker: StepTracker
|
|
19
|
-
owns: (ev: LocationLike) => boolean
|
|
20
|
-
now: () => number
|
|
21
|
-
abortTtlMs: number
|
|
22
|
-
onEntry: (entry: LogEntry) => void
|
|
23
|
-
onError: (e: unknown) => void
|
|
24
|
-
timers?: TimerPort
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Wrap a user-supplied onError so a reporter that throws itself never
|
|
29
|
-
* escapes the pipeline. The failure is reported via console.error; if
|
|
30
|
-
* console.error itself throws, that propagates unchecked by design.
|
|
31
|
-
* (Stage labels — owns/handler/drain/timer — live in code comments only;
|
|
32
|
-
* the public onError signature stays single-arg.)
|
|
33
|
-
*
|
|
34
|
-
* Internal export: shared with the assembly layers (e.g. the server's
|
|
35
|
-
* subscription-death reporter); not part of the public index.ts surface.
|
|
36
|
-
*/
|
|
37
|
-
export function makeSafeOnError(onError: (e: unknown) => void): (e: unknown) => void {
|
|
38
|
-
return (e) => {
|
|
39
|
-
try {
|
|
40
|
-
onError(e)
|
|
41
|
-
} catch (reporterError) {
|
|
42
|
-
console.error(reporterError)
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Shared engine for both entrypoints: event normalization + application,
|
|
49
|
-
* opportunistic expiry drain, and single-timer deadline management.
|
|
50
|
-
*
|
|
51
|
-
* Semantics (phase-3 rulings):
|
|
52
|
-
* - Non-owned events return immediately: no drain, no re-arm (server rule).
|
|
53
|
-
* - Owned events always drain + re-arm in a finally — including unknown
|
|
54
|
-
* event types (previous switch fall-through) and after handler failures.
|
|
55
|
-
* - `recordedAt = now()` is taken only for known events, right before apply.
|
|
56
|
-
* - The flush callback re-arms in a finally, so a drain failure never
|
|
57
|
-
* breaks the deadline chain.
|
|
58
|
-
* - After dispose(), dispatch is a no-op and the armed timer is cleared.
|
|
59
|
-
*
|
|
60
|
-
* Internal module: not part of the public index.ts surface (tests may deep
|
|
61
|
-
* path import).
|
|
62
|
-
*/
|
|
63
|
-
export function createPipeline(deps: PipelineDeps): {
|
|
64
|
-
dispatch(raw: unknown): void
|
|
65
|
-
dispose(): void
|
|
66
|
-
} {
|
|
67
|
-
const timers: TimerPort =
|
|
68
|
-
deps.timers ?? {
|
|
69
|
-
set: (cb, ms) => setTimeout(cb, ms),
|
|
70
|
-
clear: (h) => clearTimeout(h as Parameters<typeof clearTimeout>[0]),
|
|
71
|
-
}
|
|
72
|
-
const safeOnError = makeSafeOnError(deps.onError)
|
|
73
|
-
const { tracker, now, abortTtlMs, onEntry } = deps
|
|
74
|
-
|
|
75
|
-
let flushTimer: unknown = null
|
|
76
|
-
let disposed = false
|
|
77
|
-
|
|
78
|
-
function drainExpired(): void {
|
|
79
|
-
for (const { assistantMessageID, state } of tracker.collectExpired(now())) {
|
|
80
|
-
try {
|
|
81
|
-
// Per-record boundary: one failing record never drops the remaining expired entries.
|
|
82
|
-
onEntry(buildAbortedEntry(state, assistantMessageID, now()))
|
|
83
|
-
} catch (e) {
|
|
84
|
-
// drain stage
|
|
85
|
-
safeOnError(e)
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// One timer targeting the earliest parked deadline; only its callback clears the handle.
|
|
91
|
-
function rearm(): void {
|
|
92
|
-
if (flushTimer != null) timers.clear(flushTimer)
|
|
93
|
-
flushTimer = null
|
|
94
|
-
if (disposed) return
|
|
95
|
-
const next = tracker.nextDeadline()
|
|
96
|
-
if (next == null) return
|
|
97
|
-
flushTimer = timers.set(() => {
|
|
98
|
-
flushTimer = null
|
|
99
|
-
if (disposed) return
|
|
100
|
-
try {
|
|
101
|
-
// timer stage; drainExpired is per-record guarded — this outer try
|
|
102
|
-
// only catches unexpected collectExpired/nextDeadline failures.
|
|
103
|
-
drainExpired()
|
|
104
|
-
} catch (e) {
|
|
105
|
-
safeOnError(e)
|
|
106
|
-
} finally {
|
|
107
|
-
rearm()
|
|
108
|
-
}
|
|
109
|
-
}, Math.max(0, next - now()))
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function dispatch(raw: unknown): void {
|
|
113
|
-
if (disposed) return // post-dispose dispatch is a no-op
|
|
114
|
-
|
|
115
|
-
// owns stage: reading ev.location may throw; current semantics = report
|
|
116
|
-
// and skip this turn entirely (no drain).
|
|
117
|
-
let owned: boolean
|
|
118
|
-
try {
|
|
119
|
-
owned = deps.owns(raw as LocationLike)
|
|
120
|
-
} catch (e) {
|
|
121
|
-
safeOnError(e)
|
|
122
|
-
return
|
|
123
|
-
}
|
|
124
|
-
if (!owned) return // not owned: no drain, no re-arm
|
|
125
|
-
|
|
126
|
-
const n = normalizeEvent(raw)
|
|
127
|
-
if (!n.ok && n.reason === "malformed") {
|
|
128
|
-
// Reserved channel, currently unreachable: normalizeEvent never
|
|
129
|
-
// returns "malformed". Wired conservatively: report and skip the turn.
|
|
130
|
-
safeOnError(new Error("throughput: malformed event rejected"))
|
|
131
|
-
return
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
try {
|
|
135
|
-
if (n.ok) {
|
|
136
|
-
// handler stage: recordedAt is taken only for known events, just
|
|
137
|
-
// before apply (unknown events never read the clock here).
|
|
138
|
-
const recordedAt = now()
|
|
139
|
-
const entry = applyThroughputEvent(tracker, n.event, { recordedAt, abortTtlMs })
|
|
140
|
-
if (entry) onEntry(entry)
|
|
141
|
-
}
|
|
142
|
-
} catch (e) {
|
|
143
|
-
// handler stage (payload property access or the entry sink)
|
|
144
|
-
safeOnError(e)
|
|
145
|
-
} finally {
|
|
146
|
-
// Owned events always drain + re-arm — unknown types included, and
|
|
147
|
-
// even when the handler above failed.
|
|
148
|
-
drainExpired()
|
|
149
|
-
rearm()
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function dispose(): void {
|
|
154
|
-
if (disposed) return
|
|
155
|
-
disposed = true
|
|
156
|
-
if (flushTimer != null) {
|
|
157
|
-
timers.clear(flushTimer)
|
|
158
|
-
flushTimer = null
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
return { dispatch, dispose }
|
|
163
|
-
}
|
package/src/tui/controller.ts
DELETED
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
// No solid-js here: under host worker conditions it may resolve to the SSR
|
|
2
|
-
// build or a different instance than the renderer's; reactivity flows through the host store.
|
|
3
|
-
import type { LogEntry } from "../types.js"
|
|
4
|
-
import { StepTracker } from "../core/step-tracker.js"
|
|
5
|
-
import { SUBSCRIBED_EVENT_TYPES } from "../core/events.js"
|
|
6
|
-
import { createPipeline } from "../runtime/pipeline.js"
|
|
7
|
-
import { makeOwns } from "../core/ownership.js"
|
|
8
|
-
import { aggregateAt } from "../core/stats.js"
|
|
9
|
-
import { buildLogMsg } from "../format/entry.js"
|
|
10
|
-
import { buildDisplayIds, type StatsRow } from "../ui/sidebar.js"
|
|
11
|
-
|
|
12
|
-
export type ThroughputTuiOptions = {
|
|
13
|
-
/** Diagnostics sink for per-event failures (default: console.error). Never kills a subscription. */
|
|
14
|
-
onError?: (e: unknown) => void
|
|
15
|
-
/** Clock injection for tests. */
|
|
16
|
-
now?: () => number
|
|
17
|
-
/** Grace window for late terminal events after session.idle before an entry is recorded as finish:"aborted" (default 5000). */
|
|
18
|
-
abortTtlMs?: number
|
|
19
|
-
/** Fallback eviction window for active steps that receive neither a terminal event nor session.idle, in ms (default 600000 = 10 min; 0 or negative disables). */
|
|
20
|
-
activeTtlMs?: number
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Structural subset of the real TUI plugin context (kept local for testability). */
|
|
24
|
-
export type TuiContext = {
|
|
25
|
-
location?: { directory?: string }
|
|
26
|
-
data: { on: (type: string, handler: (ev: any) => void) => () => void }
|
|
27
|
-
ui: {
|
|
28
|
-
toast: {
|
|
29
|
-
show: (toast: { title: string; message: string; variant: string; duration: number }) => void
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
/** Host render hook; keeps the sidebar live regardless of the plugin-side solid build. */
|
|
33
|
-
renderer?: { requestRender: () => void }
|
|
34
|
-
/** Host live store — the only reactive channel reaching the JSX runtime; optional so tests inject a plain fallback. */
|
|
35
|
-
storage?: {
|
|
36
|
-
memory: <Value extends object>(
|
|
37
|
-
key: string,
|
|
38
|
-
options: { readonly initial: Value }
|
|
39
|
-
) => readonly [Value, (mutation: (draft: Value) => void) => void]
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// StatsRow now lives with the sidebar layout code (ui/sidebar.ts); re-exported
|
|
44
|
-
// here so the controller's public type surface is unchanged for consumers.
|
|
45
|
-
export type { StatsRow } from "../ui/sidebar.js"
|
|
46
|
-
|
|
47
|
-
/** Recent entries shown in the sidebar; keeps the widget small. */
|
|
48
|
-
const MAX_RECENT = 10
|
|
49
|
-
/** Per-model history cap feeding aggregate(); bounds memory on long sessions. */
|
|
50
|
-
const BUFFER_CAP = 200
|
|
51
|
-
|
|
52
|
-
/** Sidebar state published to the host store. */
|
|
53
|
-
export type ThroughputLiveState = {
|
|
54
|
-
open: boolean
|
|
55
|
-
recent: LogEntry[]
|
|
56
|
-
rows: StatsRow[]
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Testable core of the TUI entry: owns the same event wiring as the server
|
|
61
|
-
* plugin but publishes sidebar state through the host's live store.
|
|
62
|
-
*/
|
|
63
|
-
export function createThroughputController(
|
|
64
|
-
context: TuiContext,
|
|
65
|
-
options: ThroughputTuiOptions = {}
|
|
66
|
-
): {
|
|
67
|
-
recent: () => LogEntry[]
|
|
68
|
-
statsRows: () => StatsRow[]
|
|
69
|
-
open: () => boolean
|
|
70
|
-
toggleOpen: () => void
|
|
71
|
-
unsubscribe: () => void
|
|
72
|
-
} {
|
|
73
|
-
const onError = options.onError ?? ((e: unknown) => console.error(e))
|
|
74
|
-
const now = options.now ?? Date.now
|
|
75
|
-
const abortTtlMs = options.abortTtlMs ?? 5000
|
|
76
|
-
|
|
77
|
-
const tracker = new StepTracker({ activeTtlMs: options.activeTtlMs })
|
|
78
|
-
// Sidebar-only history: the TUI renders per-model aggregates from this
|
|
79
|
-
// in-memory buffer; persistent logging is the server plugin's job.
|
|
80
|
-
const buffers = new Map<string, LogEntry[]>()
|
|
81
|
-
const owns = makeOwns(context.location?.directory)
|
|
82
|
-
|
|
83
|
-
// Live state via the host store (reactive in the renderer's world) with a
|
|
84
|
-
// plain-object fallback for hostless environments (tests).
|
|
85
|
-
const initial: ThroughputLiveState = { open: true, recent: [], rows: [] }
|
|
86
|
-
let fallback: ThroughputLiveState = { ...initial }
|
|
87
|
-
const store = context.storage?.memory("opencode-throughput:tui", { initial })
|
|
88
|
-
// Hot-reload reset (PR-B): unconditional construction-time reset. The
|
|
89
|
-
// closure buffers are per-instance and start empty, and the store is only
|
|
90
|
-
// an in-process channel between this controller and the renderer —
|
|
91
|
-
// cross-generation persistence (any shape) would render as ghost rows
|
|
92
|
-
// that collapse on the first record. Construction happens exactly once
|
|
93
|
-
// per generation, so resetting here is strictly correct; a throwing store
|
|
94
|
-
// mutation is reported, never fatal.
|
|
95
|
-
if (store) {
|
|
96
|
-
try {
|
|
97
|
-
store[1]((draft) => {
|
|
98
|
-
draft.open = true
|
|
99
|
-
draft.recent = []
|
|
100
|
-
draft.rows = []
|
|
101
|
-
})
|
|
102
|
-
} catch (e) {
|
|
103
|
-
onError(e)
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
const read = (): ThroughputLiveState => (store ? store[0] : fallback)
|
|
107
|
-
const mutate = (fn: (draft: ThroughputLiveState) => void): void => {
|
|
108
|
-
if (store) store[1](fn)
|
|
109
|
-
else fn(fallback)
|
|
110
|
-
context.renderer?.requestRender()
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Stats are recomputed eagerly on every record(); every recompute returns
|
|
114
|
-
// brand-new row/stats objects so identity-based consumers always see
|
|
115
|
-
// fresh rows.
|
|
116
|
-
function recomputeRows(): StatsRow[] {
|
|
117
|
-
const modelIDs = new Set<string>()
|
|
118
|
-
for (const entries of buffers.values()) modelIDs.add(entries[entries.length - 1].modelID)
|
|
119
|
-
// Display IDs are stable across recomputes: first-seen keeps the 3-char form (see buildDisplayIds).
|
|
120
|
-
const displayIds = buildDisplayIds([...modelIDs])
|
|
121
|
-
const rows: StatsRow[] = []
|
|
122
|
-
for (const [modelKey, entries] of buffers) {
|
|
123
|
-
const stats = aggregateAt(entries, now()).get(modelKey)
|
|
124
|
-
if (!stats) continue
|
|
125
|
-
// modelID taken from the model's most recent entry (structured, not
|
|
126
|
-
// split from the aggregate key).
|
|
127
|
-
const modelID = entries[entries.length - 1].modelID
|
|
128
|
-
rows.push({ stats, modelID, displayID: displayIds.get(modelID)! })
|
|
129
|
-
}
|
|
130
|
-
return rows
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function showToast(entry: LogEntry) {
|
|
134
|
-
context.ui.toast.show({
|
|
135
|
-
title: "Throughput",
|
|
136
|
-
message: buildLogMsg(entry),
|
|
137
|
-
// "aborted" is a normal lifecycle outcome, not a failure; every other non-stop finish warns.
|
|
138
|
-
variant: entry.finish === "stop" || entry.finish === "aborted" ? "info" : "warning",
|
|
139
|
-
duration: 4000,
|
|
140
|
-
})
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function record(entry: LogEntry) {
|
|
144
|
-
const buffer = buffers.get(entry.model) ?? []
|
|
145
|
-
buffer.push(entry)
|
|
146
|
-
if (buffer.length > BUFFER_CAP) buffer.splice(0, buffer.length - BUFFER_CAP)
|
|
147
|
-
buffers.set(entry.model, buffer)
|
|
148
|
-
const rows = recomputeRows()
|
|
149
|
-
mutate((draft) => {
|
|
150
|
-
const next = [...draft.recent, entry]
|
|
151
|
-
draft.recent = next.length > MAX_RECENT ? next.slice(-MAX_RECENT) : next
|
|
152
|
-
draft.rows = rows
|
|
153
|
-
})
|
|
154
|
-
showToast(entry)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Shared engine: owns routing, event application, expiry drain and the
|
|
158
|
-
// single flush timer (see runtime/pipeline.ts).
|
|
159
|
-
const pipeline = createPipeline({
|
|
160
|
-
tracker,
|
|
161
|
-
owns,
|
|
162
|
-
now,
|
|
163
|
-
abortTtlMs,
|
|
164
|
-
onEntry: record,
|
|
165
|
-
onError,
|
|
166
|
-
timers: {
|
|
167
|
-
set: (cb, ms) => setTimeout(cb, ms),
|
|
168
|
-
clear: (h) => clearTimeout(h as Parameters<typeof clearTimeout>[0]),
|
|
169
|
-
},
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
// Single subscription handler for all monitored event types (the type
|
|
173
|
-
// routing itself lives in core/events.ts). `ev: any` is the one allowed
|
|
174
|
-
// controlled cast at the host boundary.
|
|
175
|
-
const onSessionEvent = (ev: any) => pipeline.dispatch(ev)
|
|
176
|
-
|
|
177
|
-
const stops = SUBSCRIBED_EVENT_TYPES.map((t) => context.data.on(t, onSessionEvent))
|
|
178
|
-
|
|
179
|
-
function unsubscribe() {
|
|
180
|
-
pipeline.dispose()
|
|
181
|
-
for (const off of stops) off()
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return {
|
|
185
|
-
recent: () => read().recent,
|
|
186
|
-
statsRows: () => read().rows,
|
|
187
|
-
open: () => read().open,
|
|
188
|
-
toggleOpen: () => mutate((draft) => { draft.open = !draft.open }),
|
|
189
|
-
unsubscribe,
|
|
190
|
-
}
|
|
191
|
-
}
|
package/src/tui.tsx
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { Plugin } from "@opencode/plugin/tui"
|
|
2
|
-
import {
|
|
3
|
-
createThroughputController,
|
|
4
|
-
type TuiContext,
|
|
5
|
-
type ThroughputTuiOptions,
|
|
6
|
-
} from "./tui/controller.js"
|
|
7
|
-
import { ThroughputWidget, type ThemeTokens } from "./ui/throughput-widget.js"
|
|
8
|
-
|
|
9
|
-
export function createThroughputTuiPlugin(options: ThroughputTuiOptions = {}) {
|
|
10
|
-
return Plugin.define({
|
|
11
|
-
id: "opencode-throughput",
|
|
12
|
-
setup(context) {
|
|
13
|
-
// Structural bridge to keep the controller testable without host SDK types.
|
|
14
|
-
const controller = createThroughputController(context as TuiContext, options)
|
|
15
|
-
const stopSlot = context.ui.slot({
|
|
16
|
-
append: "sidebar.content",
|
|
17
|
-
render: () =>
|
|
18
|
-
ThroughputWidget({
|
|
19
|
-
theme: context.theme as ThemeTokens,
|
|
20
|
-
recent: controller.recent,
|
|
21
|
-
stats: controller.statsRows,
|
|
22
|
-
open: controller.open,
|
|
23
|
-
onToggle: controller.toggleOpen,
|
|
24
|
-
}),
|
|
25
|
-
})
|
|
26
|
-
return () => {
|
|
27
|
-
stopSlot()
|
|
28
|
-
controller.unsubscribe()
|
|
29
|
-
}
|
|
30
|
-
},
|
|
31
|
-
})
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Back-compat default instance (path & exports unchanged). */
|
|
35
|
-
export const ThroughputTuiPlugin = createThroughputTuiPlugin()
|
|
36
|
-
export default ThroughputTuiPlugin
|