dsh-fast 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +201 -0
  3. package/README.es.md +166 -0
  4. package/README.hi.md +166 -0
  5. package/README.md +166 -0
  6. package/README.pt.md +166 -0
  7. package/README.zh.md +166 -0
  8. package/THIRD_PARTY_NOTICES.md +20 -0
  9. package/cordis.patch.yml +55 -0
  10. package/lib/index.js +844 -0
  11. package/lib/types/analyze.d.ts +41 -0
  12. package/lib/types/analyze.d.ts.map +1 -0
  13. package/lib/types/analyze.js +121 -0
  14. package/lib/types/analyze.js.map +1 -0
  15. package/lib/types/collector.d.ts +82 -0
  16. package/lib/types/collector.d.ts.map +1 -0
  17. package/lib/types/collector.js +236 -0
  18. package/lib/types/collector.js.map +1 -0
  19. package/lib/types/config.d.ts +76 -0
  20. package/lib/types/config.d.ts.map +1 -0
  21. package/lib/types/config.js +93 -0
  22. package/lib/types/config.js.map +1 -0
  23. package/lib/types/estimate.d.ts +24 -0
  24. package/lib/types/estimate.d.ts.map +1 -0
  25. package/lib/types/estimate.js +37 -0
  26. package/lib/types/estimate.js.map +1 -0
  27. package/lib/types/index.d.ts +35 -0
  28. package/lib/types/index.d.ts.map +1 -0
  29. package/lib/types/index.js +201 -0
  30. package/lib/types/index.js.map +1 -0
  31. package/lib/types/model.d.ts +91 -0
  32. package/lib/types/model.d.ts.map +1 -0
  33. package/lib/types/model.js +11 -0
  34. package/lib/types/model.js.map +1 -0
  35. package/lib/types/sanitize.d.ts +33 -0
  36. package/lib/types/sanitize.d.ts.map +1 -0
  37. package/lib/types/sanitize.js +59 -0
  38. package/lib/types/sanitize.js.map +1 -0
  39. package/lib/types/store.d.ts +74 -0
  40. package/lib/types/store.d.ts.map +1 -0
  41. package/lib/types/store.js +84 -0
  42. package/lib/types/store.js.map +1 -0
  43. package/lib/types/version.d.ts +3 -0
  44. package/lib/types/version.d.ts.map +1 -0
  45. package/lib/types/version.js +3 -0
  46. package/lib/types/version.js.map +1 -0
  47. package/package.json +147 -0
  48. package/src/analyze.ts +148 -0
  49. package/src/collector.ts +289 -0
  50. package/src/config.ts +163 -0
  51. package/src/estimate.ts +41 -0
  52. package/src/index.ts +235 -0
  53. package/src/model.ts +98 -0
  54. package/src/sanitize.ts +60 -0
  55. package/src/store.ts +100 -0
  56. package/src/version.ts +2 -0
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The session/event collector: folds the durable session log into per-session
3
+ * counters and builds {@link FastSnapshot}s. All inline work is O(1) per event
4
+ * (increment counters, record the latest header) — the expensive context
5
+ * measurement is deferred to {@link snapshot}, which runs on the async
6
+ * sampling timer or on demand, never in the append hot path. State lives in a
7
+ * `Map` keyed by the live Session so the timer can iterate it.
8
+ * @module dsh-fast/collector
9
+ */
10
+
11
+ import type { Session, SessionEvent, ToolResultMessage } from '@deepseek-ai/dsh-session'
12
+ // Type-only: registers the `compaction/*` SessionEventMap merge this collector folds.
13
+ import type {} from '@deepseek-ai/dsh-compaction'
14
+ import type { FastSnapshot, CacheStats, ContextStats } from './model.ts'
15
+ import type { ResolvedConfig } from './config.ts'
16
+ import { estimateSystemTokens, estimateToolsTokens } from './estimate.ts'
17
+
18
+ /**
19
+ * The structural surface of the optional `ctx.tokenMeter` service. Only the
20
+ * fields dsh-fast reads are declared; the service is optional, so a host
21
+ * without it still reports system/tool-schema volumes (surface/total fall back
22
+ * to the header heuristic).
23
+ */
24
+ export interface TokenMeasurement {
25
+ readonly totalTokens: number
26
+ readonly surfaceTokens: number
27
+ }
28
+
29
+ /** Lazy lookup of the optional token meter. */
30
+ export type MeasureFn = (session: Session) => TokenMeasurement | undefined
31
+
32
+ /** The durable marker every spill notice carries (`... Full ... stored at: <locator> ...`). */
33
+ const SPILL_NOTICE_MARKERS = ['Full', 'stored at:'] as const
34
+
35
+ /** Flatten a tool result's model-facing text blocks to one string. */
36
+ export function flattenToolResultText(message: ToolResultMessage): string {
37
+ const block = message.content[0]
38
+ if (block === undefined) return ''
39
+ let text = ''
40
+ for (const inner of block.content) {
41
+ if (inner.type === 'text') text += inner.text
42
+ }
43
+ return text
44
+ }
45
+
46
+ /**
47
+ * Best-effort spill detection: a spilled tool result is one whose durable text
48
+ * carries the spill-policy notice (`Full … stored at: <locator>`). No dedicated
49
+ * session event exists, so this is a documented heuristic, not a hard signal.
50
+ * @param message - the tool result message.
51
+ * @returns true when the result looks spilled.
52
+ */
53
+ export function detectSpilledResult(message: ToolResultMessage): boolean {
54
+ const text = flattenToolResultText(message)
55
+ return SPILL_NOTICE_MARKERS.every(marker => text.includes(marker))
56
+ }
57
+
58
+ /** Fraction of `total` each bucket represents (0 when the total is 0). */
59
+ export function sharesOf(
60
+ total: number,
61
+ system: number,
62
+ tools: number,
63
+ surface: number,
64
+ ): Pick<ContextStats, 'systemShare' | 'toolsShare' | 'surfaceShare'> {
65
+ if (total <= 0) return { systemShare: 0, toolsShare: 0, surfaceShare: 0 }
66
+ return {
67
+ systemShare: system / total,
68
+ toolsShare: tools / total,
69
+ surfaceShare: surface / total,
70
+ }
71
+ }
72
+
73
+ /** Cache hit rate from aggregate tokens: `cacheRead / (input + cacheRead)`. */
74
+ export function hitRateOf(input: number, cacheRead: number): number | null {
75
+ const denominator = input + cacheRead
76
+ if (denominator <= 0) return null
77
+ return cacheRead / denominator
78
+ }
79
+
80
+ /** Per-session live state. */
81
+ interface FastState {
82
+ readonly session: Session
83
+ readonly createdAtMs: number
84
+ readonly kind: 'open' | 'restore'
85
+ readonly firstLiveSeq: number
86
+ timeToFirstRequestMs: number | null
87
+ spilledResults: number
88
+ compactionCount: number
89
+ compactionManual: number
90
+ compactionAutomatic: number
91
+ compactionShadowedTokens: number
92
+ inputTokens: number
93
+ cacheReadTokens: number
94
+ cacheWriteTokens: number
95
+ outputTokens: number
96
+ lastHeader: import('@deepseek-ai/dsh-session').EpochHeader | undefined
97
+ dirty: boolean
98
+ }
99
+
100
+ /**
101
+ * The event → snapshot collector over real Sessions. State is adopted lazily on
102
+ * the first event so an HMR reload (which does not replay `session/created`)
103
+ * still adopts existing live sessions.
104
+ */
105
+ export class FastCollector {
106
+ private readonly live = new Map<Session, FastState>()
107
+
108
+ /** @param config - the resolved plugin config. */
109
+ constructor(private readonly config: ResolvedConfig) {}
110
+
111
+ /** Adopt a session at its creation announcement. */
112
+ handleSessionCreated(session: Session): void {
113
+ this.adopt(session)
114
+ }
115
+
116
+ /** Drop a session leaving the store. */
117
+ handleSessionDisposed(session: Session): void {
118
+ this.live.delete(session)
119
+ }
120
+
121
+ /**
122
+ * Fold one appended session event (O(1) per event).
123
+ * @param session - the session the event belongs to.
124
+ * @param event - the appended event.
125
+ */
126
+ handleEvent(session: Session, event: SessionEvent): void {
127
+ const state = this.adopt(session)
128
+ switch (event.type) {
129
+ case 'request/header':
130
+ state.lastHeader = event.data.header
131
+ if (state.timeToFirstRequestMs === null) {
132
+ state.timeToFirstRequestMs = Math.max(0, event.time - state.createdAtMs)
133
+ state.dirty = true
134
+ }
135
+ break
136
+ case 'assistant/message':
137
+ this.foldUsage(state, event.data.usage)
138
+ break
139
+ case 'compaction/start':
140
+ state.compactionCount += 1
141
+ if (event.data.sourceCommandId === undefined) state.compactionAutomatic += 1
142
+ else state.compactionManual += 1
143
+ state.dirty = true
144
+ break
145
+ case 'compaction/summary':
146
+ state.compactionShadowedTokens += event.data.shadowedTokenCount
147
+ state.dirty = true
148
+ break
149
+ case 'tool/result':
150
+ if (this.config.detectSpilledResults && detectSpilledResult(event.data.message)) {
151
+ state.spilledResults += 1
152
+ state.dirty = true
153
+ }
154
+ break
155
+ default:
156
+ // Unknown or plugin-owned session events: nothing to fold.
157
+ break
158
+ }
159
+ }
160
+
161
+ /** The live sessions the sampling timer iterates. */
162
+ liveSessions(): IterableIterator<Session> {
163
+ return this.live.keys()
164
+ }
165
+
166
+ /** Whether a session is still live (adopted and not disposed). */
167
+ has(session: Session): boolean {
168
+ return this.live.has(session)
169
+ }
170
+
171
+ /** Whether a session has un-persisted changes. */
172
+ isDirty(session: Session): boolean {
173
+ return this.live.get(session)?.dirty ?? false
174
+ }
175
+
176
+ /** Clear the dirty flag after a snapshot is appended. */
177
+ markClean(session: Session): void {
178
+ const state = this.live.get(session)
179
+ if (state !== undefined) state.dirty = false
180
+ }
181
+
182
+ /**
183
+ * Build the current metric snapshot for one session. This is the only place
184
+ * the optional token meter is consulted, so it never runs in the append path.
185
+ * @param session - the session to snapshot.
186
+ * @param measure - optional token-meter measure function.
187
+ * @returns the snapshot.
188
+ */
189
+ snapshot(session: Session, measure?: MeasureFn): FastSnapshot {
190
+ const state = this.live.get(session)
191
+ if (state === undefined) return emptySnapshot()
192
+ const measurement = measure === undefined ? undefined : measure(session)
193
+ const systemTokens = estimateSystemTokens(state.lastHeader)
194
+ const toolSchemaTokens = estimateToolsTokens(state.lastHeader)
195
+ const surfaceTokens = measurement?.surfaceTokens ?? 0
196
+ const totalTokens = measurement?.totalTokens ?? (systemTokens + toolSchemaTokens + surfaceTokens)
197
+ return {
198
+ load: {
199
+ kind: state.kind,
200
+ seedEvents: state.firstLiveSeq,
201
+ timeToFirstRequestMs: state.timeToFirstRequestMs,
202
+ },
203
+ spill: { detectedSpilledResults: state.spilledResults, heuristic: true },
204
+ compaction: {
205
+ count: state.compactionCount,
206
+ manual: state.compactionManual,
207
+ automatic: state.compactionAutomatic,
208
+ shadowedTokens: state.compactionShadowedTokens,
209
+ },
210
+ context: {
211
+ totalTokens,
212
+ systemTokens,
213
+ toolSchemaTokens,
214
+ surfaceTokens,
215
+ ...sharesOf(totalTokens, systemTokens, toolSchemaTokens, surfaceTokens),
216
+ },
217
+ cache: this.cacheStats(state),
218
+ }
219
+ }
220
+
221
+ /** Aggregate cache counters into the report shape. */
222
+ private cacheStats(state: FastState): CacheStats {
223
+ return {
224
+ inputTokens: state.inputTokens,
225
+ cacheReadTokens: state.cacheReadTokens,
226
+ cacheWriteTokens: state.cacheWriteTokens,
227
+ outputTokens: state.outputTokens,
228
+ hitRate: hitRateOf(state.inputTokens, state.cacheReadTokens),
229
+ }
230
+ }
231
+
232
+ /** Fold one provider usage record. */
233
+ private foldUsage(
234
+ state: FastState,
235
+ usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } | undefined,
236
+ ): void {
237
+ if (usage === undefined) return
238
+ state.inputTokens += usage.inputTokens
239
+ state.outputTokens += usage.outputTokens
240
+ state.cacheReadTokens += usage.cacheReadTokens ?? 0
241
+ state.cacheWriteTokens += usage.cacheWriteTokens ?? 0
242
+ state.dirty = true
243
+ }
244
+
245
+ /** Adopt (or return the existing) live state for one session. */
246
+ private adopt(session: Session): FastState {
247
+ const existing = this.live.get(session)
248
+ if (existing !== undefined) return existing
249
+ const state: FastState = {
250
+ session,
251
+ createdAtMs: Date.now(),
252
+ kind: session.firstLiveSeq > 0 ? 'restore' : 'open',
253
+ firstLiveSeq: session.firstLiveSeq,
254
+ timeToFirstRequestMs: null,
255
+ spilledResults: 0,
256
+ compactionCount: 0,
257
+ compactionManual: 0,
258
+ compactionAutomatic: 0,
259
+ compactionShadowedTokens: 0,
260
+ inputTokens: 0,
261
+ cacheReadTokens: 0,
262
+ cacheWriteTokens: 0,
263
+ outputTokens: 0,
264
+ lastHeader: undefined,
265
+ dirty: true,
266
+ }
267
+ this.live.set(session, state)
268
+ return state
269
+ }
270
+ }
271
+
272
+ /** A zeroed snapshot for a session that is no longer live. */
273
+ function emptySnapshot(): FastSnapshot {
274
+ return {
275
+ load: { kind: 'open', seedEvents: 0, timeToFirstRequestMs: null },
276
+ spill: { detectedSpilledResults: 0, heuristic: true },
277
+ compaction: { count: 0, manual: 0, automatic: 0, shadowedTokens: 0 },
278
+ context: {
279
+ totalTokens: 0,
280
+ systemTokens: 0,
281
+ toolSchemaTokens: 0,
282
+ surfaceTokens: 0,
283
+ systemShare: 0,
284
+ toolsShare: 0,
285
+ surfaceShare: 0,
286
+ },
287
+ cache: { inputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0, hitRate: null },
288
+ }
289
+ }
package/src/config.ts ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Config schema and resolution for `dsh-fast`. Every tunable is a validated
3
+ * {@link Config} field changeable from cordis.yml; the resolution step
4
+ * validates numeric bounds so misconfiguration fails loud at mount. The plugin
5
+ * is read-only and safe, so it defaults to enabled — but `enabled: false`
6
+ * mounts nothing.
7
+ * @module dsh-fast/config
8
+ */
9
+
10
+ import z from '@deepseek-ai/schemastery'
11
+
12
+ /** Privacy switches for the report surfaces. */
13
+ export interface PrivacyConfig {
14
+ /** Include the sanitized session working directory in `/fast` and `fast_report`. Off by default: a local path is sensitive. */
15
+ includeCwd?: boolean
16
+ }
17
+
18
+ /** Async sampling policy (off the model path). */
19
+ export interface SamplingConfig {
20
+ /** How often active sessions are sampled, in milliseconds. */
21
+ snapshotIntervalMs?: number
22
+ /** How many samples to retain per session in the durable domain history. */
23
+ maxHistorySamples?: number
24
+ }
25
+
26
+ /** Suggestion thresholds — the values that decide which optimization notes appear. */
27
+ export interface ThresholdConfig {
28
+ /** Warn when assembled system-prompt tokens (AGENTS.md + skills + persona) exceed this. */
29
+ systemPromptTokens?: number
30
+ /** Warn when tool-schema tokens exceed this. */
31
+ toolSchemaTokens?: number
32
+ /** Warn when conversation-surface tokens exceed this. */
33
+ surfaceTokens?: number
34
+ /** Warn when the LLM cache hit rate falls below this (0..1). */
35
+ cacheHitRateFloor?: number
36
+ /** Warn once the session has triggered this many compactions. */
37
+ compactionCountWarn?: number
38
+ /** Warn when the average shadowed token count per completed summary exceeds this. */
39
+ compactionShadowTokens?: number
40
+ }
41
+
42
+ /** Spill-detection switch. */
43
+ export interface SpillConfig {
44
+ /** Detect spilled tool results from the durable spill-notice marker. Best-effort; see README "Known limitations". */
45
+ detectSpilledResults?: boolean
46
+ }
47
+
48
+ /** Raw plugin config — every field optional; {@link resolveConfig} supplies the defaults. */
49
+ export interface Config {
50
+ /** Master switch. Off by default? No — diagnostics are read-only and safe, so on by default. */
51
+ enabled?: boolean
52
+ privacy?: PrivacyConfig
53
+ sampling?: SamplingConfig
54
+ thresholds?: ThresholdConfig
55
+ spill?: SpillConfig
56
+ }
57
+
58
+ /** Fully resolved config handed to the runtime. */
59
+ export interface ResolvedConfig {
60
+ readonly enabled: boolean
61
+ readonly includeCwd: boolean
62
+ readonly snapshotIntervalMs: number
63
+ readonly maxHistorySamples: number
64
+ readonly detectSpilledResults: boolean
65
+ readonly thresholds: {
66
+ readonly systemPromptTokens: number
67
+ readonly toolSchemaTokens: number
68
+ readonly surfaceTokens: number
69
+ readonly cacheHitRateFloor: number
70
+ readonly compactionCountWarn: number
71
+ readonly compactionShadowTokens: number
72
+ }
73
+ }
74
+
75
+ /** Schemastery schema: the loader validates and fills defaults before `apply`. */
76
+ export const Config: z<Config> = z.object({
77
+ enabled: z.boolean().default(true),
78
+ privacy: z.object({
79
+ includeCwd: z.boolean().default(false),
80
+ }).default({ includeCwd: false }),
81
+ sampling: z.object({
82
+ snapshotIntervalMs: z.number().default(60_000),
83
+ maxHistorySamples: z.number().default(20),
84
+ }).default({ snapshotIntervalMs: 60_000, maxHistorySamples: 20 }),
85
+ thresholds: z.object({
86
+ systemPromptTokens: z.number().default(20_000),
87
+ toolSchemaTokens: z.number().default(8_000),
88
+ surfaceTokens: z.number().default(60_000),
89
+ cacheHitRateFloor: z.number().default(0.1),
90
+ compactionCountWarn: z.number().default(10),
91
+ compactionShadowTokens: z.number().default(40_000),
92
+ }).default({
93
+ systemPromptTokens: 20_000,
94
+ toolSchemaTokens: 8_000,
95
+ surfaceTokens: 60_000,
96
+ cacheHitRateFloor: 0.1,
97
+ compactionCountWarn: 10,
98
+ compactionShadowTokens: 40_000,
99
+ }),
100
+ spill: z.object({
101
+ detectSpilledResults: z.boolean().default(true),
102
+ }).default({ detectSpilledResults: true }),
103
+ })
104
+
105
+ /** Throw unless `value` is a positive safe integer. */
106
+ function assertPositiveInt(name: string, value: number): void {
107
+ if (!Number.isSafeInteger(value) || value <= 0) {
108
+ throw new TypeError(`${name} must be a positive safe integer, got ${String(value)}`)
109
+ }
110
+ }
111
+
112
+ /** Throw unless `value` is a finite number in `[min, max]`. */
113
+ function assertFiniteRange(name: string, value: number, min: number, max: number): void {
114
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
115
+ throw new TypeError(`${name} must be a finite number in [${min}, ${max}], got ${String(value)}`)
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Validate raw values and fill explicit defaults. Invalid bounds throw here —
121
+ * misconfiguration fails loud at mount even without the Schemastery loader.
122
+ * @param config - raw (possibly partial) plugin config.
123
+ * @returns the fully resolved config.
124
+ */
125
+ export function resolveConfig(config: Config = {}): ResolvedConfig {
126
+ const samplingRaw = config.sampling ?? {}
127
+ const thresholdsRaw = config.thresholds ?? {}
128
+ const spillRaw = config.spill ?? {}
129
+
130
+ const snapshotIntervalMs = samplingRaw.snapshotIntervalMs ?? 60_000
131
+ assertPositiveInt('sampling.snapshotIntervalMs', snapshotIntervalMs)
132
+ const maxHistorySamples = samplingRaw.maxHistorySamples ?? 20
133
+ assertPositiveInt('sampling.maxHistorySamples', maxHistorySamples)
134
+
135
+ const systemPromptTokens = thresholdsRaw.systemPromptTokens ?? 20_000
136
+ const toolSchemaTokens = thresholdsRaw.toolSchemaTokens ?? 8_000
137
+ const surfaceTokens = thresholdsRaw.surfaceTokens ?? 60_000
138
+ const cacheHitRateFloor = thresholdsRaw.cacheHitRateFloor ?? 0.1
139
+ const compactionCountWarn = thresholdsRaw.compactionCountWarn ?? 10
140
+ const compactionShadowTokens = thresholdsRaw.compactionShadowTokens ?? 40_000
141
+ assertPositiveInt('thresholds.systemPromptTokens', systemPromptTokens)
142
+ assertPositiveInt('thresholds.toolSchemaTokens', toolSchemaTokens)
143
+ assertPositiveInt('thresholds.surfaceTokens', surfaceTokens)
144
+ assertFiniteRange('thresholds.cacheHitRateFloor', cacheHitRateFloor, 0, 1)
145
+ assertPositiveInt('thresholds.compactionCountWarn', compactionCountWarn)
146
+ assertPositiveInt('thresholds.compactionShadowTokens', compactionShadowTokens)
147
+
148
+ return {
149
+ enabled: config.enabled ?? true,
150
+ includeCwd: config.privacy?.includeCwd ?? false,
151
+ snapshotIntervalMs,
152
+ maxHistorySamples,
153
+ detectSpilledResults: spillRaw.detectSpilledResults ?? true,
154
+ thresholds: {
155
+ systemPromptTokens,
156
+ toolSchemaTokens,
157
+ surfaceTokens,
158
+ cacheHitRateFloor,
159
+ compactionCountWarn,
160
+ compactionShadowTokens,
161
+ },
162
+ }
163
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Fixed-density heuristic token pricing for the context-injection breakdown.
3
+ * Mirrors the shared estimator in `@deepseek-ai/dsh-token-meter` (which is not
4
+ * exported as a public subpath), so `dsh-fast`'s system/tool figures match the
5
+ * token meter's heuristic vocabulary: `ceil(chars / 4)` plus framing overhead.
6
+ * These are protocol constants, not tunables — they must not drift from the
7
+ * meter they mirror.
8
+ * @module dsh-fast/estimate
9
+ */
10
+
11
+ import type { EpochHeader } from '@deepseek-ai/dsh-session'
12
+
13
+ /** Fixed text-density estimate (chars per token). */
14
+ const CHARS_PER_TOKEN = 4
15
+
16
+ /** Role-field framing overhead added to every priced message. */
17
+ const ROLE_OVERHEAD = 4
18
+
19
+ /** Per-block structural overhead for JSON framing and type tags. */
20
+ const BLOCK_OVERHEAD = 4
21
+
22
+ /**
23
+ * Price the assembled system prompt (AGENTS.md + skill directory + persona +
24
+ * harness instructions).
25
+ * @param header - canonical request envelope, or undefined before any request.
26
+ * @returns heuristic system-prompt tokens; 0 when absent.
27
+ */
28
+ export function estimateSystemTokens(header: EpochHeader | undefined): number {
29
+ if (header?.system === undefined) return 0
30
+ return Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD
31
+ }
32
+
33
+ /**
34
+ * Price the tool-schema part of the request envelope.
35
+ * @param header - canonical request envelope, or undefined before any request.
36
+ * @returns heuristic tool-schema tokens; 0 when absent or empty.
37
+ */
38
+ export function estimateToolsTokens(header: EpochHeader | undefined): number {
39
+ if (header?.tools === undefined || header.tools.length === 0) return 0
40
+ return Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
41
+ }