refactor(chat): remove datetime context and implement per-message timestamp prefixing
This commit is contained in:
@@ -11,7 +11,6 @@ const llmStreamMock = vi.fn()
|
||||
const trackFirstMessageMock = vi.fn()
|
||||
const ingestContextMessageMock = vi.fn()
|
||||
const getContextsSnapshotMock = vi.fn()
|
||||
const createDatetimeContextMock = vi.fn()
|
||||
const createMinecraftContextMock = vi.fn()
|
||||
const persistSessionMessagesMock = vi.fn()
|
||||
const forkSessionMock = vi.fn()
|
||||
@@ -101,7 +100,6 @@ vi.mock('../composables/response-categoriser', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./chat/context-providers', () => ({
|
||||
createDatetimeContext: () => createDatetimeContextMock(),
|
||||
createMinecraftContext: () => createMinecraftContextMock(),
|
||||
}))
|
||||
|
||||
@@ -160,7 +158,6 @@ describe('chat orchestrator contract', () => {
|
||||
trackFirstMessageMock.mockReset()
|
||||
ingestContextMessageMock.mockReset()
|
||||
getContextsSnapshotMock.mockReset()
|
||||
createDatetimeContextMock.mockReset()
|
||||
createMinecraftContextMock.mockReset()
|
||||
createMinecraftContextMock.mockReturnValue(undefined)
|
||||
persistSessionMessagesMock.mockReset()
|
||||
@@ -180,26 +177,18 @@ describe('chat orchestrator contract', () => {
|
||||
})
|
||||
|
||||
it('keeps hook order and composes context prompt after system message', async () => {
|
||||
const datetimeContext = {
|
||||
id: 'datetime',
|
||||
contextId: 'datetime',
|
||||
source: 'ReplaceSelf',
|
||||
content: 'now',
|
||||
createdAt: 123,
|
||||
}
|
||||
const contextsSnapshot = {
|
||||
weather: [
|
||||
'system:weather': [
|
||||
{
|
||||
id: 'weather',
|
||||
contextId: 'weather',
|
||||
contextId: 'system:weather',
|
||||
source: 'ReplaceSelf',
|
||||
content: 'sunny',
|
||||
text: 'sunny',
|
||||
createdAt: 456,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
createDatetimeContextMock.mockReturnValue(datetimeContext)
|
||||
getContextsSnapshotMock.mockReturnValue(contextsSnapshot)
|
||||
|
||||
let composedMessages: Message[] = []
|
||||
@@ -249,7 +238,12 @@ describe('chat orchestrator contract', () => {
|
||||
|
||||
expect(store.sending).toBe(false)
|
||||
expect(trackFirstMessageMock).toHaveBeenCalledTimes(1)
|
||||
expect(ingestContextMessageMock).toHaveBeenCalledWith(datetimeContext)
|
||||
// Datetime is no longer pushed through ingestContextMessage; it is now
|
||||
// applied at message-assembly time as a system-prompt anchor + per-message
|
||||
// [HH:MM] prefix. ingestContextMessage should still be called for other
|
||||
// context providers (e.g. minecraft) when they are configured, but not
|
||||
// for datetime in this test (minecraft is mocked to return undefined).
|
||||
expect(ingestContextMessageMock).not.toHaveBeenCalled()
|
||||
expect(persistSessionMessagesMock).not.toHaveBeenCalled()
|
||||
expect(parserConsumeMock).toHaveBeenCalledWith('hello')
|
||||
expect(parserEndMock).toHaveBeenCalledTimes(1)
|
||||
@@ -268,13 +262,28 @@ describe('chat orchestrator contract', () => {
|
||||
expect(composedMessages).toHaveLength(2)
|
||||
expect(composedMessages[0]).toMatchObject({ role: 'system' })
|
||||
expect(composedMessages[1]).toMatchObject({ role: 'user' })
|
||||
const userMessageContent = (composedMessages[1] as any).content
|
||||
|
||||
expect(userMessageContent[0].text).toBe('hello from user')
|
||||
// System message stays untouched: keeping it 100% static is what makes
|
||||
// the prefix permanently KV-cache friendly across turns and across day
|
||||
// boundaries (the date now lives inside per-message timestamp prefixes
|
||||
// instead of a system anchor).
|
||||
const systemContent = (composedMessages[0] as any).content
|
||||
const systemText = typeof systemContent === 'string' ? systemContent : systemContent.map((p: any) => p.text).join('')
|
||||
expect(systemText).toBe('system prompt')
|
||||
|
||||
// The user turn is prefixed with [YYYY-MM-DD HH:MM]. Both historic and
|
||||
// current turns share the same shape so prefix-cache stays valid when a
|
||||
// "current" turn becomes "historic" on the next send. Side-channel context
|
||||
// (weather) is appended as a separate text part so providers don't see
|
||||
// consecutive same-role messages.
|
||||
const userMessageContent = (composedMessages[1] as any).content
|
||||
expect(userMessageContent[0].text).toMatch(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\] hello from user$/)
|
||||
|
||||
const syntheticContextText = userMessageContent[1].text
|
||||
expect(syntheticContextText).toContain('<context>')
|
||||
expect(syntheticContextText).toContain('<module name="weather">')
|
||||
expect(syntheticContextText).not.toContain('<context>')
|
||||
expect(syntheticContextText).not.toContain('<module ')
|
||||
expect(syntheticContextText).toContain('[Context]')
|
||||
expect(syntheticContextText).toContain('- system:weather: sunny')
|
||||
})
|
||||
|
||||
it('rejects cancelled queued sends before they start', async () => {
|
||||
|
||||
@@ -16,8 +16,9 @@ import { useLlmmarkerParser } from '../composables/llm-marker-parser'
|
||||
import { categorizeResponse, createStreamingCategorizer } from '../composables/response-categoriser'
|
||||
import { activeTurnSpan, startSpan } from '../composables/use-io-tracer'
|
||||
import { formatContextPromptText } from './chat/context-prompt'
|
||||
import { createDatetimeContext, createMinecraftContext } from './chat/context-providers'
|
||||
import { createMinecraftContext } from './chat/context-providers'
|
||||
import { useChatContextStore } from './chat/context-store'
|
||||
import { formatTimePrefix } from './chat/datetime-prefix'
|
||||
import { createChatHooks } from './chat/hooks'
|
||||
import { useChatSessionStore } from './chat/session-store'
|
||||
import { useChatStreamStore } from './chat/stream-store'
|
||||
@@ -27,6 +28,32 @@ import { useAiriCardStore } from './modules/airi-card'
|
||||
import { useAutonomousArtistryStore } from './modules/artistry-autonomous'
|
||||
import { useConsciousnessStore } from './modules/consciousness'
|
||||
|
||||
// Prepends a literal text fragment to a message's content. Handles both the
|
||||
// shorthand string form and the array-of-parts form. When the first part is
|
||||
// already text, it merges into that part to keep the part count stable for
|
||||
// downstream consumers; otherwise it inserts a new text part at the front.
|
||||
// Constraint is `content?: unknown` to admit both required-content roles
|
||||
// (system/user) and optional-content roles (assistant); the generic preserves
|
||||
// the caller's discriminated-union narrowing.
|
||||
function prependTextToContent<T extends { content?: unknown }>(msg: T, text: string): T {
|
||||
const content = msg.content
|
||||
if (content === undefined)
|
||||
return { ...msg, content: text } as T
|
||||
if (typeof content === 'string')
|
||||
return { ...msg, content: `${text}${content}` } as T
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const first = content[0] as { type?: string, text?: string } | undefined
|
||||
if (first && first.type === 'text' && typeof first.text === 'string') {
|
||||
const next = [{ ...first, text: `${text}${first.text}` }, ...content.slice(1)]
|
||||
return { ...msg, content: next } as T
|
||||
}
|
||||
return { ...msg, content: [{ type: 'text', text }, ...content] } as T
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
function cloneStreamingMessage(message: StreamingAssistantMessage): StreamingAssistantMessage {
|
||||
try {
|
||||
return structuredClone(message)
|
||||
@@ -136,8 +163,10 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
|
||||
|
||||
chatSession.ensureSession(sessionId)
|
||||
|
||||
// Inject current datetime context before composing the message
|
||||
chatContext.ingestContextMessage(createDatetimeContext())
|
||||
// Datetime is no longer injected through the side-channel context store.
|
||||
// It is applied at message-assembly time (see below) as a system-prompt
|
||||
// date anchor + per-message [HH:MM] prefixes, which is more KV-cache
|
||||
// friendly and less prone to weak models echoing timestamps verbatim.
|
||||
const minecraftContext = createMinecraftContext()
|
||||
if (minecraftContext)
|
||||
chatContext.ingestContextMessage(minecraftContext)
|
||||
@@ -302,13 +331,28 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
|
||||
],
|
||||
})
|
||||
|
||||
// Per-message datetime injection (replaces the old `<context>` XML block):
|
||||
// every user/assistant message gets a `[YYYY-MM-DD HH:MM]` prefix
|
||||
// derived from its persisted `createdAt`. The full date appears on every
|
||||
// turn so the model can read "today" from the most recent message; the
|
||||
// system prompt itself stays 100% static for permanent KV-cache reuse.
|
||||
// Legacy entries without a persisted `createdAt` fall back to "now"
|
||||
// rather than a fabricated older timestamp.
|
||||
// See `./chat/datetime-prefix.ts` for the rationale.
|
||||
const nowTs = Date.now()
|
||||
|
||||
const newMessages = sessionMessagesForSend.map((msg) => {
|
||||
const { context: _context, id: _id, createdAt: _createdAt, ...withoutContext } = msg
|
||||
const { context: _context, id: _id, createdAt, ...withoutContext } = msg
|
||||
const rawMessage = toRaw(withoutContext)
|
||||
const ts = createdAt ?? nowTs
|
||||
|
||||
if (rawMessage.role === 'user') {
|
||||
return prependTextToContent(rawMessage, formatTimePrefix(ts))
|
||||
}
|
||||
|
||||
if (rawMessage.role === 'assistant') {
|
||||
const { slices: _slices, tool_results: _toolResults, categorization: _categorization, ...rest } = rawMessage as ChatAssistantMessage
|
||||
return toRaw(rest)
|
||||
return prependTextToContent(toRaw(rest), formatTimePrefix(ts))
|
||||
}
|
||||
|
||||
return rawMessage
|
||||
|
||||
@@ -7,18 +7,18 @@ import { buildContextPromptMessage, formatContextPromptText } from './context-pr
|
||||
|
||||
function makeContext(overrides: Record<string, unknown> = {}): ContextSnapshot {
|
||||
return {
|
||||
'system:datetime': [
|
||||
'system:minecraft-integration': [
|
||||
{
|
||||
id: 'volatile-random-id',
|
||||
contextId: 'system:datetime',
|
||||
contextId: 'system:minecraft-integration',
|
||||
strategy: ContextUpdateStrategy.ReplaceSelf,
|
||||
text: 'Current datetime: 2026-04-07T12:34:00.000Z',
|
||||
text: 'Bot is online in forest biome',
|
||||
createdAt: 1743940440000,
|
||||
metadata: {
|
||||
source: {
|
||||
id: 'system:datetime',
|
||||
id: 'system:minecraft-integration',
|
||||
kind: 'plugin' as const,
|
||||
plugin: { id: 'airi:system:datetime' },
|
||||
plugin: { id: 'airi:minecraft' },
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
@@ -38,44 +38,42 @@ describe('formatContextPromptText', () => {
|
||||
|
||||
expect(text).not.toContain('volatile-random-id')
|
||||
expect(text).not.toContain('1743940440000')
|
||||
expect(text).not.toContain('airi:system:datetime')
|
||||
expect(text).not.toContain('airi:minecraft')
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/issues/1539
|
||||
it('issue #1539: only includes text content in XML format', () => {
|
||||
it('emits a flat [Context] bullet list (no XML wrapper)', () => {
|
||||
const text = formatContextPromptText(makeContext())
|
||||
|
||||
expect(text).toContain('<context>')
|
||||
expect(text).toContain('</context>')
|
||||
expect(text).toContain('<module name="system:datetime">')
|
||||
expect(text).toContain('Current datetime: 2026-04-07T12:34:00.000Z')
|
||||
expect(text).not.toContain('<context>')
|
||||
expect(text).not.toContain('<module')
|
||||
expect(text.startsWith('[Context]')).toBe(true)
|
||||
expect(text).toContain('- system:minecraft-integration: Bot is online in forest biome')
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/issues/1539
|
||||
it('issue #1539: produces identical output regardless of volatile fields', () => {
|
||||
it('produces identical output regardless of volatile fields', () => {
|
||||
const a = formatContextPromptText(makeContext({ id: 'aaa', createdAt: 1 }))
|
||||
const b = formatContextPromptText(makeContext({ id: 'bbb', createdAt: 2 }))
|
||||
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
it('formats multiple modules', () => {
|
||||
it('formats multiple modules as bullets under one [Context] header', () => {
|
||||
const snapshot: ContextSnapshot = {
|
||||
'system:datetime': [
|
||||
'system:minecraft-integration': [
|
||||
{
|
||||
id: 'a',
|
||||
contextId: 'system:datetime',
|
||||
contextId: 'system:minecraft-integration',
|
||||
strategy: ContextUpdateStrategy.ReplaceSelf,
|
||||
text: 'Current datetime: 2026-04-07T12:34:00.000Z',
|
||||
text: 'Bot is online',
|
||||
createdAt: 0,
|
||||
},
|
||||
],
|
||||
'system:minecraft': [
|
||||
'system:weather': [
|
||||
{
|
||||
id: 'b',
|
||||
contextId: 'system:minecraft',
|
||||
contextId: 'system:weather',
|
||||
strategy: ContextUpdateStrategy.ReplaceSelf,
|
||||
text: 'Bot is online',
|
||||
text: 'Sunny, 22C',
|
||||
createdAt: 0,
|
||||
},
|
||||
],
|
||||
@@ -83,9 +81,10 @@ describe('formatContextPromptText', () => {
|
||||
|
||||
const text = formatContextPromptText(snapshot)
|
||||
|
||||
expect(text).toContain('<module name="system:datetime">')
|
||||
expect(text).toContain('<module name="system:minecraft">')
|
||||
expect(text).toContain('Bot is online')
|
||||
const lines = text.split('\n')
|
||||
expect(lines[0]).toBe('[Context]')
|
||||
expect(lines).toContain('- system:minecraft-integration: Bot is online')
|
||||
expect(lines).toContain('- system:weather: Sunny, 22C')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,32 +2,45 @@ import type { UserMessage } from '@xsai/shared-chat'
|
||||
|
||||
import type { ContextMessage } from '../../types/chat'
|
||||
|
||||
import { toXml } from 'xast-util-to-xml'
|
||||
import { x } from 'xastscript'
|
||||
|
||||
export type ContextSnapshot = Record<string, ContextMessage[]>
|
||||
|
||||
/**
|
||||
* Build an xast tree from context snapshot.
|
||||
* Only the `text` field is included — volatile metadata (random IDs,
|
||||
* millisecond timestamps) is excluded to keep the output deterministic
|
||||
* and friendly to LLM KV-cache prefix matching.
|
||||
* See: https://github.com/moeru-ai/airi/issues/1539
|
||||
* Render runtime context modules into a compact, readable text block.
|
||||
*
|
||||
* Use when:
|
||||
* - Composing chat prompts that need to attach side-channel runtime context
|
||||
* (e.g. game state, system status) to the latest user message.
|
||||
*
|
||||
* Expects:
|
||||
* - A snapshot keyed by `contextId`. Only the per-message `text` field is
|
||||
* included; volatile metadata (random IDs, ms timestamps) is excluded so
|
||||
* the output stays deterministic and KV-cache-friendly.
|
||||
*
|
||||
* Returns:
|
||||
* - Empty string when the snapshot is empty.
|
||||
* - Otherwise a `[Context]` block with one bullet per module, e.g.
|
||||
* `[Context]\n- system:minecraft-integration: Bot is online ...`
|
||||
*
|
||||
* Why this shape (not XML):
|
||||
* - Weak local models (8B/14B) tend to mirror conspicuous structured
|
||||
* wrappers (`<context>...</context>`) back into their replies, treating
|
||||
* them as data to be quoted. A flat bullet list looks like ordinary
|
||||
* narrative, which suppresses that mirroring tendency.
|
||||
* - See: https://github.com/moeru-ai/airi/issues/1539
|
||||
*/
|
||||
function buildContextTree(contextsSnapshot: ContextSnapshot) {
|
||||
const modules = Object.entries(contextsSnapshot).map(([key, messages]) =>
|
||||
x('module', { name: key }, messages.map(m => x(null, m.text))),
|
||||
)
|
||||
|
||||
return x('context', modules)
|
||||
}
|
||||
|
||||
export function formatContextPromptText(contextsSnapshot: ContextSnapshot) {
|
||||
const entries = Object.entries(contextsSnapshot)
|
||||
if (entries.length === 0)
|
||||
return ''
|
||||
|
||||
return toXml(buildContextTree(contextsSnapshot))
|
||||
const lines = entries.flatMap(([contextId, messages]) =>
|
||||
messages.map(m => `- ${contextId}: ${m.text}`),
|
||||
)
|
||||
|
||||
if (lines.length === 0)
|
||||
return ''
|
||||
|
||||
return ['[Context]', ...lines].join('\n')
|
||||
}
|
||||
|
||||
export function buildContextPromptMessage(contextsSnapshot: ContextSnapshot): UserMessage | null {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createDatetimeContext } from './datetime'
|
||||
|
||||
describe('createDatetimeContext', () => {
|
||||
it('returns a context message with datetime text', () => {
|
||||
const ctx = createDatetimeContext()
|
||||
|
||||
expect(ctx.contextId).toBe('system:datetime')
|
||||
expect(ctx.text).toContain('Current datetime:')
|
||||
expect(ctx.strategy).toBe('replace-self')
|
||||
})
|
||||
|
||||
it('includes ISO string in text', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-04-07T12:34:56.789Z'))
|
||||
const ctx = createDatetimeContext()
|
||||
|
||||
expect(ctx.text).toContain('2026-04-07T12:34:56.789Z')
|
||||
}
|
||||
finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('produces different text at different times', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-04-07T12:34:00.000Z'))
|
||||
const a = createDatetimeContext()
|
||||
|
||||
vi.setSystemTime(new Date('2026-04-07T12:35:00.000Z'))
|
||||
const b = createDatetimeContext()
|
||||
|
||||
expect(a.text).not.toBe(b.text)
|
||||
}
|
||||
finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { ContextMessage } from '../../../types/chat'
|
||||
|
||||
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
const DATETIME_CONTEXT_ID = 'system:datetime'
|
||||
|
||||
/**
|
||||
* Creates a context message containing the current datetime information.
|
||||
* This context is injected before each chat message to provide temporal awareness.
|
||||
*/
|
||||
export function createDatetimeContext(): ContextMessage {
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: nanoid(),
|
||||
contextId: DATETIME_CONTEXT_ID,
|
||||
strategy: ContextUpdateStrategy.ReplaceSelf,
|
||||
text: `Current datetime: ${now.toISOString()} (${now.toLocaleString()})`,
|
||||
createdAt: Date.now(),
|
||||
metadata: {
|
||||
source: {
|
||||
id: DATETIME_CONTEXT_ID,
|
||||
kind: 'plugin',
|
||||
plugin: {
|
||||
id: 'airi:system:datetime',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export { createDatetimeContext } from './datetime'
|
||||
export { createMinecraftContext } from './minecraft'
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatTimePrefix } from './datetime-prefix'
|
||||
|
||||
describe('formatTimePrefix', () => {
|
||||
it('wraps `[YYYY-MM-DD HH:MM]` with trailing space', () => {
|
||||
const ts = new Date(2026, 3, 25, 18, 47, 0).getTime()
|
||||
expect(formatTimePrefix(ts)).toBe('[2026-04-25 18:47] ')
|
||||
})
|
||||
|
||||
it('zero-pads month, day, hour, minute', () => {
|
||||
const ts = new Date(2026, 0, 5, 3, 7, 0).getTime() // 5 January 2026, 03:07 local
|
||||
expect(formatTimePrefix(ts)).toBe('[2026-01-05 03:07] ')
|
||||
})
|
||||
|
||||
it('produces stable output for the same input (cache-friendly)', () => {
|
||||
const ts = new Date(2026, 3, 25, 18, 47, 0).getTime()
|
||||
expect(formatTimePrefix(ts)).toBe(formatTimePrefix(ts))
|
||||
})
|
||||
|
||||
it('produces different output across day boundaries (lets the model see day changes)', () => {
|
||||
const day1 = new Date(2026, 3, 25, 12, 0, 0).getTime()
|
||||
const day2 = new Date(2026, 3, 26, 12, 0, 0).getTime()
|
||||
expect(formatTimePrefix(day1)).not.toBe(formatTimePrefix(day2))
|
||||
expect(formatTimePrefix(day1)).toContain('2026-04-25')
|
||||
expect(formatTimePrefix(day2)).toContain('2026-04-26')
|
||||
})
|
||||
|
||||
it('shares the same prefix across timestamps in the same minute (KV-cache stable)', () => {
|
||||
const a = new Date(2026, 3, 25, 18, 47, 12).getTime()
|
||||
const b = new Date(2026, 3, 25, 18, 47, 58).getTime()
|
||||
expect(formatTimePrefix(a)).toBe(formatTimePrefix(b))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Per-message timestamp prefix.
|
||||
*
|
||||
* Replaces the old `<context><module name="system:datetime">...</module></context>`
|
||||
* block (which weak local models tended to mirror back into replies and which
|
||||
* invalidated KV-cache prefixes on every send).
|
||||
*
|
||||
* Strategy:
|
||||
* - Each user/assistant message is prefixed with `[YYYY-MM-DD HH:MM]` derived
|
||||
* from its persisted `createdAt`. Stored timestamps never change, so the
|
||||
* prefixed history stays byte-stable across turns and accumulates KV-cache
|
||||
* prefix matches.
|
||||
* - The full date is included on every message so the model can infer "today"
|
||||
* from the most recent message — there is no separate system-prompt date
|
||||
* anchor, which keeps the system prompt 100% static and permanently
|
||||
* cacheable across turns and across day boundaries.
|
||||
*
|
||||
* Format choice:
|
||||
* - `[YYYY-MM-DD HH:MM]` is ISO-like, structurally compact (~17 chars), and
|
||||
* sits in a region of the training distribution where bracketed datetime
|
||||
* prefixes occur naturally (chat logs, IRC, syslog), which suppresses the
|
||||
* "echo it back as data" tendency of weak local models.
|
||||
* - `Date.toString()` (e.g. `Sat Apr 25 2026 18:47:00 GMT+0800 (China Standard
|
||||
* Time)`) is avoided: too long, trailing locale parens carry no useful
|
||||
* signal, and the format clusters in log/debug-output training data which
|
||||
* correlates with verbatim copy-back.
|
||||
*/
|
||||
|
||||
const DATE_TIME = new Intl.DateTimeFormat('en-CA', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* Formats a timestamp as `[YYYY-MM-DD HH:MM] ` in the user's local timezone.
|
||||
*
|
||||
* Use when:
|
||||
* - Annotating user/assistant messages so the model has a concrete time
|
||||
* anchor on every turn — historic and current alike use the same shape so
|
||||
* that prefix-cache stays valid when a "current" turn becomes "historic" on
|
||||
* the next send.
|
||||
*
|
||||
* Returns:
|
||||
* - String including a trailing space, e.g. `"[2026-04-25 18:47] "`.
|
||||
*
|
||||
* Before:
|
||||
* - createdAt = 1745570820000 (a Unix ms in Asia/Shanghai)
|
||||
*
|
||||
* After:
|
||||
* - "[2026-04-25 18:47] "
|
||||
*/
|
||||
export function formatTimePrefix(createdAt: number): string {
|
||||
// Intl en-CA locale uses ISO-style `YYYY-MM-DD, HH:MM`. Strip the comma to
|
||||
// produce the bracketed `YYYY-MM-DD HH:MM` form.
|
||||
const formatted = DATE_TIME.format(new Date(createdAt)).replace(', ', ' ')
|
||||
return `[${formatted}] `
|
||||
}
|
||||
Reference in New Issue
Block a user