267 lines
9.5 KiB
TypeScript
267 lines
9.5 KiB
TypeScript
/**
|
|
* Opt-in request clock context. Eligible steps add durable,
|
|
* source-attributed time readings to the request history.
|
|
*
|
|
* @module @deepseek-ai/dsh-time-context
|
|
*/
|
|
|
|
import type { Context } from 'cordis'
|
|
import z from 'schemastery'
|
|
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
|
import {
|
|
deriveClientTimeZoneContext,
|
|
renderTimeZoneContext,
|
|
} from './request-zone.ts'
|
|
|
|
export type { ClientTimeZoneContext } from './request-zone.ts'
|
|
export { deriveClientTimeZoneContext } from './request-zone.ts'
|
|
|
|
/** Cordis plugin name used by loader diagnostics. */
|
|
export const name = 'time-context'
|
|
|
|
/** The agent registry that owns pre-step processing. */
|
|
export const inject = ['agents']
|
|
|
|
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
|
|
export interface Config {
|
|
/** Fallback display zone for headerless Sessions. Omit to use the process zone. */
|
|
timeZone?: string
|
|
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject at every eligible step. */
|
|
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 before the current proposal. */
|
|
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':
|
|
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
|
|
}
|
|
|
|
/** Collect already-entered and proposed messages belonging to one open turn. */
|
|
function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] {
|
|
const start = agent.session.events.findLastIndex(
|
|
event => event.type === 'turn/start' && event.data.turn === turn,
|
|
)
|
|
const entered = start < 0
|
|
? []
|
|
: agent.session.events.slice(start + 1).flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
|
return [...entered, ...proposed]
|
|
}
|
|
|
|
/** Render one durable time reading. */
|
|
function renderText(
|
|
now: number,
|
|
turn: number,
|
|
step: number,
|
|
previous: number | undefined,
|
|
formatter: Intl.DateTimeFormat,
|
|
displayTimeZone: string,
|
|
sessionTimeZone: string | undefined,
|
|
messages: readonly UserMessage[],
|
|
): string {
|
|
const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous)
|
|
const baseline = step === 1 ? 'model-visible message' : 'step context'
|
|
const client = deriveClientTimeZoneContext(messages)
|
|
return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, displayTimeZone)}\n`
|
|
+ `${renderTimeZoneContext(sessionTimeZone, client)}\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.
|
|
* @returns A disposer that prevents an in-flight listener from contributing.
|
|
* @throws When the refresh interval or configured/process time zone is invalid.
|
|
*/
|
|
export function apply(ctx: Context, config: Config): () => void {
|
|
const timeZone = config.timeZone
|
|
const refreshIntervalMs = config.refreshIntervalMs
|
|
validateRefreshInterval(refreshIntervalMs)
|
|
const createFormatter = (selectedTimeZone?: string): Intl.DateTimeFormat => new Intl.DateTimeFormat('en-US', {
|
|
...(selectedTimeZone === undefined ? {} : { timeZone: selectedTimeZone }),
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hourCycle: 'h23',
|
|
timeZoneName: 'longOffset',
|
|
})
|
|
let fallbackFormatter: Intl.DateTimeFormat
|
|
try {
|
|
fallbackFormatter = createFormatter(timeZone)
|
|
} 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 fallbackTimeZone = fallbackFormatter.resolvedOptions().timeZone
|
|
const formatters = new Map<string, Intl.DateTimeFormat>([[fallbackTimeZone, fallbackFormatter]])
|
|
let disposed = false
|
|
|
|
/** Resolve one Session-owned formatter without making the process zone authoritative. */
|
|
const formatterFor = (selectedTimeZone: string): Intl.DateTimeFormat => {
|
|
const existing = formatters.get(selectedTimeZone)
|
|
if (existing !== undefined) return existing
|
|
let created: Intl.DateTimeFormat
|
|
try {
|
|
created = createFormatter(selectedTimeZone)
|
|
} catch (error: unknown) {
|
|
throw new Error(`time-context: invalid Session time zone ${JSON.stringify(selectedTimeZone)}`, { cause: error })
|
|
}
|
|
formatters.set(selectedTimeZone, created)
|
|
return created
|
|
}
|
|
|
|
/** Build one current reading after downstream pre-step transforms settle. */
|
|
const readingFor = (
|
|
agent: Agent,
|
|
turn: number,
|
|
step: number,
|
|
messages: readonly UserMessage[],
|
|
): UserMessage => {
|
|
const now = Date.now()
|
|
const previous = step === 1
|
|
? precedingMessageTime(agent)
|
|
: precedingStepContextTime(agent, turn)
|
|
const sessionTimeZone = agent.session.header.timeZone
|
|
const displayTimeZone = sessionTimeZone ?? fallbackTimeZone
|
|
const formatter = sessionTimeZone === undefined
|
|
? fallbackFormatter
|
|
: formatterFor(sessionTimeZone)
|
|
return createUserMessage({
|
|
content: [{
|
|
type: 'text',
|
|
text: renderText(
|
|
now,
|
|
turn,
|
|
step,
|
|
previous,
|
|
formatter,
|
|
displayTimeZone,
|
|
sessionTimeZone,
|
|
requestMessages(agent, turn, messages),
|
|
),
|
|
}],
|
|
source: { kind: 'plugin', plugin: name },
|
|
})
|
|
}
|
|
|
|
ctx.on('agent/pre-step', async (
|
|
{ agent, turn, step, signal },
|
|
next,
|
|
): Promise<PreStepDecision> => {
|
|
const wasDisposed = (): boolean => disposed
|
|
const wasAborted = (): boolean => signal.aborted
|
|
if (wasDisposed()) return next()
|
|
const decision = await next()
|
|
if (wasDisposed() || wasAborted() || decision.kind === 'reject'
|
|
|| decision.messages.length === 0) {
|
|
return decision
|
|
}
|
|
const now = Date.now()
|
|
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
|
|
const lastInjection = latestInjectionTime(agent)
|
|
if (lastInjection !== undefined
|
|
&& now >= lastInjection
|
|
&& now - lastInjection < refreshIntervalMs) return decision
|
|
}
|
|
return {
|
|
kind: 'enter',
|
|
messages: [
|
|
...decision.messages,
|
|
readingFor(agent, turn, step, decision.messages),
|
|
],
|
|
}
|
|
}, { prepend: true })
|
|
|
|
return () => {
|
|
disposed = true
|
|
}
|
|
}
|