1
/**2
* Opt-in request clock context. Eligible steps add durable,3
* source-attributed time readings to the request history.4
*5
* @module @deepseek-ai/dsh-time-context6
*/8
import type { Context } from '@deepseek-ai/cordis'9
import z from '@deepseek-ai/schemastery'10
import { z as zod } from 'zod'11
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'12
import { createUserMessage } from '@deepseek-ai/dsh-llm'13
import type { ContextFormed } from '@deepseek-ai/dsh-llm'14
declare module '@deepseek-ai/dsh-llm' {15
interface MessageSourceMap {16
'time-context': { kind: 'time-context' } & ContextFormed17
}18
}20
import type { UserMessage } from '@deepseek-ai/dsh-llm'21
import { SessionSeq } from '@deepseek-ai/dsh-session'22
import type {} from '@deepseek-ai/dsh-session-projection'23
import {24
deriveBrowserTimeZoneContext,25
renderBrowserTimeZoneContext,26
} from './request-zone.ts'27
import type { BrowserTimeZoneContext } from './request-zone.ts'28
import { createTimestampFormatter, formatTimestamp } from './timestamp.ts'30
/** Cordis plugin name used by loader diagnostics. */31
export const name = 'time-context'33
declare module '@deepseek-ai/dsh-session-projection/types' {34
interface SessionProjectionStateMap {35
/** Latest time-context readings. */36
timeContext: TimeContextProjection37
}38
}40
const timeContextStateSchema = zod.object({41
/** Time of the latest model-visible event (user/assistant message, tool result), or null. */42
lastMessageTime: zod.number().nullable(),43
/** Time of this plugin's latest durable injection, or null. */44
lastInjectionTime: zod.number().nullable(),45
/** Latest injection time in the open turn, or null before that turn receives one. */46
lastTurnInjectionTime: zod.number().nullable(),47
})49
/** Folded time-context readings. */50
type TimeContextProjection = zod.infer<typeof timeContextStateSchema>52
/** The agent registry that owns pre-step processing. */53
export const inject = ['agents', 'sessionProjections']55
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */56
export interface Config {57
/** Fallback display zone when the open turn has no unique browser zone. Omit to use the process zone. */58
timeZone?: string59
/** Minimum milliseconds between durable injections in one session. Defaults to 600000 (10 minutes); 0 injects at every eligible step. */60
refreshIntervalMs?: number61
}63
/** Schemastery validation for {@link Config}. */64
export const Config: z<Config> = z.object({65
timeZone: z.string(),66
refreshIntervalMs: z.number(),67
})69
/** Format a non-negative elapsed millisecond count as compact whole-second units. */70
function formatDuration(elapsedMs: number): string {71
let seconds = Math.floor(Math.max(0, elapsedMs) / 1000)72
const days = Math.floor(seconds / 86_400)73
seconds %= 86_40074
const hours = Math.floor(seconds / 3600)75
seconds %= 360076
const minutes = Math.floor(seconds / 60)77
seconds %= 6078
const parts: string[] = []79
if (days > 0) parts.push(`${days}d`)80
if (hours > 0) parts.push(`${hours}h`)81
if (minutes > 0) parts.push(`${minutes}m`)82
parts.push(`${seconds}s`)83
return parts.join(' ')84
}86
/** Collect already-entered and proposed user messages belonging to one open turn. */87
function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] {88
const entered: UserMessage[] = []89
for (let seq = agent.session.seq - 1; seq >= 0; seq -= 1) {90
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.91
const event = agent.session.eventAt(SessionSeq(seq))92
if (event?.type === 'turn/start' && event.data.turn === turn) {93
return [...entered.reverse(), ...proposed]94
}95
if (event?.type === 'user/message') entered.push(event.data)96
}97
return [...proposed]98
}100
function renderText(101
now: number,102
turn: number,103
step: number,104
previous: number | undefined,105
formatter: Intl.DateTimeFormat,106
timeZone: string,107
browserContext: BrowserTimeZoneContext,108
): string {109
const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous)110
const baseline = step === 1 ? 'model-visible message' : 'step context'111
const browserText = renderBrowserTimeZoneContext(browserContext)112
return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n`113
+ `${browserText}\n`114
+ `Elapsed since the preceding ${baseline}: ${elapsed}.`115
}117
/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */118
function validateRefreshInterval(refreshIntervalMs: number): void {119
if (!Number.isSafeInteger(refreshIntervalMs) || refreshIntervalMs < 0) {120
throw new TypeError(121
`time-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,122
)123
}124
}126
/**127
* Register a prepended pre-step listener for the lifetime of `ctx`.128
* @param ctx - plugin context; the listener is disposed with it.129
* @param config - time zone and durable refresh scheduling configuration.130
* @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.131
*/132
export function apply(ctx: Context, config: Config): void {133
const timeZone = config.timeZone134
const refreshIntervalMs = config.refreshIntervalMs ?? 600_000135
validateRefreshInterval(refreshIntervalMs)136
let fallbackFormatter: Intl.DateTimeFormat137
try {138
fallbackFormatter = createTimestampFormatter(timeZone)139
} catch (error: unknown) {140
const message = timeZone === undefined141
? 'time-context: failed to resolve the system time zone'142
: `time-context: invalid IANA timeZone ${JSON.stringify(timeZone)}`143
throw new Error(message, { cause: error })144
}145
const fallbackTimeZone = fallbackFormatter.resolvedOptions().timeZone146
const formatters = new Map<string, Intl.DateTimeFormat>([[fallbackTimeZone, fallbackFormatter]])148
/** Resolve and cache one request-local timestamp formatter. */149
const formatterFor = (selectedTimeZone: string): Intl.DateTimeFormat => {150
const existing = formatters.get(selectedTimeZone)151
if (existing !== undefined) return existing152
const created = createTimestampFormatter(selectedTimeZone)153
formatters.set(selectedTimeZone, created)154
return created155
}157
ctx.sessionProjections.register({158
key: 'timeContext',159
stateVersion: 2,160
stateSchema: timeContextStateSchema,161
init: () => ({ lastMessageTime: null, lastInjectionTime: null, lastTurnInjectionTime: null }),162
apply: (state, event) => {163
if (event.type === 'turn/start' || event.type === 'turn/end') {164
return state.lastTurnInjectionTime === null ? state : { ...state, lastTurnInjectionTime: null }165
}166
if (event.type === 'user/message') {167
const injected = event.data.source.kind === name168
const withMessage = state.lastMessageTime === event.time169
? state170
: { ...state, lastMessageTime: event.time }171
if (!injected) return withMessage172
return {173
...withMessage,174
lastInjectionTime: event.time,175
lastTurnInjectionTime: event.time,176
}177
}178
if (event.type === 'assistant/message' || event.type === 'tool/result') {179
return state.lastMessageTime === event.time ? state : { ...state, lastMessageTime: event.time }180
}181
return state182
},183
})185
ctx.on('agent/pre-step', async (186
{ agent, turn, step, signal },187
next,188
): Promise<PreStepDecision> => {189
const decision = await next()190
if (decision.kind === 'reject' || signal.aborted) return decision191
const now = Date.now()192
const state = ctx.sessionProjections.stateOf(agent.session, 'timeContext') as TimeContextProjection193
if (refreshIntervalMs > 0) {194
const lastInjection = state.lastInjectionTime195
if (lastInjection != null196
&& now >= lastInjection197
&& now - lastInjection < refreshIntervalMs) return decision198
}199
/* v8 ignore next 6 -- every later step follows a recorded injection in the same turn */200
const previous = step === 1201
? state.lastMessageTime ?? undefined202
: state.lastTurnInjectionTime ?? undefined203
const messages = requestMessages(agent, turn, decision.messages)204
const browser = deriveBrowserTimeZoneContext(messages)205
const selectedTimeZone = browser.kind === 'resolved' ? browser.timeZone : fallbackTimeZone206
const text = renderText(207
now,208
turn,209
step,210
previous,211
formatterFor(selectedTimeZone),212
selectedTimeZone,213
browser,214
)215
return {216
...decision,217
messages: [218
...decision.messages,219
createUserMessage({220
content: [{ type: 'text', text }],221
source: { kind: name, form: 'snapshot', sections: [{ name, text }] },222
}),223
],224
}225
}, { prepend: true })226
}