Skip to content

Memory timestamps stored as naive UTC with no timezone option — breaks today/yesterday reasoning in UTC+ regions #1127

Open

Description

@lyz78101-Tony

Memory timestamps stored in UTC with no timezone option — breaks "today/yesterday" reasoning in UTC+ regions

Summary

working_memory.created_at (and timestamp) are stored as naive UTC, so every
timestamp is exactly the local offset behind (e.g. 8 hours for China Standard Time,
UTC+8). There is no configuration switch to store local time or to set a timezone.

The practical consequence: conversations that happen between 00:00 and 08:00 local
time are recorded with the previous day's date.
An agent reading those timestamps
to answer "what happened today" or "is this within the last 48 hours" will reason
against the wrong day.

Reproduction

Measured on a China Standard Time machine (UTC+8), all timestamps in one profile:

local time (datetime.now())   : 2026-10-05 20:41:40
UTC      (datetime.utcnow())  : 2026-10-05 12:41:40
newest row's created_at       : 2026-10-05 12:36:18   ← equals UTC

Every row carries UTC: out of 324 rows, 324/324 satisfy created_at + 8h <= now.

Because the library stores naive datetimes with no offset, a consumer cannot even
detect this from the data — created_at + 8h and the literal are indistinguishable
once written.

Where it comes from

hermes_memory_provider/__init__.py stamps records with UTC and offers no alternative:

