index.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  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 'cordis'
  8. import z from 'schemastery'
  9. import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
  10. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  11. /** Cordis plugin name used by loader diagnostics. */
  12. export const name = 'time-context'
  13. /** The agent registry that owns pre-step processing. */
  14. export const inject = ['agents']
  15. /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
  16. export interface Config {
  17. /** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */
  18. timeZone?: string
  19. /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject at every eligible step. */
  20. refreshIntervalMs?: number
  21. }
  22. /** Schemastery validation for {@link Config}. */
  23. export const Config: z<Config> = z.object({
  24. timeZone: z.string(),
  25. refreshIntervalMs: z.number(),
  26. })
  27. type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year'
  28. /** Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. */
  29. function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string {
  30. const parts = Object.fromEntries(
  31. formatter.formatToParts(now).map(part => [part.type, part.value]),
  32. ) as Record<TimestampPart, string>
  33. const offset = parts.timeZoneName.replace(/^GMT$/, 'GMT+00:00').slice(3)
  34. return `${parts['year']}-${parts['month']}-${parts['day']}T${parts['hour']}:${parts['minute']}:${parts['second']}${offset}[${timeZone}]`
  35. }
  36. /** Format a non-negative elapsed millisecond count as compact whole-second units. */
  37. function formatDuration(elapsedMs: number): string {
  38. let seconds = Math.floor(Math.max(0, elapsedMs) / 1000)
  39. const days = Math.floor(seconds / 86_400)
  40. seconds %= 86_400
  41. const hours = Math.floor(seconds / 3600)
  42. seconds %= 3600
  43. const minutes = Math.floor(seconds / 60)
  44. seconds %= 60
  45. const parts: string[] = []
  46. if (days > 0) parts.push(`${days}d`)
  47. if (hours > 0) parts.push(`${hours}h`)
  48. if (minutes > 0) parts.push(`${minutes}m`)
  49. parts.push(`${seconds}s`)
  50. return parts.join(' ')
  51. }
  52. /** Find the latest model-visible event, excluding this plugin's pending append. */
  53. function precedingMessageTime(agent: Agent): number | undefined {
  54. for (const event of [...agent.session.events].reverse()) {
  55. switch (event.type) {
  56. case 'user/message':
  57. case 'assistant/message':
  58. case 'tool/result':
  59. return event.time
  60. default:
  61. // Merge-extensible session events: non-surface records are not messages.
  62. break
  63. }
  64. }
  65. return undefined
  66. }
  67. /** Find the preceding time-context event within the open turn. */
  68. function precedingStepContextTime(agent: Agent, turn: number): number | undefined {
  69. for (const event of [...agent.session.events].reverse()) {
  70. if (event.type === 'turn/start' && event.data.turn === turn) return undefined
  71. if (event.type === 'user/message'
  72. && event.data.source.kind === 'plugin'
  73. && event.data.source.plugin === name) {
  74. return event.time
  75. }
  76. }
  77. return undefined
  78. }
  79. /** Find this plugin's latest durable injection, including a shadowed surface event. */
  80. function latestInjectionTime(agent: Agent): number | undefined {
  81. for (const event of [...agent.session.events].reverse()) {
  82. if (event.type === 'user/message'
  83. && event.data.source.kind === 'plugin'
  84. && event.data.source.plugin === name) {
  85. return event.time
  86. }
  87. }
  88. return undefined
  89. }
  90. function renderText(
  91. now: number,
  92. turn: number,
  93. step: number,
  94. previous: number | undefined,
  95. formatter: Intl.DateTimeFormat,
  96. timeZone: string,
  97. ): string {
  98. const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous)
  99. const baseline = step === 1 ? 'model-visible message' : 'step context'
  100. return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n`
  101. + `Elapsed since the preceding ${baseline}: ${elapsed}.`
  102. }
  103. /** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
  104. function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
  105. if (refreshIntervalMs !== undefined && (
  106. !Number.isSafeInteger(refreshIntervalMs)
  107. || refreshIntervalMs < 0
  108. )) {
  109. throw new TypeError(
  110. `time-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,
  111. )
  112. }
  113. }
  114. /**
  115. * Register a prepended pre-step listener for the lifetime of `ctx`.
  116. * @param ctx - plugin context; the listener is disposed with it.
  117. * @param config - time zone and durable refresh scheduling configuration.
  118. * @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.
  119. */
  120. export function apply(ctx: Context, config: Config): void {
  121. const timeZone = config.timeZone
  122. const refreshIntervalMs = config.refreshIntervalMs
  123. validateRefreshInterval(refreshIntervalMs)
  124. let formatter: Intl.DateTimeFormat
  125. try {
  126. formatter = new Intl.DateTimeFormat('en-US', {
  127. ...(timeZone === undefined ? {} : { timeZone }),
  128. year: 'numeric',
  129. month: '2-digit',
  130. day: '2-digit',
  131. hour: '2-digit',
  132. minute: '2-digit',
  133. second: '2-digit',
  134. hourCycle: 'h23',
  135. timeZoneName: 'longOffset',
  136. })
  137. } catch (error: unknown) {
  138. const message = timeZone === undefined
  139. ? 'time-context: failed to resolve the system time zone'
  140. : `time-context: invalid IANA timeZone ${JSON.stringify(timeZone)}`
  141. throw new Error(message, { cause: error })
  142. }
  143. const resolvedTimeZone = formatter.resolvedOptions().timeZone
  144. ctx.on('agent/pre-step', async (
  145. { agent, turn, step, signal },
  146. next,
  147. ): Promise<PreStepDecision> => {
  148. const decision = await next()
  149. if (decision.kind === 'reject' || signal.aborted) return decision
  150. const now = Date.now()
  151. if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
  152. const lastInjection = latestInjectionTime(agent)
  153. if (lastInjection !== undefined
  154. && now >= lastInjection
  155. && now - lastInjection < refreshIntervalMs) return decision
  156. }
  157. const previous = step === 1
  158. ? precedingMessageTime(agent)
  159. : precedingStepContextTime(agent, turn)
  160. const text = renderText(now, turn, step, previous, formatter, resolvedTimeZone)
  161. return {
  162. kind: 'enter',
  163. messages: [
  164. ...decision.messages,
  165. createUserMessage({
  166. content: [{ type: 'text', text }],
  167. source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
  168. }),
  169. ],
  170. }
  171. }, { prepend: true })
  172. }