Skip to content

Chat-loop: tell the model how long since its last reply#32

Merged
sysread merged 1 commit into
mainfrom
claude/add-message-timestamps-M7Wo1
May 12, 2026
Merged

Chat-loop: tell the model how long since its last reply#32
sysread merged 1 commit into
mainfrom
claude/add-message-timestamps-M7Wo1

Conversation

@sysread
Copy link
Copy Markdown
Owner

@sysread sysread commented May 12, 2026

SYNOPSIS

Extend the per-turn <datetime> tag with a since_last_response="..." attribute so the model can tell a live continuation from a thread the user revived hours or days later.

PURPOSE

The chat-loop already injects <datetime local="..." utc="..." zone="..." /> outside the <user_message> fence, so the model can answer "what year is it?" without guessing. It still has no signal for whether the user is mid-thought ("you just answered") or reviving an old thread ("about 3 days have passed"), and the user shouldn't have to spell that out every turn. Without it the model picks the wrong register on revival turns - charging straight back into the conversation when a brief reorientation would land better.

DESCRIPTION

Existing: buildDatetimeTag(tz) in chat-loop.ts builds the <datetime> tag once per round, formatted in the user's journalTimezone with local / utc / zone attributes. tagLastUserMessage prepends the tag outside the <user_message> fence on the latest user turn. The system prompt's DATETIME_BLOCK teaches the model that the tag is authoritative for clock questions.

Changed:

  • new formatRelativeDuration(elapsedMs) - coarse, bucketed elapsed-time formatter: just now (<2min) / a few minutes / about N minutes / about an hour / about N hours / yesterday / about N days / about N weeks / about N months / over a year. Negative / NaN inputs collapse to "just now" so server-vs-browser clock skew doesn't surface as "in 4 seconds".
  • buildDatetimeTag(tz, lastAssistantTimestamp?) picks up an optional anchor; on a parseable timestamp it appends since_last_response="..." to the existing tag. Omitted otherwise, so opening turns and unparseable timestamps ship the unchanged 3-attribute tag.
  • ChatLoopOptions.lastAssistantTimestamp? plumbs the anchor in. Chat.svelte walks its messages array from the end for the latest role==='assistant' row that isn't in pendingDeleteSet (regenerate-from-here rows are about to be replaced and shouldn't count as "your last reply") and passes its created_at. Recomputed at call time so an auto-retry after a 429 picks up assistant rows persisted on earlier rounds.
  • DATETIME_BLOCK in chat-prompt.ts gains a paragraph teaching the model to read the new attribute as a register-calibration signal, with explicit guardrails: do NOT quote the elapsed string back at the user verbatim, do NOT thank them for the gap.

How it closes PURPOSE: mid-thread turns now carry e.g. <datetime ... since_last_response="about 22 hours" />, so the model can decide between picking up mid-thought and re-orienting briefly without the user having to flag the gap. Synthetic ephemeral injections (intuition / context-recall <think> blocks) aren't persisted and therefore not eligible anchors - the semantic is "how long since you last actually replied to the user?", not "since any assistant-role row appeared on the wire."

Notes:

  • Tag is recomputed every round (matching the existing buildDatetimeTag recompute), not once at send-time, so a 30s+ multi-tool turn doesn't ship a stale value.
  • Attribute is omitted on opening turn (no prior assistant) and on unparseable timestamps, so a fresh thread never carries a misleading "just now". Existing chat-loop tests that anchor on the bare <datetime ... /> tag continue to pass unchanged.
  • Buckets are intentionally fuzzy. The model uses this for register, not arithmetic, and "yesterday" / "about 3 days" obviously read as a stale-thread revival.
  • Pure additive change to the wire shape; no DB schema change, no user-visible UI change. Updated docs/dev/chat.md accordingly; no docs/user/ change needed.

Test plan

  • mise run check green (1069 tests, 0 svelte-check errors, 0 lint warnings)
  • new describe('formatRelativeDuration', ...) block covers every bucket boundary including the singular "about an hour" branch, negative-ms clock skew, and NaN
  • new chat-loop integration tests cover the three observable shapes: omitted on opening turn, present with bucketed value when anchored, omitted when the anchor doesn't parse
  • manual smoke: open a thread, reply on it, leave it overnight, send another message, confirm the model's tone shifts toward reorientation rather than picking up mid-stream

Generated by Claude Code

The per-turn <datetime> tag already gives the model a clock so it
can answer "what year is it?" without guessing. It didn't tell the
model whether the user is mid-thought ("you just answered") or
reviving a thread from days ago - the model has no way to read that
from the timestamps alone, and the user shouldn't have to spell it
out every time.

Extend the tag with an optional since_last_response="..." attribute
carrying a coarse, conversational elapsed string ("about 22 hours",
"yesterday", "about 3 days") computed against the most recent
persisted assistant message's created_at. Chat.svelte walks its
messages array for the latest role==='assistant' row that isn't
marked for regenerate-from-here, and passes the timestamp through
as a new ChatLoopOptions.lastAssistantTimestamp. The chat-loop
recomputes the elapsed bucket every round (matching the existing
datetime-tag recomputation) so a long multi-tool turn doesn't ship
a stale value.

Buckets are intentionally fuzzy: the model uses this to calibrate
register, not to do arithmetic, and "yesterday" / "about 3 days"
obviously read as a stale-thread revival while "a few minutes" /
"just now" read as a live continuation. Synthetic ephemeral
injections (intuition / context-recall <think> blocks) are not
persisted and therefore not eligible anchors - the semantic is
"how long since you last actually replied to the user?". The
attribute is omitted on the opening turn (no prior assistant to
anchor against) and when the supplied timestamp doesn't parse, so
a fresh thread never carries a misleading "just now".

The system prompt's datetime block gains a paragraph teaching the
model how to read the attribute and explicitly cautioning against
quoting it back at the user verbatim or thanking them for the gap.
@sysread sysread merged commit 9815d6c into main May 12, 2026
1 check passed
@sysread sysread deleted the claude/add-message-timestamps-M7Wo1 branch May 12, 2026 03:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants