index.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  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-context
  6. */
  7. import type { Context } from '@deepseek-ai/cordis'
  8. import z from '@deepseek-ai/schemastery'
  9. import { z as zod } from 'zod'
  10. import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
  11. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  12. import type { UserMessage } from '@deepseek-ai/dsh-llm'
  13. import { SessionSeq } from '@deepseek-ai/dsh-session'
  14. import type {} from '@deepseek-ai/dsh-session-projection'
  15. import {
  16. deriveBrowserTimeZoneContext,
  17. renderBrowserTimeZoneContext,
  18. } from './request-zone.ts'
  19. import type { BrowserTimeZoneContext } from './request-zone.ts'
  20. import { createTimestampFormatter, formatTimestamp } from './timestamp.ts'
  21. /** Cordis plugin name used by loader diagnostics. */
  22. export const name = 'time-context'
  23. declare module '@deepseek-ai/dsh-session-projection/types' {
  24. interface SessionProjectionStateMap {
  25. /** Latest time-context readings. */
  26. timeContext: TimeContextProjection
  27. }
  28. }
  29. const timeContextStateSchema = zod.object({
  30. /** Time of the latest model-visible event (user/assistant message, tool result), or null. */
  31. lastMessageTime: zod.number().nullable(),
  32. /** Time of this plugin's latest durable injection, or null. */
  33. lastInjectionTime: zod.number().nullable(),
  34. /** Latest injection time in the open turn, or null before that turn receives one. */
  35. lastTurnInjectionTime: zod.number().nullable(),
  36. })
  37. /** Folded time-context readings. */
  38. type TimeContextProjection = zod.infer<typeof timeContextStateSchema>
  39. /** The agent registry that owns pre-step processing. */
  40. export const inject = ['agents', 'sessionProjections']
  41. /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
  42. export interface Config {
  43. /** Fallback display zone when the open turn has no unique browser zone. Omit to use the process zone. */
  44. timeZone?: string
  45. /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject at every eligible step. */
  46. refreshIntervalMs?: number
  47. }
  48. /** Schemastery validation for {@link Config}. */
  49. export const Config: z<Config> = z.object({
  50. timeZone: z.string(),
  51. refreshIntervalMs: z.number(),
  52. })
  53. /** Format a non-negative elapsed millisecond count as compact whole-second units. */
  54. function formatDuration(elapsedMs: number): string {
  55. let seconds = Math.floor(Math.max(0, elapsedMs) / 1000)
  56. const days = Math.floor(seconds / 86_400)
  57. seconds %= 86_400
  58. const hours = Math.floor(seconds / 3600)
  59. seconds %= 3600
  60. const minutes = Math.floor(seconds / 60)
  61. seconds %= 60
  62. const parts: string[] = []
  63. if (days > 0) parts.push(`${days}d`)
  64. if (hours > 0) parts.push(`${hours}h`)
  65. if (minutes > 0) parts.push(`${minutes}m`)
  66. parts.push(`${seconds}s`)
  67. return parts.join(' ')
  68. }
  69. /** Collect already-entered and proposed user messages belonging to one open turn. */
  70. function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] {
  71. const entered: UserMessage[] = []
  72. for (let seq = agent.session.seq - 1; seq >= 0; seq -= 1) {
  73. const event = agent.session.eventAt(SessionSeq(seq))
  74. if (event?.type === 'turn/start' && event.data.turn === turn) {
  75. return [...entered.reverse(), ...proposed]
  76. }
  77. if (event?.type === 'user/message') entered.push(event.data)
  78. }
  79. return [...proposed]
  80. }
  81. function renderText(
  82. now: number,
  83. turn: number,
  84. step: number,
  85. previous: number | undefined,
  86. formatter: Intl.DateTimeFormat,
  87. timeZone: string,
  88. browserContext: BrowserTimeZoneContext,
  89. ): string {
  90. const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous)
  91. const baseline = step === 1 ? 'model-visible message' : 'step context'
  92. const browserText = renderBrowserTimeZoneContext(browserContext)
  93. return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n`
  94. + `${browserText}\n`
  95. + `Elapsed since the preceding ${baseline}: ${elapsed}.`
  96. }
  97. /** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
  98. function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
  99. if (refreshIntervalMs !== undefined && (
  100. !Number.isSafeInteger(refreshIntervalMs)
  101. || refreshIntervalMs < 0
  102. )) {
  103. throw new TypeError(
  104. `time-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,
  105. )
  106. }
  107. }
  108. /**
  109. * Register a prepended pre-step listener for the lifetime of `ctx`.
  110. * @param ctx - plugin context; the listener is disposed with it.
  111. * @param config - time zone and durable refresh scheduling configuration.
  112. * @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.
  113. */
  114. export function apply(ctx: Context, config: Config): void {
  115. const timeZone = config.timeZone
  116. const refreshIntervalMs = config.refreshIntervalMs
  117. validateRefreshInterval(refreshIntervalMs)
  118. let fallbackFormatter: Intl.DateTimeFormat
  119. try {
  120. fallbackFormatter = createTimestampFormatter(timeZone)
  121. } catch (error: unknown) {
  122. const message = timeZone === undefined
  123. ? 'time-context: failed to resolve the system time zone'
  124. : `time-context: invalid IANA timeZone ${JSON.stringify(timeZone)}`
  125. throw new Error(message, { cause: error })
  126. }
  127. const fallbackTimeZone = fallbackFormatter.resolvedOptions().timeZone
  128. const formatters = new Map<string, Intl.DateTimeFormat>([[fallbackTimeZone, fallbackFormatter]])
  129. /** Resolve and cache one request-local timestamp formatter. */
  130. const formatterFor = (selectedTimeZone: string): Intl.DateTimeFormat => {
  131. const existing = formatters.get(selectedTimeZone)
  132. if (existing !== undefined) return existing
  133. const created = createTimestampFormatter(selectedTimeZone)
  134. formatters.set(selectedTimeZone, created)
  135. return created
  136. }
  137. ctx.sessionProjections.register({
  138. key: 'timeContext',
  139. stateVersion: 2,
  140. stateSchema: timeContextStateSchema,
  141. init: () => ({ lastMessageTime: null, lastInjectionTime: null, lastTurnInjectionTime: null }),
  142. apply: (state, event) => {
  143. if (event.type === 'turn/start' || event.type === 'turn/end') {
  144. return state.lastTurnInjectionTime === null ? state : { ...state, lastTurnInjectionTime: null }
  145. }
  146. if (event.type === 'user/message') {
  147. const injected = event.data.source.kind === 'plugin' && event.data.source.plugin === name
  148. const withMessage = state.lastMessageTime === event.time
  149. ? state
  150. : { ...state, lastMessageTime: event.time }
  151. if (!injected) return withMessage
  152. return {
  153. ...withMessage,
  154. lastInjectionTime: event.time,
  155. lastTurnInjectionTime: event.time,
  156. }
  157. }
  158. if (event.type === 'assistant/message' || event.type === 'tool/result') {
  159. return state.lastMessageTime === event.time ? state : { ...state, lastMessageTime: event.time }
  160. }
  161. return state
  162. },
  163. })
  164. ctx.on('agent/pre-step', async (
  165. { agent, turn, step, signal },
  166. next,
  167. ): Promise<PreStepDecision> => {
  168. const decision = await next()
  169. if (decision.kind === 'reject' || signal.aborted) return decision
  170. const now = Date.now()
  171. const state = ctx.sessionProjections.stateOf(agent.session, 'timeContext') as TimeContextProjection
  172. if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
  173. const lastInjection = state.lastInjectionTime
  174. if (lastInjection != null
  175. && now >= lastInjection
  176. && now - lastInjection < refreshIntervalMs) return decision
  177. }
  178. /* v8 ignore next 6 -- every later step follows a recorded injection in the same turn */
  179. const previous = step === 1
  180. ? state.lastMessageTime ?? undefined
  181. : state.lastTurnInjectionTime ?? undefined
  182. const messages = requestMessages(agent, turn, decision.messages)
  183. const browser = deriveBrowserTimeZoneContext(messages)
  184. const selectedTimeZone = browser.kind === 'resolved' ? browser.timeZone : fallbackTimeZone
  185. const text = renderText(
  186. now,
  187. turn,
  188. step,
  189. previous,
  190. formatterFor(selectedTimeZone),
  191. selectedTimeZone,
  192. browser,
  193. )
  194. return {
  195. ...decision,
  196. messages: [
  197. ...decision.messages,
  198. createUserMessage({
  199. content: [{ type: 'text', text }],
  200. source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
  201. }),
  202. ],
  203. }
  204. }, { prepend: true })
  205. }