| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- /**
- * Opt-in request-preparation clock context. Eligible pre-step attempts append
- * durable, source-attributed time readings to conversation history.
- *
- * @module @deepseek-ai/dsh-time-context
- */
- import type { Context } from 'cordis'
- import z from 'schemastery'
- import type { Agent } from '@deepseek-ai/dsh-agent'
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
- /** Cordis plugin name used by loader diagnostics. */
- export const name = 'time-context'
- /** The agent registry that owns the pre-step lifecycle seam. */
- export const inject = ['agents']
- /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
- export interface Config {
- /** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */
- timeZone?: string
- /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible pre-step attempt. */
- refreshIntervalMs?: number
- }
- /** Schemastery validation for {@link Config}. */
- export const Config: z<Config> = z.object({
- timeZone: z.string(),
- refreshIntervalMs: z.number(),
- })
- type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year'
- /** Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. */
- function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string {
- const parts = Object.fromEntries(
- formatter.formatToParts(now).map(part => [part.type, part.value]),
- ) as Record<TimestampPart, string>
- const offset = parts.timeZoneName.replace(/^GMT$/, 'GMT+00:00').slice(3)
- return `${parts['year']}-${parts['month']}-${parts['day']}T${parts['hour']}:${parts['minute']}:${parts['second']}${offset}[${timeZone}]`
- }
- /** Format a non-negative elapsed millisecond count as compact whole-second units. */
- function formatDuration(elapsedMs: number): string {
- let seconds = Math.floor(Math.max(0, elapsedMs) / 1000)
- const days = Math.floor(seconds / 86_400)
- seconds %= 86_400
- const hours = Math.floor(seconds / 3600)
- seconds %= 3600
- const minutes = Math.floor(seconds / 60)
- seconds %= 60
- const parts: string[] = []
- if (days > 0) parts.push(`${days}d`)
- if (hours > 0) parts.push(`${hours}h`)
- if (minutes > 0) parts.push(`${minutes}m`)
- parts.push(`${seconds}s`)
- return parts.join(' ')
- }
- /** Find the latest model-visible event, excluding this plugin's pending append. */
- function precedingMessageTime(agent: Agent): number | undefined {
- for (const event of [...agent.session.events].reverse()) {
- switch (event.type) {
- case 'user/message':
- case 'assistant/message':
- case 'tool/result':
- case 'steering/message':
- return event.time
- default:
- // Merge-extensible session events: non-surface records are not messages.
- break
- }
- }
- return undefined
- }
- /** Find the preceding time-context event within the open turn. */
- function precedingStepContextTime(agent: Agent, turn: number): number | undefined {
- for (const event of [...agent.session.events].reverse()) {
- if (event.type === 'turn/start' && event.data.turn === turn) return undefined
- if (event.type === 'user/message'
- && event.data.source.kind === 'plugin'
- && event.data.source.plugin === name) {
- return event.time
- }
- }
- return undefined
- }
- /** Find this plugin's latest durable injection, including a shadowed surface event. */
- function latestInjectionTime(agent: Agent): number | undefined {
- for (const event of [...agent.session.events].reverse()) {
- if (event.type === 'user/message'
- && event.data.source.kind === 'plugin'
- && event.data.source.plugin === name) {
- return event.time
- }
- }
- return undefined
- }
- function renderText(
- now: number,
- turn: number,
- step: number,
- previous: number | undefined,
- formatter: Intl.DateTimeFormat,
- timeZone: string,
- ): string {
- const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous)
- const baseline = step === 1 ? 'model-visible message' : 'step context'
- return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n`
- + `Elapsed since the preceding ${baseline}: ${elapsed}.`
- }
- /** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
- function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
- if (refreshIntervalMs !== undefined && (
- !Number.isSafeInteger(refreshIntervalMs)
- || refreshIntervalMs < 0
- )) {
- throw new TypeError(
- `time-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,
- )
- }
- }
- /**
- * Register a prepended pre-step listener for the lifetime of `ctx`.
- * @param ctx - plugin context; the listener is disposed with it.
- * @param config - time zone and durable refresh scheduling configuration.
- * @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.
- */
- export function apply(ctx: Context, config: Config): void {
- const timeZone = config.timeZone
- const refreshIntervalMs = config.refreshIntervalMs
- validateRefreshInterval(refreshIntervalMs)
- let formatter: Intl.DateTimeFormat
- try {
- formatter = new Intl.DateTimeFormat('en-US', {
- ...(timeZone === undefined ? {} : { timeZone }),
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- hourCycle: 'h23',
- timeZoneName: 'longOffset',
- })
- } catch (error: unknown) {
- const message = timeZone === undefined
- ? 'time-context: failed to resolve the system time zone'
- : `time-context: invalid IANA timeZone ${JSON.stringify(timeZone)}`
- throw new Error(message, { cause: error })
- }
- const resolvedTimeZone = formatter.resolvedOptions().timeZone
- ctx.on('agent/step', (
- agent: Agent,
- turn: number,
- step: number,
- signal: AbortSignal,
- ) => {
- if (signal.aborted) return
- const now = Date.now()
- if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
- const lastInjection = latestInjectionTime(agent)
- if (lastInjection !== undefined
- && now >= lastInjection
- && now - lastInjection < refreshIntervalMs) return
- }
- const previous = step === 1
- ? precedingMessageTime(agent)
- : precedingStepContextTime(agent, turn)
- agent.inject(createUserMessage({ content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], source: { kind: 'plugin', plugin: name } }))
- }, { prepend: true })
- }
|