cutoff = (
    datetime.now(timezone.utc).replace(tzinfo=None)
    - timedelta(hours=WORKING_MEMORY_TTL_HOURS // 2)
).strftime("%Y-%m-%d %H:%M:%S")

Grepping the packaged config (mnemosyne/core/config.py) and the exported
config.yaml (121 keys) turns up no timezone, utc, or local_time key.

Why it matters beyond bookkeeping

Agents consuming this memory routinely ask "what did we discuss today" or "is this
news within the 48h window". With a systematic −8h offset:

  • Anything written after 00:00 local but before 08:00 local lands on the previous
    calendar day.
  • Comparing a recalled timestamp against a live datetime.now() without shifting
    yields answers that are wrong by 8 hours — often enough to flip a date boundary.

Downstream tooling has to re-apply the offset manually every time, which is exactly
the kind of hidden state that produces confident, wrong "yesterday" answers.

Suggested fix

Either of these would resolve it; the first is less disruptive:

  1. Add a config key (e.g. timezone: local, TZ, or store_local_time: true)
    that switches the stamping function to datetime.now().astimezone() and records
    the offset (or writes offset-aware timestamps).
  2. Store timezone-aware timestamps and keep UTC internally, so consumers can
    recover the local wall-clock time without guessing the host's zone.

A migration for already-written rows is a separate concern — existing rows cannot
be disambiguated after the fact, so a fix would apply going forward.

Environment

mnemosyne-hermes 0.7.3
mnemosyne-memory 4.0.0b3
Hermes Agent v0.21.5+5337.ge8c9732 (2026.9.24)
Windows 11 · Python 3.11.16 · TZ = China Standard Time (UTC+8)

How to confirm on your own install

import datetime, sqlite3, os
db = os.path.join(os.environ["LOCALAPPDATA"], "hermes", "mnemosyne", "data", "mnemosyne.db")
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
row = con.execute("SELECT created_at FROM working_memory ORDER BY created_at DESC LIMIT 1").fetchone()
print("local:", datetime.datetime.now())
print("dbUTC:", row[0] if row else "(empty)")

A consistent gap equal to your UTC offset confirms the same behaviour.

Activity

  1. added
    needs-triageNew issue awaiting maintainer triage for evidence, scope, ownership, and priority.
    on Oct 5, 2026
  2. dplush commented on Oct 5, 2026

    @dplush
    Collaborator

    Triage: decision_required for a new timezone/local-day contract; UTC-only display clarification is ready_to_implement separately. Independent Linux UTC+8 runs on main/b4 and b3 with provider 0.7.3 confirm naive UTC storage and zone-less prefetch/recall output. A local 00:30 event displays the previous UTC date. The quoted cutoff is a reader, not the writer; normalized age is correctly 47h, not the 55h produced by mixing local-naive and UTC. No core TTL or actual LLM reasoning failure was established.

    I recommend preserving UTC persistence, comparisons and raw API strings. The narrow consumer fix should normalize valid naive/Z/offset timestamps before visibly labelling UTC; malformed values must not gain an invented zone. Local-day behavior needs an explicit user-zone preference and UTC-converted day boundaries with DST coverage, not host-zone inference or local-time storage.

    Next: I can drive the bounded display clarification and separately settle consumer-zone preference; AJ is needed only for affected core semantics. Test both renderers/provider, offsets and raw/TTL compatibility. No migration, +8h backfill or full Windows/Hermes-host certification.

  3. added
    needs-decisionMaintainer decision or contract clarification required before implementation or merge.
    and removed
    needs-triageNew issue awaiting maintainer triage for evidence, scope, ownership, and priority.
    on Oct 5, 2026
  4. LostInInaka commented on Nov 3, 2026

    @LostInInaka
    Contributor

    Second platform confirms storage, and narrows where it goes wrong

    Confirming on a different platform from the report, and adding one detail that
    bears on the triage above.

    Environment: Linux, Python 3.14.9, mnemosyne-memory 3.15.1, TZ=Asia/Tokyo
    (UTC+9). No Windows, no Hermes-host involvement.

    created_at is indeed naive UTC here too. The part I'd add: timestamp in the
    same row is not UTC
    — it is host-local wall-clock, so a single row carries two
    clocks nine hours apart for the same instant.

    rowid=3723  created_at=2026-11-03 02:49:44      timestamp=2026-11-03T11:49:44.600909
    rowid=3722  created_at=2026-11-03 02:47:25      timestamp=2026-11-03T11:47:25.956251
    

    Across the profile: 3,559 working-memory rows carry both columns, and the median
    timestamp − created_at gap is 9.00 h — exactly the host's UTC offset. 68.4%
    of rows sit within 8.5–9.5 h (the remainder predate the current write path).

    SELECT rowid, created_at, timestamp FROM working_memory
     WHERE created_at IS NOT NULL AND timestamp IS NOT NULL
     ORDER BY rowid DESC LIMIT 3;
    

    Why this matters for the triage

    @dplush's point that "the quoted cutoff is a reader, not the writer" is correct
    for created_at, and I agree UTC should stay the persistence contract. But the
    same writer stamps timestamp in local time, so this is not a consumer failing
    to normalize a well-formed UTC string — it is the writer producing two
    incompatible clocks in one row. A reader cannot normalize that: the value is
    naive either way, and timestamp + 9h and a literal are indistinguishable once
    written, exactly as the report says of created_at.

    Downstream it is not just display. recall() scores recency as
    exp(-age_hours / WORKING_MEMORY_TTL_HOURS) after parsing the stored string as
    UTC. With timestamp nine hours ahead of true UTC, a row written now computes
    an age of −9 h, so the decay factor exceeds 1:

    row just written:  timestamp = 2026-11-03T10:41:49   (local, true UTC 01:41:49)
    parsed as UTC    :  age = -9 h
    recency_decay    :  exp(9/168) = 1.0549     ← > 1, "younger than now"
    

    That is a negative age producing a ranking bonus, not a date label off by one
    day — it feeds the vector/FTS/importance fusion directly, and it is systematic
    for every row written in a UTC+N zone.

    Proposed fix, within the contract @dplush described

    Keep UTC persistence and UTC comparisons; make the writer honest about it:

    1. Stamp timestamp the same way as created_at (naive UTC, or offset-aware —
      either is fine as long as one row has one clock).
    2. Until (1) lands, the reader should not infer UTC for a naive timestamp;
      treat naive as host-local and convert, or refuse and surface the ambiguity.
      I ran (2) as a one-function patch and the decay for a just-written row goes
      1.0549 → 1.0000, with a genuinely 9 h-old row correctly at 0.9478 = exp(-9/168).

    I have both a minimal reader patch and a reproduction script (pins the >1 decay
    and the two-clock row) if useful. Happy to open a PR against whichever of (1) or
    (2) the maintainers prefer — (1) alone would be the cleaner fix, (2) is the
    stopgap for rows already written the wrong way.


    Related but distinct: #1094 and closed #525 / #1062 cover consolidated_at and
    valid_until mixing local wall-clock with SQLite UTC comparisons — same class,
    different column. This is that class on the field recall() actually scores.

  5. dplush commented on Nov 3, 2026

    @dplush
    Collaborator

    Thanks for the concrete reproduction. You are right that this is more than a display issue on 3.15.1; my earlier b3/Main check should not be generalized to that older release.

    I reproduced it independently using the actual PyPI 3.15.1 wheel, a fresh synthetic database and TZ=Asia/Tokyo: real BeamMemory.remember() writes a local-naive timestamp approximately nine hours ahead of SQLite's UTC created_at. The release's own _recency_decay() interprets it as UTC and returns about 1.055 for the newly written row. Its default denominator is RECENCY_HALFLIFE_HOURS=168, rather than the working-memory TTL setting. This confirms the invalid recency multiplier; I have not reproduced your private corpus's final ranking impact.

    The same fresh-write probe on current Main e1f34fb produces matching UTC timestamps and decay approximately 1.0. Main already uses naive-UTC producer stamps, so a PR should not duplicate that writer correction against the old release.

    The remaining historical-data problem is real: Main's corrected writer does not identify or repair previously stored local-naive values. Treating every naive timestamp as host-local would also shift correctly written UTC rows, and the current host timezone may differ from the writer's historical timezone. I would keep UTC parsing for the established contract and handle legacy repair separately, using explicit provenance/timezone information and a bounded, opt-in migration design rather than a blanket reader reinterpretation. A small synthetic reproduction script would be useful for pinning the legacy-write/current-read regression without sharing profile data.

    That leaves three distinct tracks: the old-release writer defect (already corrected on Main), ambiguous historical rows, and the separate UTC-label/user-local-day presentation policy discussed above.

  6. LostInInaka commented on Nov 4, 2026

    @LostInInaka
    Contributor

    Synthetic reproduction, and the failure mode a blanket reader fix introduces

    Following @dplush's request for a small reproduction of the legacy-write / current-read regression that
    does not require sharing profile data. Self-contained, stdlib only, no database, no embedding model;
    exits non-zero on failure so it doubles as a regression test. Run it as
    TZ=Asia/Tokyo python3 repro-legacy-timestamp.py.

    Two things it pins that I had not stated clearly enough before:

    1. The sign of the defect depends on the host's offset. The report is correct that a UTC+ host sees
    decay > 1.0 (1.0549 at UTC+9, a bonus). On a UTC− host the same skew makes the row look older,
    so it produces an unearned penalty instead (0.9478 freshly written at UTC−5). Same bug, opposite
    direction — worth stating in the docs, since a reader in the Americas testing for ">1.0" would conclude
    it does not affect them.

    2. Repairing it in the reader breaks the rows that were already right. On this store both producer
    generations coexist, and created_at distinguishes them:

    rows timestamp − created_at written as
    2,611 +9.00 h naive LOCAL wall time (pre-fix producer)
    1,121 0.00 h naive UTC (post-fix producer)
    4 other retimed / backdated

    A blanket "naive means local" reader fixes the 2,611 and shifts the 1,121 by the host offset — a
    5.2% relative error on every decay multiplier for the second group, i.e. the same class of error
    inverted rather than removed. I applied that blanket fix first, measured it, and reverted to a per-row
    decision against created_at (gap ≈ 0 → already UTC; gap ≈ +offset → local).

    One thing I did not expect: the blanket reader also shifted query_time, because _parse_iso_datetime_utc
    is shared with _parse_query_time, whose documented contract is "naive values are treated as UTC".
    _parse_query_time("2026-11-01T00:00:00") returned 2026-10-31T15:00:00+00:00. Pinning
    naive_is_local=False at those two call sites restored the documented behaviour.

    3. A related, smaller issue — offered separately, not as part of this. Independently of which clock
    contract wins, _recency_decay can return > 1.0, and it enters the score as a multiplier, so the skew
    rewards the misdated row rather than merely failing to penalise it. Clamping to 1.0 is sound
    regardless of the reader question (an age cannot be negative), costs one min(), and does not repair
    or reinterpret anything. That is now up as #1329 with tests, so it can be judged on its own merits — it
    does not depend on this thread's decision.

    repro-legacy-timestamp.py
    #!/usr/bin/env python3
    """Synthetic reproduction: legacy local-naive writes read as UTC.
    
    No profile data, no database, no embedding model — just the two column values and the decay
    function, so it can be run anywhere:
    
        TZ=Asia/Tokyo python3 repro-legacy-timestamp.py
        TZ=America/New_York python3 repro-legacy-timestamp.py   # any non-UTC zone
    """
    import datetime as dt
    import math
    import sys
    
    HALFLIFE_H = 168.0  # RECENCY_HALFLIFE_HOURS
    NOW = dt.datetime.now(dt.timezone.utc)
    
    
    def decay(ts):
        age_h = (NOW - ts).total_seconds() / 3600.0
        return math.exp(-age_h / HALFLIFE_H)
    
    
    def parse_as_utc(naive_str):
        return dt.datetime.fromisoformat(naive_str).replace(tzinfo=dt.timezone.utc)
    
    
    def parse_as_local(naive_str):
        return dt.datetime.fromisoformat(naive_str).astimezone().astimezone(dt.timezone.utc)
    
    
    def parse_gap_aware(naive_str, created_at_utc, slack_h=1.0):
        naive = dt.datetime.fromisoformat(naive_str)
        ca = dt.datetime.fromisoformat(created_at_utc).replace(tzinfo=dt.timezone.utc)
        gap_h = (naive.replace(tzinfo=dt.timezone.utc) - ca).total_seconds() / 3600.0
        if abs(gap_h) <= slack_h:
            return naive.replace(tzinfo=dt.timezone.utc)
        return naive.astimezone().astimezone(dt.timezone.utc)
    
    
    def write_legacy(age_hours):
        created_utc = NOW - dt.timedelta(hours=age_hours)
        stored = dt.datetime.now().replace(microsecond=0) - dt.timedelta(hours=age_hours)
        return stored.isoformat(), created_utc.replace(tzinfo=None).isoformat()
    
    
    def write_current(age_hours):
        created_utc = (NOW - dt.timedelta(hours=age_hours)).replace(tzinfo=None)
        return created_utc.isoformat(), created_utc.isoformat()
    
    
    tzname = dt.datetime.now().astimezone().tzname()
    off = dt.datetime.now().astimezone().utcoffset()
    offset_h = (off.total_seconds() / 3600.0) if off else 0.0
    print(f"host timezone: {tzname} (UTC{offset_h:+.1f}), halflife {HALFLIFE_H:.0f}h\n")
    
    if abs(offset_h) < 0.1:
        print("NOTE: UTC host — the defect cannot reproduce here. Re-run with a non-UTC TZ.")
    
    print("1. fresh legacy write: timestamp is local wall-clock, created_at is UTC")
    ts_l, ca_l = write_legacy(0.0)
    gap = (dt.datetime.fromisoformat(ts_l) - dt.datetime.fromisoformat(ca_l)).total_seconds() / 3600.0
    print(f"   timestamp={ts_l}  created_at={ca_l}  gap={gap:+.2f}h")
    
    print("\n2. reader assumes naive == UTC (documented contract)")
    d_utc = decay(parse_as_utc(ts_l))
    direction = "bonus (appears in the FUTURE)" if offset_h > 0 else "penalty (appears older)"
    print(f"   decay={d_utc:.4f}  -> {direction}")
    
    print("\n3. a corrected writer stamps naive UTC; both generations coexist")
    ts_c, ca_c = write_current(0.0)
    print(f"   decay={decay(parse_as_utc(ts_c)):.4f}")
    
    print("\n4. a blanket 'naive means local' reader fixes gen-1 and shifts gen-2")
    for age, label in ((0.0, "fresh"), (720.0, "30 days old")):
        ts_l2, _ = write_legacy(age)
        ts_c2, _ = write_current(age)
        want = decay(NOW - dt.timedelta(hours=age))
        got_l, got_c = decay(parse_as_local(ts_l2)), decay(parse_as_local(ts_c2))
        rel = abs(got_c - want) / max(want, 1e-9)
        print(f"   {label:12s} legacy: {got_l:.4f} (want {want:.4f})   utc-written: {got_c:.4f} "
              f"({rel*100:.1f}% relative error)")
    
    print("\n5. the per-row gap test resolves both")
    for age, label in ((0.0, "fresh"), (720.0, "30 days old")):
        ts_l3, ca_l3 = write_legacy(age)
        ts_c3, ca_c3 = write_current(age)
        want = decay(NOW - dt.timedelta(hours=age))
        print(f"   {label:12s} legacy: {decay(parse_gap_aware(ts_l3, ca_l3)):.4f}   "
              f"utc-written: {decay(parse_gap_aware(ts_c3, ca_c3)):.4f}   (want {want:.4f})")

    Observed at TZ=Asia/Tokyo: decay=1.0550 for a fresh legacy row; blanket reader fixes legacy rows to
    1.0000 but shifts the UTC-written row to 0.9478 (5.2% relative); the gap test yields 1.0000 for
    both.

    Happy to fold any of this into a docs note instead if that is the preferred shape.

  7. dplush commented on Nov 4, 2026

    @dplush
    Collaborator

    The UTC+/UTC- distinction is useful and consistent with the legacy local-write/current-UTC-read mechanism. I ran the supplied example under Tokyo, New York, Berlin and UTC and kept current Main's naive query_time contract unchanged: "2026-11-01T00:00:00" still parses as that UTC instant. A blanket local reinterpretation would change that supported meaning as well as shift correctly written UTC rows. I have not verified the private-store counts.

    The timestamp-created_at gap is useful diagnostic evidence, but it is not sufficient provenance for an automatic reader correction. I reproduced a counterexample using real Main BeamMemory.remember() calls: create a row, retain its created_at from nine hours earlier, then remember the same content again. The ordinary dedup update correctly refreshes timestamp to current naive UTC while keeping created_at. That legitimate UTC row now has a +9h gap, exactly the shape used to identify a legacy Tokyo write. The supplied gap reader moves this fresh row nine hours into the past under Tokyo; under New York it moves it five hours into the future.

    There are two narrower issues in the supplied classifier too. The abs(gap)<=1h slack leaves a genuine UTC+1 legacy row uncorrected (Berlin gives decay about 1.006 rather than 1). Every gap outside that slack is otherwise treated as local, without checking that its sign/magnitude matches a known historical writer offset. Retimed/backdated/imported rows and timezone changes need explicit treatment rather than that catch-all branch. The supplied 30-day New York example also crosses a DST boundary; constructing historical local time by subtracting hours from today's naive local clock is not a faithful historical-offset model.

    Finally, this is currently a diagnostic script rather than a failing regression test: it contains no assertions or mismatch exit check. It exits successfully even when the Berlin legacy case remains wrong. Freeze the reference instant, construct each historical local stamp using its timestamp's timezone rules, and assert independent expected instants/decays if it is to become regression coverage.

    I would retain the example as evidence, keep the UTC reader contract, and design any legacy repair as an explicit provenance-aware opt-in migration with ambiguous rows reported rather than guessed. The bounded future-score clamp in #1329 can be assessed separately; it does not repair the UTC-minus penalty or identify historical rows.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    coreneeds-decisionMaintainer decision or contract clarification required before implementation or merge.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions