Skip to content

Commit 303b8aa

Browse files
idoubiclaude
andcommitted
fix(timezone): resolve the chatter's timezone from USER.md, infer it proactively
A UTC pod with no stored timezone rendered every message prefix and the "current date/time" line in UTC, so the model narrated "good morning" at 16:12 Beijing time. Fix both the read and write sides: - read (deterministic): chatterLocation now parses the chatter's USER.md first (then DB prefs, then UTC). New scope.LocationFromText understands IANA names, UTC/GMT offsets, bare ±HH:MM, Chinese 东N区/西N区, and a few named zones — so an explicit timezone in the profile drives the clock with no model involvement. - write (inference): the system-prompt date directive now tells the agent to infer the timezone from signals (city, region, language — e.g. a Chinese speaker who mentions 浦东 is Asia/Shanghai) and record it in USER.md, instead of only reacting to an explicit statement. Together: the model populates USER.md, the parser reads it back, and from the next turn timestamps auto-convert to the chatter's local time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 583a1a7 commit 303b8aa

4 files changed

Lines changed: 272 additions & 1 deletion

File tree

internal/agent/context.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,10 @@ func (cb *ContextBuilder) BuildSystemPromptAs(chatterUID string, chatterMem *Mem
325325
"Each past user message in the history is prefixed with its own send time in [brackets] (e.g. [2026-06-13 22:15 Fri]). "+
326326
"Reason about time from NOW and those prefixes: tell today apart from earlier days (never treat a past day's events as today's), "+
327327
"and before ANY time-of-day remark check NOW — e.g. don't say \"good night\" in the middle of the day. "+
328-
"If the chatter states a timezone or local time that disagrees with the above, call set_timezone to correct it.",
328+
"If the timezone above looks wrong for this chatter — it reads UTC but their city, region, or language place them elsewhere "+
329+
"(e.g. someone writing in Chinese who mentions 浦东 is in Asia/Shanghai) — infer their real timezone, apply it when you talk "+
330+
"about time this turn, and record it in their USER.md profile (e.g. a `Timezone: Asia/Shanghai` line) so future timestamps "+
331+
"convert automatically. Do the same when they state a timezone outright. Never invent a timezone without a real signal.",
329332
now.Format("2006-01-02 15:04:05 -0700"), wd, now.Location().String())
330333

