diff --git a/packages/stage-ui/src/stores/chat.contract.test.ts b/packages/stage-ui/src/stores/chat.contract.test.ts index b2e379569..f8abb03ee 100644 --- a/packages/stage-ui/src/stores/chat.contract.test.ts +++ b/packages/stage-ui/src/stores/chat.contract.test.ts @@ -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('') - expect(syntheticContextText).toContain('') + expect(syntheticContextText).not.toContain('') + expect(syntheticContextText).not.toContain(' { diff --git a/packages/stage-ui/src/stores/chat.ts b/packages/stage-ui/src/stores/chat.ts index ec37c879d..fb955d724 100644 --- a/packages/stage-ui/src/stores/chat.ts +++ b/packages/stage-ui/src/stores/chat.ts @@ -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(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 `` 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 diff --git a/packages/stage-ui/src/stores/chat/context-prompt.test.ts b/packages/stage-ui/src/stores/chat/context-prompt.test.ts index c340f7b05..1d3c1beb2 100644 --- a/packages/stage-ui/src/stores/chat/context-prompt.test.ts +++ b/packages/stage-ui/src/stores/chat/context-prompt.test.ts @@ -7,18 +7,18 @@ import { buildContextPromptMessage, formatContextPromptText } from './context-pr function makeContext(overrides: Record = {}): 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('') - expect(text).toContain('') - expect(text).toContain('') - expect(text).toContain('Current datetime: 2026-04-07T12:34:00.000Z') + expect(text).not.toContain('') + expect(text).not.toContain(' { + 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('') - expect(text).toContain('') - 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') }) }) diff --git a/packages/stage-ui/src/stores/chat/context-prompt.ts b/packages/stage-ui/src/stores/chat/context-prompt.ts index b4d13e932..6d383ab5a 100644 --- a/packages/stage-ui/src/stores/chat/context-prompt.ts +++ b/packages/stage-ui/src/stores/chat/context-prompt.ts @@ -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 /** - * 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 (`...`) 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 { diff --git a/packages/stage-ui/src/stores/chat/context-providers/datetime.test.ts b/packages/stage-ui/src/stores/chat/context-providers/datetime.test.ts deleted file mode 100644 index 20081d9d8..000000000 --- a/packages/stage-ui/src/stores/chat/context-providers/datetime.test.ts +++ /dev/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() - } - }) -}) diff --git a/packages/stage-ui/src/stores/chat/context-providers/datetime.ts b/packages/stage-ui/src/stores/chat/context-providers/datetime.ts deleted file mode 100644 index ecc6fe116..000000000 --- a/packages/stage-ui/src/stores/chat/context-providers/datetime.ts +++ /dev/null @@ -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', - }, - }, - }, - } -} diff --git a/packages/stage-ui/src/stores/chat/context-providers/index.ts b/packages/stage-ui/src/stores/chat/context-providers/index.ts index 0dfa151d3..7e1c8bd50 100644 --- a/packages/stage-ui/src/stores/chat/context-providers/index.ts +++ b/packages/stage-ui/src/stores/chat/context-providers/index.ts @@ -1,2 +1 @@ -export { createDatetimeContext } from './datetime' export { createMinecraftContext } from './minecraft' diff --git a/packages/stage-ui/src/stores/chat/datetime-prefix.test.ts b/packages/stage-ui/src/stores/chat/datetime-prefix.test.ts new file mode 100644 index 000000000..e744aacd3 --- /dev/null +++ b/packages/stage-ui/src/stores/chat/datetime-prefix.test.ts @@ -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)) + }) +}) diff --git a/packages/stage-ui/src/stores/chat/datetime-prefix.ts b/packages/stage-ui/src/stores/chat/datetime-prefix.ts new file mode 100644 index 000000000..bdee72b1b --- /dev/null +++ b/packages/stage-ui/src/stores/chat/datetime-prefix.ts @@ -0,0 +1,61 @@ +/** + * Per-message timestamp prefix. + * + * Replaces the old `...` + * 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}] ` +}