From ad1abe6d98e771d9c9a7adc89acb7227463b385c Mon Sep 17 00:00:00 2001 From: rwilkin Date: Sat, 27 Jun 2026 17:49:09 +1000 Subject: [PATCH 1/3] Add scene clock support --- common/grammar.ts | 2 + common/prompt-order.ts | 23 +++- common/prompt.ts | 60 ++++++++++- common/scene-clock.ts | 55 ++++++++++ common/template-parser.ts | 4 + common/types/schema.ts | 17 +++ srv/api/chat/edit.ts | 13 +++ tests/__snapshots__/prompt.spec.js.snap | 1 + tests/prompt.spec.ts | 50 +++++++++ tests/scene-clock.spec.ts | 48 +++++++++ web/pages/Chat/ChatMenu.tsx | 3 + web/pages/Chat/ChatSettings.tsx | 32 ++++++ web/pages/Chat/SceneClockPanel.tsx | 136 ++++++++++++++++++++++++ web/store/data/bot-generate.ts | 25 ++++- 14 files changed, 464 insertions(+), 5 deletions(-) create mode 100644 common/scene-clock.ts create mode 100644 tests/scene-clock.spec.ts create mode 100644 web/pages/Chat/SceneClockPanel.tsx diff --git a/common/grammar.ts b/common/grammar.ts index f0ed182c3..575f84f29 100644 --- a/common/grammar.ts +++ b/common/grammar.ts @@ -135,6 +135,7 @@ MainCharacter "main-character" = ("mainchar"i / "mainbot"i) { return "main-char" Character "character" = ("char"i / "character"i / "bot"i) { return "char" } User "user" = "user"i { return "user" } Scenario "scenario" = "scenario"i { return "scenario" } +SceneClock "scene-clock" = ("scene_clock"i / "sceneclock"i) { return "scene_clock" } Impersonate "impersonating" = ("impersonate"i / "impersonating"i / "impersonality"i) { return "impersonating" } Persona "personality" = ("personality"i / "persona"i) { return "personality" } AllPersona "all_personalities" = ("all_personas"i / "all_personalities"i) { return "all_personalities" } @@ -173,6 +174,7 @@ Interp "interp" / UserEmbed / User / Scenario + / SceneClock / Persona / Impersonate / AllPersona diff --git a/common/prompt-order.ts b/common/prompt-order.ts index 03c096504..6e7caf967 100644 --- a/common/prompt-order.ts +++ b/common/prompt-order.ts @@ -15,9 +15,10 @@ export function promptOrderToTemplate( ) { const parts: string[] = [] const preamble = getOrderHolder(format, 'preamble') + const normalized = withSceneClock(order) if (preamble) parts.push(preamble) - for (const item of order) { + for (const item of normalized) { if (!item.enabled) continue const text = getOrderHolder(format, item.placeholder) @@ -36,7 +37,7 @@ export function promptOrderToTemplate( } export function promptOrderToSections(opts: OrderOptions) { - const order = (opts.order || SIMPLE_ORDER).filter( + const order = withSceneClock(opts.order || SIMPLE_ORDER).filter( (o) => o.placeholder !== 'system_prompt' && o.placeholder !== 'ujb' && @@ -75,9 +76,23 @@ function getOrderHolder(format: string, holder: string) { return formatHolders[format]?.[holder] || formatHolders.Universal[holder] || '' } +function withSceneClock(order: NonNullable) { + if (order.some((item) => item.placeholder === 'scene_clock')) return order + + const index = order.findIndex((item) => item.placeholder === 'scenario') + const sceneClock = { placeholder: 'scene_clock', enabled: true } + + if (index === -1) { + return [sceneClock, ...order] + } + + return order.slice(0, index + 1).concat(sceneClock, order.slice(index + 1)) +} + export const SIMPLE_ORDER: NonNullable = [ 'system_prompt', 'scenario', + 'scene_clock', 'personality', 'chat_embed', 'memory', @@ -89,6 +104,7 @@ export const formatHolders: Record> = { Universal: { system_prompt: neat`{{#if system_prompt}}{{value}}{{#else}}${defaultSystemPrompt}{{/else}}{{/if}}`, scenario: neat`{{#if scenario}}The scenario of the conversation:\n{{scenario}}\n{{/if}}`, + scene_clock: `{{scene_clock}}`, memory: neat`{{#if memory}}"{{char}}'s" memories:\n{{memory}}\n{{/if}}`, personality: neat`{{#if personality}}{{char}}'s personality:\n{{personality}}\n{{/if}}`, impersonating: neat`{{#if impersonating}}{{user}}'s personality:\n{{impersonating}}\n{{/if}}`, @@ -145,6 +161,7 @@ export const formatHolders: Record> = { system_prompt: neat`<|im_start|>system {{#if system_prompt}}{{value}}<|im_end|>{{#else}}${defaultSystemPrompt}<|im_end|>{{/else}}{{/if}}`, scenario: neat`{{#if scenario}}The scenario of the conversation:\n{{scenario}}\n{{/if}}`, + scene_clock: `{{scene_clock}}`, memory: neat`{{#if memory}}"{{char}}'s" memories:\n{{memory}}\n{{/if}}`, personality: neat`{{#if personality}}{{char}}'s personality:\n{{personality}}\n{{/if}}`, impersonating: neat`{{#if impersonating}}{{user}}'s personality:\n{{impersonating}}\n{{/if}}`, @@ -162,6 +179,7 @@ export const formatHolders: Record> = { system_prompt: neat`<|begin_of_text|><|start_header_id|>system {{#if system_prompt}}{{value}}<|eot_id|>{{#else}}${defaultSystemPrompt}<|eot_id|>{{/else}}{{/if}}`, scenario: neat`{{#if scenario}}The scenario of the conversation:\n{{scenario}}\n{{/if}}`, + scene_clock: `{{scene_clock}}`, memory: neat`{{#if memory}}"{{char}}'s" memories:\n{{memory}}\n{{/if}}`, personality: neat`{{#if personality}}"{{char}}'s" personality:\n{{personality}}\n{{/if}}`, impersonating: neat`{{#if impersonating}}"{{user}}'s" personality:\n{{impersonating}}\n{{/if}}`, @@ -178,6 +196,7 @@ export const formatHolders: Record> = { 'Pyg/Simple': { history: `Start of the conversation:\n\n{{history}}`, scenario: `{{#if scenario}}Scenario: {{scenario}}{{/if}}`, + scene_clock: `{{scene_clock}}`, }, } diff --git a/common/prompt.ts b/common/prompt.ts index cb10031b0..923ea98db 100644 --- a/common/prompt.ts +++ b/common/prompt.ts @@ -42,6 +42,7 @@ export type PromptLine = { export type PromptPlaceholders = { scenario?: string + sceneClock?: string greeting?: string sampleChat?: string[] persona: string @@ -138,6 +139,7 @@ const HOLDER_NAMES = { memory: 'memory', post: 'post', scenario: 'scenario', + sceneClock: 'scene_clock', history: 'history', systemPrompt: 'system_prompt', linebreak: 'br', @@ -154,6 +156,7 @@ export const HOLDERS = { ujb: /{{ujb}}/gi, sampleChat: /{{example_dialogue}}/gi, scenario: /{{scenario}}/gi, + sceneClock: /{{scene_clock}}/gi, memory: /{{memory}}/gi, persona: /{{personality}}/gi, allPersonas: /{{all_personalities}}/gi, @@ -405,7 +408,7 @@ export function getTemplate(opts: Pick) } // Deprecated - return ensureValidTemplate(template) + return ensureValidTemplate(template, opts.chat.sceneClock?.enabled ? undefined : ['sceneClock']) } type InjectOpts = { @@ -466,15 +469,22 @@ function replaceSectionTags(sections: Record, format: Mo */ export function ensureValidTemplate( template: string, - skip?: Array<'history' | 'post' | 'persona' | 'scenario' | 'userEmbed' | 'chatEmbed'> + skip?: Array< + 'history' | 'post' | 'persona' | 'scenario' | 'sceneClock' | 'userEmbed' | 'chatEmbed' + > ) { const skips = new Set(skip || []) let hasHistory = !!template.match(HOLDERS.history) || !!template.match(/{{\#each msg}}/gi) let hasPost = !!template.match(HOLDERS.post) + let hasSceneClock = !!template.match(HOLDERS.sceneClock) let modified = template + if (!skips.has('sceneClock') && !hasSceneClock) { + modified = insertSceneClockHolder(modified) + } + if (!skips.has('post') && !skips.has('history') && !hasHistory && !hasPost) { modified += `\n{{history}}\n{{post}}` } else if (!skips.has('history') && !hasHistory && hasPost) { @@ -486,6 +496,20 @@ export function ensureValidTemplate( return modified } +function insertSceneClockHolder(template: string) { + const sceneClock = `{{${HOLDER_NAMES.sceneClock}}}` + + if (template.match(HOLDERS.persona)) { + return template.replace(HOLDERS.persona, `${sceneClock}{{${HOLDER_NAMES.persona}}}`) + } + + if (template.match(HOLDERS.history)) { + return template.replace(HOLDERS.history, `${sceneClock}{{${HOLDER_NAMES.history}}}`) + } + + return `${template}${sceneClock}` +} + type PromptPartsOptions = Pick< PromptOpts, | 'kind' @@ -575,6 +599,7 @@ export async function buildPromptPlaceholders( // replyAs instead of the main character // (we always use the main character's scenario, not replyAs) parts.scenario = replace(opts.resolvedScenario, char.name) + parts.sceneClock = getSceneClockPrompt(chat.sceneClock) const sampleChat = replyAs._id === char._id && !!chat.overrides @@ -643,6 +668,37 @@ export async function buildPromptPlaceholders( } } +function getSceneClockPrompt(clock?: AppSchema.SceneClock) { + if (!clock?.enabled) return '' + + const date = clock.date?.trim() + const time = clock.time?.trim() + const current = [date, time].filter(Boolean).join(' ') + if (!current && !clock.calendarName?.trim() && !clock.notes?.trim()) return '' + + const lines = ['[Scene Clock]'] + if (current) { + lines.push(`Current in-scene date and time: ${current}.`) + lines.push('Treat this as the authoritative current RP scene time unless the user changes it.') + } + if (clock.calendarName?.trim()) { + lines.push(`Calendar: ${clock.calendarName.trim()}.`) + } + if (clock.notes?.trim()) { + lines.push(`Notes: ${clock.notes.trim()}`) + } + if (clock.allowAssistantUpdates) { + lines.push( + 'When the in-scene date or time changes, append exactly one hidden control tag at the end of your response:', + '{"date":"NEW_DATE","time":"NEW_TIME"}', + 'The opening and closing scene_clock_update tags are mandatory. Use valid JSON inside them. You may include date, time, calendarName, or notes; omit unchanged fields. Do not mention this tag in the dialogue.' + ) + } + lines.push('[/Scene Clock]') + + return `${lines.join('\n')}\n` +} + function getSupplementaryParts(opts: PromptPartsOptions, replyAs: AppSchema.Character) { const { settings, chat } = opts const parts = { diff --git a/common/scene-clock.ts b/common/scene-clock.ts new file mode 100644 index 000000000..dd7c00bc7 --- /dev/null +++ b/common/scene-clock.ts @@ -0,0 +1,55 @@ +import { AppSchema } from './types' + +export const SCENE_CLOCK_UPDATE_TAG = + /\s*([\s\S]*?)\s*<\/scene_clock_update>/gi + +type SceneClockUpdate = Partial< + Pick +> + +export function parseSceneClockUpdate(response: string): { + text: string + update?: SceneClockUpdate +} { + let update: SceneClockUpdate | undefined + let text = response.replace(SCENE_CLOCK_UPDATE_TAG, (_, json: string) => { + const parsed = parseUpdateJson(json) + if (parsed) update = parsed + return '' + }) + + if (!update) { + const bareJson = text.match(/(?:^|\n)\s*(\{[^\n]*\})\s*$/) + if (bareJson?.index !== undefined) { + const parsed = parseUpdateJson(bareJson[1]) + if (parsed) { + update = parsed + text = text.slice(0, bareJson.index) + } + } + } + + return { + text: text.replace(/[ \t]+\n/g, '\n').trim(), + update, + } +} + +function parseUpdateJson(value: string): SceneClockUpdate | undefined { + try { + const parsed = JSON.parse(value) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return + + const update: SceneClockUpdate = {} + for (const key of ['date', 'time', 'calendarName', 'notes'] as const) { + const next = parsed[key] + if (next === undefined) continue + if (typeof next !== 'string') return + update[key] = next.trim() + } + + return Object.keys(update).length ? update : undefined + } catch { + return + } +} diff --git a/common/template-parser.ts b/common/template-parser.ts index 33a04db6b..f7a0f5202 100644 --- a/common/template-parser.ts +++ b/common/template-parser.ts @@ -164,6 +164,7 @@ type Holder = | 'char' | 'user' | 'scenario' + | 'scene_clock' | 'personality' | 'example_dialogue' | 'history' @@ -1003,6 +1004,9 @@ function getPlaceholder( case 'scenario': return opts.parts?.scenario || opts.chat?.scenario || opts.char?.scenario || '' + case 'scene_clock': + return opts.parts?.sceneClock || '' + case 'memory': return opts.parts?.memory || '' diff --git a/common/types/schema.ts b/common/types/schema.ts index bc52e2eb3..c489690e2 100644 --- a/common/types/schema.ts +++ b/common/types/schema.ts @@ -265,6 +265,22 @@ export namespace AppSchema { kind: 'saga-session' } + export type SceneClockDateFormat = 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD' | 'Long' + export type SceneClockTimeFormat = '12h' | '24h' + + export interface SceneClock { + enabled: boolean + date: string + time: string + dateFormat: SceneClockDateFormat + timeFormat: SceneClockTimeFormat + calendarName?: string + notes?: string + allowAssistantUpdates?: boolean + lastUpdatedBy?: 'user' | 'assistant' | 'system' + lastUpdatedAt?: string + } + export interface Chat { _id: string kind: 'chat' @@ -297,6 +313,7 @@ export namespace AppSchema { scenarioIds?: string[] scenarioStates?: string[] + sceneClock?: SceneClock treeLeafId?: string diff --git a/srv/api/chat/edit.ts b/srv/api/chat/edit.ts index ab4d8e237..282b0025d 100644 --- a/srv/api/chat/edit.ts +++ b/srv/api/chat/edit.ts @@ -22,6 +22,18 @@ export const updateChat = handle(async ({ params, body, user, userId }) => { useOverrides: 'boolean?', userEmbedId: 'string?', scenarioStates: ['string?'], + sceneClock: optional({ + enabled: 'boolean', + date: 'string', + time: 'string', + dateFormat: ['DD/MM/YYYY', 'MM/DD/YYYY', 'YYYY-MM-DD', 'Long'], + timeFormat: ['12h', '24h'], + calendarName: 'string?', + notes: 'string?', + allowAssistantUpdates: 'boolean?', + lastUpdatedBy: ['user', 'assistant', 'system', null], + lastUpdatedAt: 'string?', + }), systemPrompt: 'string?', postHistoryInstructions: 'string?', imageSource: 'string?', @@ -54,6 +66,7 @@ export const updateChat = handle(async ({ params, body, user, userId }) => { userEmbedId: body.userEmbedId ?? prev.userEmbedId, scenarioIds: body.scenarioIds ?? prev.scenarioIds, scenarioStates: body.scenarioStates ?? prev.scenarioStates, + sceneClock: body.sceneClock ?? prev.sceneClock, imageSource: (body.imageSource as any) ?? prev.imageSource, imageSettings: body.imageSettings, invisible: body.invisible ?? prev.invisible, diff --git a/tests/__snapshots__/prompt.spec.js.snap b/tests/__snapshots__/prompt.spec.js.snap index 24a2a5360..c11fdac14 100644 --- a/tests/__snapshots__/prompt.spec.js.snap +++ b/tests/__snapshots__/prompt.spec.js.snap @@ -203,6 +203,7 @@ Object { "SAMPLECHAT OtherBot", ], "scenario": "MAIN MainChar", + "sceneClock": "", "systemPrompt": undefined, "ujb": "!!UJB_PROMPT!!", "userEmbeds": Array [], diff --git a/tests/prompt.spec.ts b/tests/prompt.spec.ts index fad590d13..ba2daa253 100644 --- a/tests/prompt.spec.ts +++ b/tests/prompt.spec.ts @@ -201,6 +201,56 @@ This is how {{char}} should talk: {{example_dialogue}}`, expect(actual.template.parsed).toMatchSnapshot() }) + it('will include scene clock for saved prompt orders without the scene clock placeholder', async () => { + const actual = await build([botMsg('first')], { + chat: toChat(main, { + sceneClock: { + enabled: true, + date: '2026-06-27', + time: '11:30', + dateFormat: 'YYYY-MM-DD', + timeFormat: '24h', + calendarName: 'Imperial calendar', + }, + }), + settings: { + useAdvancedPrompt: 'basic', + modelFormat: 'None', + promptOrder: [ + { placeholder: 'system_prompt', enabled: true }, + { placeholder: 'scenario', enabled: true }, + { placeholder: 'personality', enabled: true }, + { placeholder: 'example_dialogue', enabled: true }, + { placeholder: 'history', enabled: true }, + ], + }, + }) + + expect(actual.template.parsed).to.include('[Scene Clock]') + expect(actual.template.parsed).to.include('Current in-scene date and time: 2026-06-27 11:30.') + expect(actual.template.parsed).to.include('Calendar: Imperial calendar.') + }) + + it('will include scene clock for custom gaslights without the scene clock placeholder', async () => { + const actual = await build([botMsg('first')], { + chat: toChat(main, { + sceneClock: { + enabled: true, + date: '2026-06-27', + time: '11:30', + dateFormat: 'YYYY-MM-DD', + timeFormat: '24h', + }, + }), + settings: { + gaslight: `The scenario of the conversation:\n{{scenario}}\n\n{{char}}'s personality:\n{{personality}}`, + }, + }) + + expect(actual.template.parsed).to.include('[Scene Clock]') + expect(actual.template.parsed).to.include('Current in-scene date and time: 2026-06-27 11:30.') + }) + it('will use currently speaking character book', async () => { const actual = await build([toMsg('TRIGGER')], { char: { ...main, characterBook: toBook('main char book', []) }, diff --git a/tests/scene-clock.spec.ts b/tests/scene-clock.spec.ts new file mode 100644 index 000000000..0f3fc1f53 --- /dev/null +++ b/tests/scene-clock.spec.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai' +import { parseSceneClockUpdate } from '../common/scene-clock' + +describe('Scene Clock assistant updates', () => { + it('extracts a valid partial update and removes the markup', () => { + const result = parseSceneClockUpdate( + 'The bells ring at midnight.\n{"date":"2026-06-29","time":"00:00"}' + ) + + expect(result.text).to.equal('The bells ring at midnight.') + expect(result.update).to.deep.equal({ date: '2026-06-29', time: '00:00' }) + }) + + it('removes malformed markup without updating the clock', () => { + const result = parseSceneClockUpdate( + 'Time passes.{date: tomorrow}' + ) + + expect(result.text).to.equal('Time passes.') + expect(result.update).to.equal(undefined) + }) + + it('rejects non-string field values', () => { + const result = parseSceneClockUpdate( + '{"time":1230}' + ) + + expect(result.text).to.equal('') + expect(result.update).to.equal(undefined) + }) + + it('accepts a valid standalone update object at the end of a response', () => { + const result = parseSceneClockUpdate( + 'Christmas morning arrives.\n\n{"date":"2026-12-25","time":"08:00"}' + ) + + expect(result.text).to.equal('Christmas morning arrives.') + expect(result.update).to.deep.equal({ date: '2026-12-25', time: '08:00' }) + }) + + it('does not remove an unrelated JSON object', () => { + const response = 'The API returned:\n{"status":"ready"}' + const result = parseSceneClockUpdate(response) + + expect(result.text).to.equal(response) + expect(result.update).to.equal(undefined) + }) +}) diff --git a/web/pages/Chat/ChatMenu.tsx b/web/pages/Chat/ChatMenu.tsx index 397df336c..7e96d769d 100644 --- a/web/pages/Chat/ChatMenu.tsx +++ b/web/pages/Chat/ChatMenu.tsx @@ -25,6 +25,7 @@ import { PresetProvider } from '../Settings/Provider' import { createEmitter } from '/web/shared/util' import { usePresetContext } from '/web/store/preset-context' import { getStore } from '/web/store/create' +import { SceneClockPanel } from './SceneClockPanel' type NavProps = { ctx: ChatContext @@ -188,6 +189,8 @@ const ChatNav: Component = (props) => { + + ) } diff --git a/web/pages/Chat/ChatSettings.tsx b/web/pages/Chat/ChatSettings.tsx index a04532fa3..5639eb0d9 100644 --- a/web/pages/Chat/ChatSettings.tsx +++ b/web/pages/Chat/ChatSettings.tsx @@ -236,6 +236,7 @@ const ChatSettings: Component<{ } const onSave = () => { + const sceneClock = getInitSceneClock(state.active?.chat?.sceneClock) const payload = { name: edit.name, greeting: edit.greeting, @@ -250,6 +251,12 @@ const ChatSettings: Component<{ imageSource: edit.imageSource, scenarioIds: edit.scenarioId ? [edit.scenarioId] : [], scenarioStates: edit.scenarioStates, + sceneClock: { + ...sceneClock, + enabled: edit.sceneClock.enabled, + lastUpdatedBy: 'user' as const, + lastUpdatedAt: new Date().toISOString(), + }, } chatStore.editChat(state.active?.chat?._id!, payload, { useOverrides: edit.useOverrides, @@ -508,6 +515,15 @@ const ChatSettings: Component<{ + + setEdit('sceneClock', 'enabled', ev)} + label="Scene Clock" + helperText="Keeps the fictional scene date and time with this chat and includes it in prompts. Edit the clock details from the Chat Menu." + /> + + = [ + { value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' }, + { value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' }, + { value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' }, + { value: 'Long', label: 'Long' }, +] + +const timeFormats: Array<{ value: AppSchema.SceneClockTimeFormat; label: string }> = [ + { value: '12h', label: '12 hour' }, + { value: '24h', label: '24 hour' }, +] + +export const SceneClockPanel: Component<{ chat?: AppSchema.Chat }> = (props) => { + const [clock, setClock] = createStore(getInitSceneClock(props.chat?.sceneClock)) + + createEffect(() => { + setClock(getInitSceneClock(props.chat?.sceneClock)) + }) + + const save = () => { + if (!props.chat) return + + chatStore.editChat( + props.chat._id, + { + sceneClock: { + ...clock, + date: clock.date.trim(), + time: clock.time.trim(), + calendarName: clock.calendarName?.trim(), + notes: clock.notes?.trim(), + lastUpdatedBy: 'user', + lastUpdatedAt: new Date().toISOString(), + }, + }, + { quiet: true } + ) + } + + return ( + +
+
+
+ + Scene Clock +
+ +
+ +
+ setClock('date', ev.currentTarget.value)} + /> + setClock('time', ev.currentTarget.value)} + /> +
+ +
+ setClock('timeFormat', ev.value as AppSchema.SceneClockTimeFormat)} + /> +
+ + setClock('calendarName', ev.currentTarget.value)} + /> + + setClock('notes', ev.currentTarget.value)} + /> + + setClock('allowAssistantUpdates', ev)} + label="Assistant Update Markup" + helperText="Allows assistant responses to update the clock using hidden markup." + /> +
+
+ ) +} + +function getInitSceneClock(clock?: AppSchema.SceneClock): AppSchema.SceneClock { + return { + enabled: clock?.enabled || false, + date: clock?.date || '', + time: clock?.time || '', + dateFormat: clock?.dateFormat || 'YYYY-MM-DD', + timeFormat: clock?.timeFormat || '24h', + calendarName: clock?.calendarName || '', + notes: clock?.notes || '', + allowAssistantUpdates: clock?.allowAssistantUpdates || false, + lastUpdatedBy: clock?.lastUpdatedBy, + lastUpdatedAt: clock?.lastUpdatedAt, + } +} diff --git a/web/store/data/bot-generate.ts b/web/store/data/bot-generate.ts index 06de3a9a9..0ae48688f 100644 --- a/web/store/data/bot-generate.ts +++ b/web/store/data/bot-generate.ts @@ -43,6 +43,7 @@ import { debug } from '/common/debug' import { formatJsonSchemaVars, prepareJsonSchema } from '/common/guidance/json-schema' import { getJsonSchema } from '/web/shared/util' import { ResponseSchema } from '/common/types/library' +import { parseSceneClockUpdate } from '/common/scene-clock' iconv.enableStreamingAPI(require('stream')) @@ -438,7 +439,29 @@ async function handlePostStreamResponse(input: { json?: JsonOutput jsonCall?: boolean }) { - const { req, opts, response, json, meta } = input + const { req, opts, json, meta } = input + let { response } = input + + const sceneClock = req.request.chat.sceneClock + if (sceneClock?.enabled && sceneClock.allowAssistantUpdates) { + const parsed = parseSceneClockUpdate(response) + response = parsed.text + + if (parsed.update) { + await getStore('chat').editChat( + req.request.chat._id, + { + sceneClock: { + ...sceneClock, + ...parsed.update, + lastUpdatedBy: 'assistant', + lastUpdatedAt: new Date().toISOString(), + }, + }, + { quiet: true } + ) + } + } if (opts.signal.signal.aborted) { getStore('responses').setState({ From ad7cdb650ed371e1bfb6d0940f6d67fb15c3af6f Mon Sep 17 00:00:00 2001 From: rwilkin Date: Wed, 1 Jul 2026 10:18:52 +1000 Subject: [PATCH 2/3] Add day of week to scene clock --- common/prompt.ts | 13 +++++++++++-- common/scene-clock.ts | 4 ++-- common/types/schema.ts | 1 + srv/api/chat/edit.ts | 1 + tests/prompt.spec.ts | 2 ++ tests/scene-clock.spec.ts | 8 ++++++-- web/pages/Chat/ChatSettings.tsx | 1 + web/pages/Chat/SceneClockPanel.tsx | 10 ++++++++++ 8 files changed, 34 insertions(+), 6 deletions(-) diff --git a/common/prompt.ts b/common/prompt.ts index 923ea98db..e0345811d 100644 --- a/common/prompt.ts +++ b/common/prompt.ts @@ -674,13 +674,22 @@ function getSceneClockPrompt(clock?: AppSchema.SceneClock) { const date = clock.date?.trim() const time = clock.time?.trim() const current = [date, time].filter(Boolean).join(' ') - if (!current && !clock.calendarName?.trim() && !clock.notes?.trim()) return '' + if ( + !current && + !clock.dayOfWeek?.trim() && + !clock.calendarName?.trim() && + !clock.notes?.trim() + ) + return '' const lines = ['[Scene Clock]'] if (current) { lines.push(`Current in-scene date and time: ${current}.`) lines.push('Treat this as the authoritative current RP scene time unless the user changes it.') } + if (clock.dayOfWeek?.trim()) { + lines.push(`Day of week: ${clock.dayOfWeek.trim()}.`) + } if (clock.calendarName?.trim()) { lines.push(`Calendar: ${clock.calendarName.trim()}.`) } @@ -691,7 +700,7 @@ function getSceneClockPrompt(clock?: AppSchema.SceneClock) { lines.push( 'When the in-scene date or time changes, append exactly one hidden control tag at the end of your response:', '{"date":"NEW_DATE","time":"NEW_TIME"}', - 'The opening and closing scene_clock_update tags are mandatory. Use valid JSON inside them. You may include date, time, calendarName, or notes; omit unchanged fields. Do not mention this tag in the dialogue.' + 'The opening and closing scene_clock_update tags are mandatory. Use valid JSON inside them. You may include date, time, dayOfWeek, calendarName, or notes; omit unchanged fields. Do not mention this tag in the dialogue.' ) } lines.push('[/Scene Clock]') diff --git a/common/scene-clock.ts b/common/scene-clock.ts index dd7c00bc7..f3c50847b 100644 --- a/common/scene-clock.ts +++ b/common/scene-clock.ts @@ -4,7 +4,7 @@ export const SCENE_CLOCK_UPDATE_TAG = /\s*([\s\S]*?)\s*<\/scene_clock_update>/gi type SceneClockUpdate = Partial< - Pick + Pick > export function parseSceneClockUpdate(response: string): { @@ -41,7 +41,7 @@ function parseUpdateJson(value: string): SceneClockUpdate | undefined { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return const update: SceneClockUpdate = {} - for (const key of ['date', 'time', 'calendarName', 'notes'] as const) { + for (const key of ['date', 'time', 'dayOfWeek', 'calendarName', 'notes'] as const) { const next = parsed[key] if (next === undefined) continue if (typeof next !== 'string') return diff --git a/common/types/schema.ts b/common/types/schema.ts index c489690e2..bfe31c6a9 100644 --- a/common/types/schema.ts +++ b/common/types/schema.ts @@ -274,6 +274,7 @@ export namespace AppSchema { time: string dateFormat: SceneClockDateFormat timeFormat: SceneClockTimeFormat + dayOfWeek?: string calendarName?: string notes?: string allowAssistantUpdates?: boolean diff --git a/srv/api/chat/edit.ts b/srv/api/chat/edit.ts index 282b0025d..70a0e9c5d 100644 --- a/srv/api/chat/edit.ts +++ b/srv/api/chat/edit.ts @@ -28,6 +28,7 @@ export const updateChat = handle(async ({ params, body, user, userId }) => { time: 'string', dateFormat: ['DD/MM/YYYY', 'MM/DD/YYYY', 'YYYY-MM-DD', 'Long'], timeFormat: ['12h', '24h'], + dayOfWeek: 'string?', calendarName: 'string?', notes: 'string?', allowAssistantUpdates: 'boolean?', diff --git a/tests/prompt.spec.ts b/tests/prompt.spec.ts index ba2daa253..0edee1a28 100644 --- a/tests/prompt.spec.ts +++ b/tests/prompt.spec.ts @@ -210,6 +210,7 @@ This is how {{char}} should talk: {{example_dialogue}}`, time: '11:30', dateFormat: 'YYYY-MM-DD', timeFormat: '24h', + dayOfWeek: 'Saturday', calendarName: 'Imperial calendar', }, }), @@ -228,6 +229,7 @@ This is how {{char}} should talk: {{example_dialogue}}`, expect(actual.template.parsed).to.include('[Scene Clock]') expect(actual.template.parsed).to.include('Current in-scene date and time: 2026-06-27 11:30.') + expect(actual.template.parsed).to.include('Day of week: Saturday.') expect(actual.template.parsed).to.include('Calendar: Imperial calendar.') }) diff --git a/tests/scene-clock.spec.ts b/tests/scene-clock.spec.ts index 0f3fc1f53..73eaff325 100644 --- a/tests/scene-clock.spec.ts +++ b/tests/scene-clock.spec.ts @@ -4,11 +4,15 @@ import { parseSceneClockUpdate } from '../common/scene-clock' describe('Scene Clock assistant updates', () => { it('extracts a valid partial update and removes the markup', () => { const result = parseSceneClockUpdate( - 'The bells ring at midnight.\n{"date":"2026-06-29","time":"00:00"}' + 'The bells ring at midnight.\n{"date":"2026-06-29","time":"00:00","dayOfWeek":"Monday"}' ) expect(result.text).to.equal('The bells ring at midnight.') - expect(result.update).to.deep.equal({ date: '2026-06-29', time: '00:00' }) + expect(result.update).to.deep.equal({ + date: '2026-06-29', + time: '00:00', + dayOfWeek: 'Monday', + }) }) it('removes malformed markup without updating the clock', () => { diff --git a/web/pages/Chat/ChatSettings.tsx b/web/pages/Chat/ChatSettings.tsx index 5639eb0d9..4e7f63c3f 100644 --- a/web/pages/Chat/ChatSettings.tsx +++ b/web/pages/Chat/ChatSettings.tsx @@ -613,6 +613,7 @@ function getInitSceneClock(clock?: AppSchema.SceneClock): AppSchema.SceneClock { time: clock?.time || '', dateFormat: clock?.dateFormat || 'YYYY-MM-DD', timeFormat: clock?.timeFormat || '24h', + dayOfWeek: clock?.dayOfWeek || '', calendarName: clock?.calendarName || '', notes: clock?.notes || '', allowAssistantUpdates: clock?.allowAssistantUpdates || false, diff --git a/web/pages/Chat/SceneClockPanel.tsx b/web/pages/Chat/SceneClockPanel.tsx index be8d0201b..6787834c3 100644 --- a/web/pages/Chat/SceneClockPanel.tsx +++ b/web/pages/Chat/SceneClockPanel.tsx @@ -37,6 +37,7 @@ export const SceneClockPanel: Component<{ chat?: AppSchema.Chat }> = (props) => ...clock, date: clock.date.trim(), time: clock.time.trim(), + dayOfWeek: clock.dayOfWeek?.trim(), calendarName: clock.calendarName?.trim(), notes: clock.notes?.trim(), lastUpdatedBy: 'user', @@ -60,6 +61,14 @@ export const SceneClockPanel: Component<{ chat?: AppSchema.Chat }> = (props) => + setClock('dayOfWeek', ev.currentTarget.value)} + /> +
Date: Mon, 27 Jul 2026 19:30:15 +1000 Subject: [PATCH 3/3] Harden scene clock assistant updates --- common/prompt.ts | 15 ++++----- common/scene-clock.ts | 33 ++++++++++++++++--- tests/scene-clock.spec.ts | 53 ++++++++++++++++++++++++++++-- web/pages/Chat/SceneClockPanel.tsx | 15 ++++++--- 4 files changed, 94 insertions(+), 22 deletions(-) diff --git a/common/prompt.ts b/common/prompt.ts index e0345811d..40d244508 100644 --- a/common/prompt.ts +++ b/common/prompt.ts @@ -674,18 +674,15 @@ function getSceneClockPrompt(clock?: AppSchema.SceneClock) { const date = clock.date?.trim() const time = clock.time?.trim() const current = [date, time].filter(Boolean).join(' ') - if ( - !current && - !clock.dayOfWeek?.trim() && - !clock.calendarName?.trim() && - !clock.notes?.trim() - ) + if (!current && !clock.dayOfWeek?.trim() && !clock.calendarName?.trim() && !clock.notes?.trim()) return '' const lines = ['[Scene Clock]'] if (current) { lines.push(`Current in-scene date and time: ${current}.`) - lines.push('Treat this as the authoritative current RP scene time unless the user changes it.') + lines.push( + 'Use this as the current in-scene time, not a fixed value. Advance or change it whenever the narrative or user indicates that time passes.' + ) } if (clock.dayOfWeek?.trim()) { lines.push(`Day of week: ${clock.dayOfWeek.trim()}.`) @@ -698,9 +695,9 @@ function getSceneClockPrompt(clock?: AppSchema.SceneClock) { } if (clock.allowAssistantUpdates) { lines.push( - 'When the in-scene date or time changes, append exactly one hidden control tag at the end of your response:', + 'When your response will change the in-scene date or time, begin the response with exactly one hidden control tag before any dialogue:', '{"date":"NEW_DATE","time":"NEW_TIME"}', - 'The opening and closing scene_clock_update tags are mandatory. Use valid JSON inside them. You may include date, time, dayOfWeek, calendarName, or notes; omit unchanged fields. Do not mention this tag in the dialogue.' + 'The opening and closing scene_clock_update tags are mandatory. Use valid JSON inside them. You may include date, time, dayOfWeek, calendarName, or notes; omit unchanged fields. Place the tag at the very start, never at the end, and do not mention it in the dialogue.' ) } lines.push('[/Scene Clock]') diff --git a/common/scene-clock.ts b/common/scene-clock.ts index f3c50847b..af7331f63 100644 --- a/common/scene-clock.ts +++ b/common/scene-clock.ts @@ -1,7 +1,10 @@ import { AppSchema } from './types' export const SCENE_CLOCK_UPDATE_TAG = - /\s*([\s\S]*?)\s*<\/scene_clock_update>/gi + /(?:<\s*)?scene[\s_-]*clock[\s_-]*update\s*>\s*([\s\S]*?)\s*(?:<\s*)?\/\s*scene[\s_-]*clock[\s_-]*update\s*>/gi + +const SCENE_CLOCK_UPDATE_MARKDOWN = + /^\s*\*{1,2}\s*scene[\s_-]*clock[\s_-]*update\s*\*{0,2}\s*(\{[^\n]*\})\s*\*{0,2}(?:\s*\n|$)/i type SceneClockUpdate = Partial< Pick @@ -19,12 +22,32 @@ export function parseSceneClockUpdate(response: string): { }) if (!update) { - const bareJson = text.match(/(?:^|\n)\s*(\{[^\n]*\})\s*$/) - if (bareJson?.index !== undefined) { - const parsed = parseUpdateJson(bareJson[1]) + const markdownUpdate = text.match(SCENE_CLOCK_UPDATE_MARKDOWN) + if (markdownUpdate) { + update = parseUpdateJson(markdownUpdate[1]) + text = text.slice(markdownUpdate[0].length) + } + } + + if (!update) { + const leadingJson = text.match(/^\s*(\{[^\n]*\})(?:\s*\n|$)/) + if (leadingJson) { + const parsed = parseUpdateJson(leadingJson[1]) + if (parsed) { + update = parsed + text = text.slice(leadingJson[0].length) + } + } + } + + // Keep accepting the previous end-of-response fallback for existing prompts and retries. + if (!update) { + const trailingJson = text.match(/(?:^|\n)\s*(\{[^\n]*\})\s*$/) + if (trailingJson?.index !== undefined) { + const parsed = parseUpdateJson(trailingJson[1]) if (parsed) { update = parsed - text = text.slice(0, bareJson.index) + text = text.slice(0, trailingJson.index) } } } diff --git a/tests/scene-clock.spec.ts b/tests/scene-clock.spec.ts index 73eaff325..c0b86fba5 100644 --- a/tests/scene-clock.spec.ts +++ b/tests/scene-clock.spec.ts @@ -4,7 +4,7 @@ import { parseSceneClockUpdate } from '../common/scene-clock' describe('Scene Clock assistant updates', () => { it('extracts a valid partial update and removes the markup', () => { const result = parseSceneClockUpdate( - 'The bells ring at midnight.\n{"date":"2026-06-29","time":"00:00","dayOfWeek":"Monday"}' + '{"date":"2026-06-29","time":"00:00","dayOfWeek":"Monday"}\nThe bells ring at midnight.' ) expect(result.text).to.equal('The bells ring at midnight.') @@ -24,11 +24,45 @@ describe('Scene Clock assistant updates', () => { expect(result.update).to.equal(undefined) }) - it('rejects non-string field values', () => { + it('accepts an update with a missing opening angle bracket', () => { + const result = parseSceneClockUpdate( + 'scene_clock_update>\n{"date":"11/07/2026","time":"8:52 PM"}\n\nSarah steps into the room.' + ) + + expect(result.text).to.equal('Sarah steps into the room.') + expect(result.update).to.deep.equal({ + date: '11/07/2026', + time: '8:52 PM', + }) + }) + + it('accepts an update with collapsed tag separators', () => { + const result = parseSceneClockUpdate( + 'sceneclockupdate>\n{"date":"2026-07-11","time":"20:52"}\n\nSarah steps into the room.' + ) + + expect(result.text).to.equal('Sarah steps into the room.') + expect(result.update).to.deep.equal({ + date: '2026-07-11', + time: '20:52', + }) + }) + + it('accepts an asterisk-wrapped update label and object', () => { const result = parseSceneClockUpdate( - '{"time":1230}' + '**scene_clock_update*{ "date": "15/02/2008", "time": "6:10PM" }*\nThe evening settles over the city.' ) + expect(result.text).to.equal('The evening settles over the city.') + expect(result.update).to.deep.equal({ + date: '15/02/2008', + time: '6:10PM', + }) + }) + + it('rejects non-string field values', () => { + const result = parseSceneClockUpdate('{"time":1230}') + expect(result.text).to.equal('') expect(result.update).to.equal(undefined) }) @@ -42,6 +76,19 @@ describe('Scene Clock assistant updates', () => { expect(result.update).to.deep.equal({ date: '2026-12-25', time: '08:00' }) }) + it('accepts a valid standalone update object at the start of a response', () => { + const result = parseSceneClockUpdate( + '{"date":"2027-01-01","time":"09:30","dayOfWeek":"Friday"}\nA new morning begins.' + ) + + expect(result.text).to.equal('A new morning begins.') + expect(result.update).to.deep.equal({ + date: '2027-01-01', + time: '09:30', + dayOfWeek: 'Friday', + }) + }) + it('does not remove an unrelated JSON object', () => { const response = 'The API returned:\n{"status":"ready"}' const result = parseSceneClockUpdate(response) diff --git a/web/pages/Chat/SceneClockPanel.tsx b/web/pages/Chat/SceneClockPanel.tsx index 6787834c3..095356c0d 100644 --- a/web/pages/Chat/SceneClockPanel.tsx +++ b/web/pages/Chat/SceneClockPanel.tsx @@ -21,17 +21,22 @@ const timeFormats: Array<{ value: AppSchema.SceneClockTimeFormat; label: string ] export const SceneClockPanel: Component<{ chat?: AppSchema.Chat }> = (props) => { - const [clock, setClock] = createStore(getInitSceneClock(props.chat?.sceneClock)) + const chats = chatStore((state) => ({ + chat: props.chat?._id ? state.details[props.chat._id]?.chat : undefined, + })) + const activeChat = () => chats.chat || props.chat + const [clock, setClock] = createStore(getInitSceneClock(activeChat()?.sceneClock)) createEffect(() => { - setClock(getInitSceneClock(props.chat?.sceneClock)) + setClock(getInitSceneClock(activeChat()?.sceneClock)) }) const save = () => { - if (!props.chat) return + const chat = activeChat() + if (!chat) return chatStore.editChat( - props.chat._id, + chat._id, { sceneClock: { ...clock, @@ -49,7 +54,7 @@ export const SceneClockPanel: Component<{ chat?: AppSchema.Chat }> = (props) => } return ( - +