331334
switch mode {

internal/agent/loop.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3056,6 +3056,19 @@ func (a *Agent) WorkspacePath() string {
30563056
// chatter's wall clock; the cron tool runs the same resolution at
30573057
// job-creation time.
30583058
func (a *Agent) chatterLocation(chatterUID string) *time.Location {
3059+
// USER.md is the chatter-authoritative source: the deployment clock is
3060+
// UTC and inbound timestamps are UTC, so the only place the chatter's
3061+
// real timezone lives is what they (or the agent) recorded in their
3062+
// profile — "东八区", "UTC+8", "Asia/Shanghai". Parse it and let it win
3063+
// over the DB prefs, so editing USER.md is enough to fix the clock
3064+
// without also having to run set_timezone.
3065+
if a.memory != nil {
3066+
if profile := a.memory.WithUserID(chatterUID).LoadUserFile(); profile != "" {
3067+
if loc := scope.LocationFromText(profile); loc != nil {
3068+
return loc
3069+
}
3070+
}
3071+
}
30593072
if a.dataStore == nil {
30603073
return time.Local
30613074
}

internal/scope/parse_timezone.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package scope
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"strconv"
7+
"strings"
8+
"time"
9+
)
10+
11+
// LocationFromText extracts a timezone from free-form profile text (the
12+
// chatter's USER.md, where they record their timezone in their own
13+
// words) and returns the matching *time.Location, or nil when the text
14+
// contains no recognizable timezone.
15+
//
16+
// The deployment clock is UTC and inbound message timestamps are UTC;
17+
// USER.md is the only place the chatter's real timezone lives, so this
18+
// is what lets the agent render "now" and per-message timestamps in the
19+
// chatter's local time. Recognized forms, in priority order (most
20+
// explicit first):
21+
//
22+
// 1. an IANA name token — "Asia/Shanghai", "America/New_York"
23+
// (validated with time.LoadLocation, so a stray "他/她" never matches)
24+
// 2. a UTC/GMT offset — "UTC+8", "GMT+08:00", "UTC-5"
25+
// 3. a bare signed HH:MM offset — "+08:00", "-05:30"
26+
// 4. a Chinese zone — "东八区" / "西五区" (东 = east/ahead, 西 = west/behind)
27+
// 5. a named zone — "北京时间" / "中国标准时间" / "Beijing time"
28+
//
29+
// Offset and Chinese forms become a fixed-offset zone (no DST — that's
30+
// the correct semantics for "the user said UTC+8"); named zones resolve
31+
// to their IANA location so DST still applies.
32+
func LocationFromText(text string) *time.Location {
33+
if strings.TrimSpace(text) == "" {
34+
return nil
35+
}
36+
if loc := ianaFromText(text); loc != nil {
37+
return loc
38+
}
39+
if loc := offsetFromText(text); loc != nil {
40+
return loc
41+
}
42+
if loc := chineseZoneFromText(text); loc != nil {
43+
return loc
44+
}
45+
return namedZoneFromText(text)
46+
}
47+
48+
// ianaRe matches a "Region/City" token (optionally a three-segment name
49+
// like "America/Argentina/Salta"). Validation against the tz database is
50+
// what filters out non-timezone slashes.
51+
var ianaRe = regexp.MustCompile(`\b([A-Za-z]+(?:_[A-Za-z]+)*(?:/[A-Za-z]+(?:_[A-Za-z]+)*){1,2})\b`)
52+
53+
func ianaFromText(text string) *time.Location {
54+
for _, m := range ianaRe.FindAllStringSubmatch(text, -1) {
55+
// "UTC" is handled by the offset branch; skip obvious non-zones
56+
// fast, but ultimately LoadLocation is the gate.
57+
if loc, err := time.LoadLocation(m[1]); err == nil {
58+
return loc
59+
}
60+
}
61+
return nil
62+
}
63+
64+
// offsetRe matches "UTC+8", "GMT+08:00", "UTC-5", and bare "+08:00".
65+
// The UTC/GMT prefix is optional, but a bare offset must carry a colon
66+
// (HH:MM) so we don't grab unrelated numbers like "+8 points".
67+
var offsetRe = regexp.MustCompile(`(?i)(UTC|GMT)?\s*([+-])\s*(\d{1,2})(?::?([0-5]\d))?`)
68+
69+
func offsetFromText(text string) *time.Location {
70+
for _, m := range offsetRe.FindAllStringSubmatch(text, -1) {
71+
prefix := m[1]
72+
hasMinutes := m[4] != ""
73+
// Bare sign+digits with no UTC/GMT prefix and no minutes is too
74+
// ambiguous (could be any signed number) — require either the
75+
// prefix or an explicit HH:MM.
76+
if prefix == "" && !hasMinutes {
77+
continue
78+
}
79+
hours, _ := strconv.Atoi(m[3])
80+
if hours > 14 { // max real UTC offset is +14
81+
continue
82+
}
83+
mins := 0
84+
if hasMinutes {
85+
mins, _ = strconv.Atoi(m[4])
86+
}
87+
secs := hours*3600 + mins*60
88+
if m[2] == "-" {
89+
secs = -secs
90+
}
91+
return fixedZone(secs)
92+
}
93+
return nil
94+
}
95+
96+
// chineseZoneRe matches "东八区" / "西五区" / "东十二区" etc. 东 (east) is
97+
// ahead of UTC, 西 (west) is behind.
98+
var chineseZoneRe = regexp.MustCompile(`([东西])([一二三四五六七八九十]{1,3}|\d{1,2})区`)
99+
100+
func chineseZoneFromText(text string) *time.Location {
101+
m := chineseZoneRe.FindStringSubmatch(text)
102+
if m == nil {
103+
return nil
104+
}
105+
n := parseCJKNumeral(m[2])
106+
if n < 0 || n > 12 {
107+
return nil
108+
}
109+
secs := n * 3600
110+
if m[1] == "西" {
111+
secs = -secs
112+
}
113+
return fixedZone(secs)
114+
}
115+
116+
// namedZones maps a few well-known colloquial timezone names to IANA
117+
// locations. Kept short on purpose — broad city-name matching invites
118+
// false positives; the model is expected to write an IANA name or an
119+
// offset for anything exotic.
120+
var namedZones = map[string]string{
121+
"北京时间": "Asia/Shanghai",
122+
"中国标准时间": "Asia/Shanghai",
123+
"中国时间": "Asia/Shanghai",
124+
"beijing": "Asia/Shanghai",
125+
}
126+
127+
func namedZoneFromText(text string) *time.Location {
128+
lower := strings.ToLower(text)
129+
for name, iana := range namedZones {
130+
if strings.Contains(text, name) || strings.Contains(lower, name) {
131+
if loc, err := time.LoadLocation(iana); err == nil {
132+
return loc
133+
}
134+
}
135+
}
136+
return nil
137+
}
138+
139+
// fixedZone builds a fixed-offset *time.Location with a readable name
140+
// like "UTC+08:00" / "UTC-05:30" / "UTC".
141+
func fixedZone(offsetSecs int) *time.Location {
142+
name := "UTC"
143+
if offsetSecs != 0 {
144+
sign := "+"
145+
s := offsetSecs
146+
if s < 0 {
147+
sign = "-"
148+
s = -s
149+
}
150+
name = fmt.Sprintf("UTC%s%02d:%02d", sign, s/3600, (s%3600)/60)
151+
}
152+
return time.FixedZone(name, offsetSecs)
153+
}
154+
155+
// parseCJKNumeral parses 1–2 digit Arabic numbers and Chinese numerals
156+
// up to 十二 (12). Returns -1 on anything it can't read.
157+
func parseCJKNumeral(s string) int {
158+
if n, err := strconv.Atoi(s); err == nil {
159+
return n
160+
}
161+
digits := map[rune]int{'一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9}
162+
runes := []rune(s)
163+
switch {
164+
case len(runes) == 1 && runes[0] == '十':
165+
return 10
166+
case len(runes) == 1:
167+
if d, ok := digits[runes[0]]; ok {
168+
return d
169+
}
170+
case len(runes) == 2 && runes[0] == '十': // 十一, 十二
171+
if d, ok := digits[runes[1]]; ok {
172+
return 10 + d
173+
}
174+
case len(runes) == 2 && runes[1] == '十': // 二十 (unlikely for zones)
175+
if d, ok := digits[runes[0]]; ok {
176+
return d * 10
177+
}
178+
}
179+
return -1
180+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package scope
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
// offsetOf returns the seconds-east-of-UTC a location applies on a fixed
9+
// reference instant (2026-06-14, a date with no half-hour-DST oddities
10+
// for the zones tested). Comparing offsets is more robust than comparing
11+
// Location.String(), which differs between FixedZone and IANA zones.
12+
func offsetOf(loc *time.Location) int {
13+
ref := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
14+
_, off := ref.In(loc).Zone()
15+
return off
16+
}
17+
18+
func TestLocationFromText(t *testing.T) {
19+
cases := []struct {
20+
name string
21+
text string
22+
wantNil bool
23+
wantOffset int // seconds east of UTC, when !wantNil
24+
}{
25+
{"iana shanghai", "时区: Asia/Shanghai", false, 8 * 3600},
26+
{"iana ny dst", "I live in America/New_York", false, -4 * 3600}, // EDT in June
27+
{"utc plus 8", "my timezone is UTC+8", false, 8 * 3600},
28+
{"gmt colon", "GMT+08:00 here", false, 8 * 3600},
29+
{"utc minus 5", "UTC-5 east coast", false, -5 * 3600},
30+
{"bare hhmm", "offset +08:00", false, 8 * 3600},
31+
{"bare half hour", "-05:30 somewhere", false, -5*3600 - 30*60},
32+
{"chinese east 8", "用户在东八区", false, 8 * 3600},
33+
{"chinese arabic", "东8区", false, 8 * 3600},
34+
{"chinese west 5", "我在西五区", false, -5 * 3600},
35+
{"chinese east 12", "东十二区", false, 12 * 3600},
36+
{"named beijing zh", "默认北京时间", false, 8 * 3600},
37+
{"named beijing en", "uses Beijing time", false, 8 * 3600},
38+
39+
// No timezone present / must not false-positive.
40+
{"empty", "", true, 0},
41+
{"prose with slash", "她/他 喜欢喝咖啡", true, 0},
42+
{"bare plus no colon", "得了 +8 分", true, 0},
43+
{"unrelated", "name: Alice, job: accountant", true, 0},
44+
}
45+
for _, c := range cases {
46+
loc := LocationFromText(c.text)
47+
if c.wantNil {
48+
if loc != nil {
49+
t.Errorf("%s: LocationFromText(%q) = %v, want nil", c.name, c.text, loc)
50+
}
51+
continue
52+
}
53+
if loc == nil {
54+
t.Errorf("%s: LocationFromText(%q) = nil, want offset %d", c.name, c.text, c.wantOffset)
55+
continue
56+
}
57+
if got := offsetOf(loc); got != c.wantOffset {
58+
t.Errorf("%s: LocationFromText(%q) offset = %d, want %d", c.name, c.text, got, c.wantOffset)
59+
}
60+
}
61+
}
62+
63+
// The reported bug: a UTC pod renders an 08:12 UTC timestamp, but the
64+
// chatter's USER.md says 东八区, so the resolved instant must read 16:12.
65+
func TestUserMDEastEightConvertsAfternoon(t *testing.T) {
66+
loc := LocationFromText("基本信息\n- 时区:东八区\n- 职业:会计")
67+
if loc == nil {
68+
t.Fatal("expected a location from 东八区")
69+
}
70+
utc := time.Date(2026, 6, 14, 8, 12, 0, 0, time.UTC)
71+
local := utc.In(loc)
72+
if h, m := local.Hour(), local.Minute(); h != 16 || m != 12 {
73+
t.Fatalf("08:12 UTC in 东八区 = %02d:%02d, want 16:12", h, m)
74+
}
75+
}

0 commit comments

Comments
 (0)