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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +201 -0
- package/README.es.md +166 -0
- package/README.hi.md +166 -0
- package/README.md +166 -0
- package/README.pt.md +166 -0
- package/README.zh.md +166 -0
- package/THIRD_PARTY_NOTICES.md +20 -0
- package/cordis.patch.yml +55 -0
- package/lib/index.js +844 -0
- package/lib/types/analyze.d.ts +41 -0
- package/lib/types/analyze.d.ts.map +1 -0
- package/lib/types/analyze.js +121 -0
- package/lib/types/analyze.js.map +1 -0
- package/lib/types/collector.d.ts +82 -0
- package/lib/types/collector.d.ts.map +1 -0
- package/lib/types/collector.js +236 -0
- package/lib/types/collector.js.map +1 -0
- package/lib/types/config.d.ts +76 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/config.js +93 -0
- package/lib/types/config.js.map +1 -0
- package/lib/types/estimate.d.ts +24 -0
- package/lib/types/estimate.d.ts.map +1 -0
- package/lib/types/estimate.js +37 -0
- package/lib/types/estimate.js.map +1 -0
- package/lib/types/index.d.ts +35 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +201 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/model.d.ts +91 -0
- package/lib/types/model.d.ts.map +1 -0
- package/lib/types/model.js +11 -0
- package/lib/types/model.js.map +1 -0
- package/lib/types/sanitize.d.ts +33 -0
- package/lib/types/sanitize.d.ts.map +1 -0
- package/lib/types/sanitize.js +59 -0
- package/lib/types/sanitize.js.map +1 -0
- package/lib/types/store.d.ts +74 -0
- package/lib/types/store.d.ts.map +1 -0
- package/lib/types/store.js +84 -0
- package/lib/types/store.js.map +1 -0
- package/lib/types/version.d.ts +3 -0
- package/lib/types/version.d.ts.map +1 -0
- package/lib/types/version.js +3 -0
- package/lib/types/version.js.map +1 -0
- package/package.json +147 -0
- package/src/analyze.ts +148 -0
- package/src/collector.ts +289 -0
- package/src/config.ts +163 -0
- package/src/estimate.ts +41 -0
- package/src/index.ts +235 -0
- package/src/model.ts +98 -0
- package/src/sanitize.ts +60 -0
- package/src/store.ts +100 -0
- package/src/version.ts +2 -0
package/src/collector.ts
ADDED
|
@@ -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
|
+
}
|
package/src/estimate.ts
ADDED
|
@@ -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
|
+
}
|