Files
dsh-context/src/shared/types.ts
T
Bowen Liang 6819fa7fe6 feat(compat): fold the V2 and V3 session-log generations alongside V0
The supported range (dsh 0.1.2-rc.1+) spans three durable-log generations, and the fold only read the V0 spellings — so on 0.1.5-alpha.1 sessions the system prompt priced 0, TTFT/generation stayed 0, and startSeq/endSeq replacements appended instead of replacing.

Read SHAPES, never a detected version (the probe can be wrong: a healed profile mirror may name another release than the running harness):

- system/message surface nodes feed systemTokens plus a bounded systems list (the harness's "last nonempty surviving system" rule), replacing the V0/V2 request/header.header.system envelope figure; the browser resolves the prompt in force per step and fetches its text from either event shape.
- The first token comes from the embedded assistant stream (assistant/message.data.stream / assistant/attempt.data.stream) when the log carries no V0 assistant/chunk deltas, mirroring dsh-llm's assistantStreamFirstTokenTime.
- Replacement endpoints accept both startSeq/endSeq and start/end.
- tool/ptc-dispatch books nested file ops alongside tool/code-dispatch.

Statistics were differentially verified against the harness's OWN projection values on real V0, V3-native, and V3-migrated logs: system/tools/message tokens, request and turn counts, TTFT, generation, tool time, and every billed cost bucket match exactly.

The new state fields are additive-optional, so stateVersion stays 15: a bump would orphan the key for idle sessions (the #37 regression).
2026-09-09 02:09:38 +08:00

572 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shared wire contract — the snapshot model exchanged between the Host and Client halves. Delivered as the `view()` payload of the
* `contextTimeline`/`contextHeaders` session projections (registered on `ctx.sessionProjections`; the registry pushes finished views as
* `session/projection` frames — see host/timeline.ts). TYPE-ONLY host-side module: both halves import these as `import type`, so nothing
* from here ever reaches the runtime bundles.
*/
import type { HeadersState } from '../host/headers'
import type { TimelineState } from '../host/fold'
// The registry package ROOT carries the `@deepseek-ai/cordis` Context
// augmentation (`sessionProjections` service); the `/types` subpath below
// only declares the merge-extensible maps.
import type {} from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-session-projection/types'
declare module '@deepseek-ai/dsh-session-projection/types' {
// Both maps take the plugin keys: `SessionProjectionMap` names the
// client-visible wire values, `SessionProjectionStateMap` constrains the
// unit `key`s and their fold-state types (the split arrived with
// dsh 0.1.1 — the definition's K constraint moved to the state map).
interface SessionProjectionMap {
/**
* The plugin's context timeline: current composition, counters, and the
* headline anchor. Since the split generation the wire value is the SLIM
* head (every delivery channel — session.list rows, control baselines,
* push frames — carries it whole); the per-request history, context
* events, and the surface/archive collections ride the on-demand detail
* channel ({@link ContextTimelineDetail}, host/detail.ts). Channel-less
* hosts keep the inline generation (the collections stay in the value).
* Key absence = the plugin's host half is not composed.
*/
contextTimeline: ContextTimeline
/**
* The request-header CONTENT epochs (full system prompt + tool schemas)
* behind the timeline's envelope figures. A separate unit so the hot
* `contextTimeline` value stays lean: headers change rarely, so this
* value (and its pushes) change only when a `request/header` lands.
* The Context browser card reads it to show the actual prompt/schema
* content of a picked step (key absence = older host: tokens only).
*/
contextHeaders: ContextHeaders
}
interface SessionProjectionStateMap {
contextTimeline: TimelineState
contextHeaders: HeadersState
}
}
export type Category = 'user' | 'inject' | 'assistant' | 'tool'
/**
* One live system-prompt node (Snapshot.systems) — the harness models the
* system prompt as a surface node, so its TEXT is fetched on demand from the
* event at `seq`: a V3 `system/message` event, or the V0/V2 `request/header`
* whose envelope carried `header.system`. `tokens` is the node's heuristic
* price (0 for a dormant empty node, which the harness reads as "no system
* prompt"); the effective figure is the LAST node with `tokens > 0`.
*/
export interface SystemPromptNode {
seq: number
time: number
tokens: number
}
/**
* The stats board's count figures, precomputed host-side over the RETAINED
* request/event records (the same set the detail payload serves). Carried by
* the split-generation wire head so the board — and the Agent card's
* per-session request tally — never need the collections themselves.
* `steps` doubles as the retained request-record count.
*/
export interface TimelineCounts {
turns: number
steps: number
injects: number
compactions: number
prunes: number
}
/**
* The newest retained request record's billing summary — the headline's
* derived-occupancy anchor (`prompt + surface movement since`), carried by
* the split-generation wire head so the headline never needs the request
* records themselves.
*/
export interface TimelineLast {
seq: number
total: number
prompt?: number
}
/**
* The per-user display-preference vocabulary of the `dsh-context` settings
* namespace — the ONE declaration both halves share: the Host registers the
* namespace schema against it (host/settings.ts), the Client binds the scope
* and edits fields by name (client/settings.ts). Type-only, so both bundles
* erase it.
*/
export type DefaultGranularity = 'step' | 'turn'
export type DefaultTrendMode = 'total' | 'delta'
/** File Activity row order: most operations first, most-recently-touched first, or path ascending. */
export type DefaultFileSort = 'count' | 'latest' | 'path'
export interface PluginSettings {
defaultGranularity: DefaultGranularity
defaultTrendMode: DefaultTrendMode
defaultFileSort: DefaultFileSort
}
/** The section fields the settings card edits, as the Host schema names them. */
export type SettingsField = keyof PluginSettings
export interface Snapshot {
ok: boolean
/**
* The host's baseline-gate record, present ONLY when the running harness
* is below the plugin's supported baseline: the host then folds nothing
* and every figure in this snapshot is zero/empty. The client keeps
* rendering the (blank) cards and pops the upgrade gate modal naming
* `current` (the detected harness version) and `minimum` (the baseline).
*/
unsupported?: {
current: string
minimum: string
}
model?: string
provider?: string
contextWindow?: number
current: {
system: number
tools: number
user: number
inject: number
assistant: number
tool: number
total: number
}
/**
* Image blocks live in the CURRENT context (user uploads plus tool-result
* images, nested blocks included) — the sum over the live surface nodes'
* `imgs`, so compaction/prune shrink it. Absent from older hosts; clients
* treat absence as zero.
*/
images?: number
/**
* Tool calls whose result is live in the CURRENT context (one `tool/result`
* folds to one `tool` surface node). Calls still in flight and results
* compacted/pruned out of the surface are not counted. Absent from older
* hosts; clients treat absence as zero.
*/
toolCalls?: number
/**
* Split-generation head fields — present exactly when the host serves the
* SLIM head (the heavy collections moved to the on-demand detail channel,
* host/detail.ts) and absent on the inline generation (older or
* channel-less hosts serve the collections in place). `detailRev` is the
* detail's revision marker: it bumps whenever the detail collections
* change, so an open tab refetches on the push alone.
*/
counts?: TimelineCounts
last?: TimelineLast
detailRev?: number
/**
* The per-request history / context-event collections. On the split
* generation these stay ABSENT from the wire value (every session.list row,
* control baseline, and push frame would carry them whole otherwise); the
* client fills them from the detail channel ({@link ContextTimelineDetail}).
*/
requests: RequestRecord[]
events: ContextEventRecord[]
/**
* Cumulative session-cost raw material (per-family, per-period billed
* token totals — see SessionCostUsage). Absent until a DeepSeek V4
* request reports usage.
*/
cost?: SessionCostUsage
/**
* Whole-session timing totals (see TimingTotals). Absent until the first
* step lifecycle completes in the log (older plugin builds never folded
* one — clients treat absence as an empty timing card).
*/
timing?: TimingTotals
/**
* The live system-prompt nodes, oldest first — the browser's per-step source
* for the System section. Absent when the log carried no system prompt, and
* on older plugin builds (the client then falls back to the header epoch's
* own `systemTokens`, the pre-V3 shape).
*/
systems?: SystemPromptNode[]
/**
* The served live surface: the newest `maxNodes` tail PLUS every live inject node older than the tail (injections land first and are
* few,
* so they are pinned). Seq-ordered, oldest first.
*/
nodes: SurfaceNode[]
/** Live nodes not served (the overflow beyond `maxNodes`, minus pinned injects — see `nodes`). */
droppedNodes: number
/**
* Recently REMOVED surface nodes (compaction/prune shadows), each stamped
* with `gone` (the replacing event's seq). Together with `nodes` this lets
* the Context browser reconstruct the assembled surface of any retained
* step: alive at request R = seq < R.seq && (gone undefined || gone > R.seq).
*/
archive: SurfaceNode[]
/**
* Coverage floor of the served live `nodes`: the newest seq among the
* `droppedNodes` live nodes not served. Present only when droppedNodes > 0.
*/
surfaceFloor?: number
/**
* Coverage floor of `archive`: the newest `gone` among archive entries the
* retention bounds dropped. Steps with seq < archiveFloor may miss removed
* nodes (the browser shows the reconstruction as approximate).
*/
archiveFloor?: number
/**
* The fold-derived file-operation log and its trim floor — present on the
* INLINE wire value (channel-less hosts) and on the detail payload
* (ContextTimelineDetail), absent from the slim head (they ride the detail
* channel there).
*/
fileOps?: FileOpRecord[]
fileOpsFloor?: number
}
/**
* The on-demand DETAIL payload of the split `contextTimeline` generation —
* the heavy collections (per-request records, context events, the served
* surface window, and the removed-node archive) that the slim wire head no
* longer carries through every delivery channel. The host serves it off the
* live fold state at the `/dsh-context` `detail` endpoint (host/detail.ts);
* `rev` mirrors the head's `detailRev` at build time and acts as the
* client's latest-wins cursor.
*/
export interface ContextTimelineDetail {
rev: number
requests: RequestRecord[]
events: ContextEventRecord[]
nodes: SurfaceNode[]
droppedNodes: number
archive: SurfaceNode[]
surfaceFloor?: number
archiveFloor?: number
/**
* The fold-derived file-operation log (shared/fileOps.ts): one record per
* executed file op, newest-retained, covering the full log (never
* window-bound like the client-side join derivation it replaces on this
* generation). Code-Mode nested dispatches book ops located on their
* parent run_code result (`parent`).
*/
fileOps?: FileOpRecord[]
/** The newest dropped op's seq when the op log trimmed (coverage honesty, same family as archiveFloor). */
fileOpsFloor?: number
}
/**
* One executed file operation (a settled file-tool call with a resolved
* target), folded host-side from the durable tool lifecycle: the call's
* name+arguments (`tool/call`), the result's presentation meta and error
* (`tool/result`), or a nested Code-Mode settle (`tool/code-dispatch`,
* located on its parent run_code result via `parent` + `program`).
*
* `gone` is NOT host-stamped: the client joins it from the detail's archive
* at render time (the op's result node leaving the live surface marks where
* its content is still viewable). Line deltas are estimates read off the
* call ARGUMENTS (an edit's old/new strings, a write's content), never off
* result payloads.
*/
export interface FileOpRecord {
seq: number
/** The op's file; for a pathless search the searched PATTERN (`pattern: true`). */
path: string
kind: 'read' | 'write' | 'search'
tool: string
time?: number
err: boolean
added: number
removed: number
/** What was searched for, when a search named both a path and a pattern. */
detail?: string
/** Meta-attributed search op only: matched lines the result reported for this file. */
hits?: number
/** Read ops only: the exact 1-based window the result meta reported, else the `limit`-argument estimate (`est: true`). */
read?: { start: number; count: number } | { count: number; est: true }
/** Nested Code-Mode op only: the run_code result node the op ran under (the locate target). */
parent?: number
/** Nested Code-Mode op only: the run_code program's model-authored description. */
program?: string
/** The searched-pattern marker: `path` is a pattern, not a file — display must not relativize it. */
pattern?: true
/** Client-joined archive stamp (see the type note); absent on the wire. */
gone?: number
}
/**
* The `contextTimeline` projection's whole value — the same snapshot the Client has always rendered. `ok` is always `true` here (a
* delivered projection is by definition available); kept for wire compatibility with the snapshot shape.
*/
export type ContextTimeline = Snapshot
/**
* The official token-meter `contextPressure` projection (registered by
* `@deepseek-ai/dsh-token-meter` on the same `SessionProjectionMap`): the
* provider-anchored occupancy of the NEXT request. The Client reads this key
* directly instead of the Host mirroring it inside `contextTimeline`
* (token-meter owns estimation and replay — the docs' stated division of
* labor). Fields are independent last-wins records; absent until a provider
* reports usage. Absent key/value = the registry (or the meter) is not
* composed — the Client falls back to its derived anchor.
*/
export interface ContextPressure {
/** Provider-reported prompt size of the most recent request (input + cache). */
pressureTokens?: number
/** pressureTokens + heuristic surface movement since the sample (clamped ≥ 0). */
projectedTokens?: number
/** Newest recorded route capacity (last-wins). */
contextWindow?: number
}
/**
* The official token-meter `contextBreakdown` projection: the heuristic
* composition rows the chat ring's click-open panel shows (system prompt,
* tool schemas, conversation). The Client reads this key directly so the
* composition card's proportions AND counts stay identical to the panel's
* by construction; the message bucket is subdivided into the plugin's four
* surface categories by the fold's per-category ratios. Absent key/value =
* an older harness without the meter's projection units — callers fall back
* to the fold's own sums (identical estimator, minus the image correction).
*/
export interface ContextBreakdown {
systemTokens: number
toolsTokens: number
messageTokens: number
}
/**
* The official token-meter `tokenUsage` projection (registered by
* `@deepseek-ai/dsh-token-meter` on the same `SessionProjectionMap`): durable
* cumulative provider-reported usage across the COMPLETE session log. The four
* buckets are disjoint (reasoning tokens are already inside `outputTokens`).
* The Client reads this key directly to compute the cache-hit share — the
* exact same data the chat stats line below the input box shows, same formula
* — instead of the Host mirroring it inside `contextTimeline`. Absent until a
* provider reports usage.
*/
export interface TokenUsage {
/** Billed prompt tokens that missed the provider cache. */
uncachedInputTokens: number
/** Billed output tokens (reasoning included). */
outputTokens: number
/** Billed prompt tokens served from the provider cache. */
cacheReadTokens: number
/** Billed prompt tokens written into the provider cache. */
cacheWriteTokens: number
}
/**
* Cumulative billed-token totals for one pricing bucket of the session-cost
* estimate (host-folded, never trimmed — running totals over the COMPLETE
* session log, immune to the request/event retention bounds).
*/
export interface CostBucketTotals {
uncached: number
cacheRead: number
cacheWrite: number
output: number
}
/**
* One completed tool name's whole-session call tally behind the timing
* card's top-tools ranking (running totals, never trimmed).
*/
export interface ToolTimingTotals {
calls: number
ms: number
}
/**
* Whole-session timing totals, host-folded from the durable `step/start` /
* `step/end` / `tool/call` / `tool/result` lifecycle plus the model call's
* first token (running totals over the COMPLETE session log — the same
* never-trimmed framing as `cost`). The first token comes from a V0
* `assistant/chunk` delta or from the call's own embedded stream
* (`assistant/message.data.stream` / `assistant/attempt.data.stream`, the
* V2+ settlement) — whichever the log carries, matching the harness's own
* session-stats fold. Durations are wall-clock milliseconds: `wallMs` sums
* whole steps, `ttftMs` the step-start → first-token slice (the model wait)
* and `genMs` the first-token → assistant-message slice (the generation) —
* both only over calls whose stream carried a token delta, `toolsMs` the sum
* of per-call tool durations (parallel calls each count, so it can overlap).
* Absent until the first step lifecycle completes in the log.
*/
export interface TimingTotals {
/** Summed wall time of completed steps (the session's active time). */
wallMs: number
/** Summed step-start → first-token time (the model wait, TTFT). */
ttftMs: number
/** Summed first-token → assistant-message time (the generation). */
genMs: number
/** Completed model calls (assistant messages folded). */
calls: number
/** Summed per-call durations of completed tool calls. */
toolsMs: number
/** Completed tool calls (call/result pairs folded). */
toolCalls: number
/** Per-tool-name tallies behind the timing card's ranking (bounded). */
tools: Record<string, ToolTimingTotals>
}
/** One model family's totals split by DeepSeek's pricing period (Beijing Time). */
export interface CostFamilyUsage {
peak?: CostBucketTotals
off?: CostBucketTotals
}
/**
* The session-cost estimate's raw material: cumulative provider-reported
* token totals per DeepSeek V4 model family (matched on the model NAME,
* provider-agnostic) and pricing period. The Client prices these with its
* hardcoded list-price table in the locale's currency. Absent until a
* deepseek-v4-flash / deepseek-v4-pro request reports usage.
*/
export interface SessionCostUsage {
flash?: CostFamilyUsage
pro?: CostFamilyUsage
}
/** One model-visible message on the surface, with its heuristic token price. */
export interface SurfaceNode {
seq: number
time?: number
cat: Category
tokens: number
/** Image blocks inside this node's message (absent when zero). */
imgs?: number
/**
* Removal marker, present only on `archive` entries: the seq of the
* replacement surface event that shadowed this node (compaction/prune).
* The node is part of the assembled context of every request with
* seq > this node.seq and seq < gone.
*/
gone?: number
form?: string
text?: string
tool?: string
err?: boolean
skill?: string
calls?: string[]
}
/** One answered model call (a step); consecutive records of one turn form it. */
export interface RequestRecord {
turn?: number
step?: number
time: number
seq: number
system: number
tools: number
user: number
inject: number
assistant: number
tool: number
total: number
prompt?: number
/**
* Billed cache-read (served) prompt tokens of this request — the
* hit-rate numerator against `prompt` (input + cacheRead + cacheWrite).
* Absent on older hosts / usage-less requests; zero is a real value.
*/
cacheRead?: number
output?: number
/**
* Turn-mode aggregate marker, set by the Client's aggregateByTurn (one bar
* per turn shows its LAST step's record). The Host never sets it.
*/
stepCount?: number
/**
* Delta-mode signed net change, set by the Client's deltaOf (only present
* on the delta-transformed records the TrendChart plots). The Host never
* sets it.
*/
net?: number
}
/** A notable context event (compaction, prune, injection, model switch). */
export interface ContextEventRecord {
seq: number
time: number
kind: 'compaction' | 'prune' | 'inject' | 'model' | 'mode'
form?: string
tokens?: number
count?: number
sub?: string
name?: string
/** One-line producer account (notice-form summary), shown after the name. */
detail?: string
from?: string
to?: string
/** Turn/step of the request logged right BEFORE the event (host-stamped). */
fromTurn?: number
fromStep?: number
/** Turn/step of the request this event contributed to (host-stamped). */
turn?: number
step?: number
}
/**
* Sentinel `HeaderTool.plugin` value marking a tool whose provider could not
* be attributed: it was already registered in the harness tool service when
* this plugin's runtime attribution hook installed (boot-time third-party
* tools — e.g. local-link plugins that apply before dsh-context). The client
* renders a localized "unknown plugin" tag with an explanatory tooltip. No
* real plugin name can collide (it is not a valid package identifier).
*/
export const UNKNOWN_TOOL_SOURCE = '<unknown-plugin>'
/** One tool of a request-header epoch, with its display price. */
export interface HeaderTool {
name: string
tokens: number
/**
* The registering plugin's label, when attribution is known: the host's
* best-effort attribution (`mcp:<server>` for MCP tools, or the pinned
* first-party package map), or a `plugin` field carried by the raw header
* entry — no supported-baseline harness path writes one, but the read
* stays defensive for foreign/newer producers. `UNKNOWN_TOOL_SOURCE` marks
* a tool whose provider predates the attribution hook; absent means
* nothing is known and the browser shows no tag.
*/
plugin?: string
}
/**
* One request-header epoch's METADATA: the epoch boundaries and token prices
* in force from this event's seq until the next epoch. The epoch CONTENT
* (full system prompt text, tool descriptions/schemas) is not projected —
* every session.list row, control baseline, push frame, and projection-cache
* checkpoint would otherwise carry it per session × epoch. The client
* fetches one epoch's content on demand (a seq-anchored history read off
* `seq`) as a {@link HeaderEpochContent}.
*/
export interface HeaderRecord {
seq: number
time: number
/** The epoch's estimated system-prompt tokens; absent when it logged no system prompt. */
systemTokens?: number
tools: HeaderTool[]
}
/** The `contextHeaders` projection value: the bounded epoch list (newest last). */
export interface ContextHeaders {
headers: HeaderRecord[]
}
/**
* The fetched CONTENT of one request-header epoch — the full system prompt
* text and per-tool descriptions/schemas, mapped client-side off the raw
* durable event (see historyPage.ts). Tool identity joins the epoch metadata
* by `name`; absent description/schema means the raw entry carried none.
*/
export interface HeaderEpochContent {
system?: string
tools: Array<{
name: string
description?: string
/** The raw JSON schema object the model received (plain JSON). */
schema?: unknown
}>
}