返回源码地图

packages/context/time-context/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 09;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

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
8import type { Context } from '@deepseek-ai/cordis'
9import z from '@deepseek-ai/schemastery'
10import { z as zod } from 'zod'
11import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
12import { createUserMessage } from '@deepseek-ai/dsh-llm'
13import type { ContextFormed } from '@deepseek-ai/dsh-llm'
14declare module '@deepseek-ai/dsh-llm' {
15 interface MessageSourceMap {
16 'time-context': { kind: 'time-context' } & ContextFormed
17 }
18}
19
20import type { UserMessage } from '@deepseek-ai/dsh-llm'
21import { SessionSeq } from '@deepseek-ai/dsh-session'
22import type {} from '@deepseek-ai/dsh-session-projection'
23import {
24 deriveBrowserTimeZoneContext,
25 renderBrowserTimeZoneContext,
26} from './request-zone.ts'
27import type { BrowserTimeZoneContext } from './request-zone.ts'
28import { createTimestampFormatter, formatTimestamp } from './timestamp.ts'
29
30/** Cordis plugin name used by loader diagnostics. */
31export const name = 'time-context'
32
33declare module '@deepseek-ai/dsh-session-projection/types' {
34 interface SessionProjectionStateMap {
35 /** Latest time-context readings. */
36 timeContext: TimeContextProjection
37 }
38}
39
40const 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})
48
49/** Folded time-context readings. */
50type TimeContextProjection = zod.infer<typeof timeContextStateSchema>
51
52/** The agent registry that owns pre-step processing. */
53export const inject = ['agents', 'sessionProjections']
54
55/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
56export interface Config {
57 /** Fallback display zone when the open turn has no unique browser zone. Omit to use the process zone. */
58 timeZone?: string
59 /** Minimum milliseconds between durable injections in one session. Defaults to 600000 (10 minutes); 0 injects at every eligible step. */
60 refreshIntervalMs?: number
61}
62
63/** Schemastery validation for {@link Config}. */
64export const Config: z<Config> = z.object({
65 timeZone: z.string(),
66 refreshIntervalMs: z.number(),
67})
68
69/** Format a non-negative elapsed millisecond count as compact whole-second units. */
70function 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_400
74 const hours = Math.floor(seconds / 3600)
75 seconds %= 3600
76 const minutes = Math.floor(seconds / 60)
77 seconds %= 60
78 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}
85
86/** Collect already-entered and proposed user messages belonging to one open turn. */
87function 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}
99
100function 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}
116
117/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
118function 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}
125
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 */
132export function apply(ctx: Context, config: Config): void {
133 const timeZone = config.timeZone
134 const refreshIntervalMs = config.refreshIntervalMs ?? 600_000
135 validateRefreshInterval(refreshIntervalMs)
136 let fallbackFormatter: Intl.DateTimeFormat
137 try {
138 fallbackFormatter = createTimestampFormatter(timeZone)
139 } catch (error: unknown) {
140 const message = timeZone === undefined
141 ? '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().timeZone
146 const formatters = new Map<string, Intl.DateTimeFormat>([[fallbackTimeZone, fallbackFormatter]])
147
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 existing
152 const created = createTimestampFormatter(selectedTimeZone)
153 formatters.set(selectedTimeZone, created)
154 return created
155 }
156
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 === name
168 const withMessage = state.lastMessageTime === event.time
169 ? state
170 : { ...state, lastMessageTime: event.time }
171 if (!injected) return withMessage
172 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 state
182 },
183 })
184
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 decision
191 const now = Date.now()
192 const state = ctx.sessionProjections.stateOf(agent.session, 'timeContext') as TimeContextProjection
193 if (refreshIntervalMs > 0) {
194 const lastInjection = state.lastInjectionTime
195 if (lastInjection != null
196 && now >= lastInjection
197 && now - lastInjection < refreshIntervalMs) return decision
198 }
199 /* v8 ignore next 6 -- every later step follows a recorded injection in the same turn */
200 const previous = step === 1
201 ? state.lastMessageTime ?? undefined
202 : state.lastTurnInjectionTime ?? undefined
203 const messages = requestMessages(agent, turn, decision.messages)
204 const browser = deriveBrowserTimeZoneContext(messages)
205 const selectedTimeZone = browser.kind === 'resolved' ? browser.timeZone : fallbackTimeZone
206 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}