Repository navigation
Description
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:
- Add a config key (e.g.
timezone: local,TZ, orstore_local_time: true)
that switches the stamping function todatetime.now().astimezone()and records
the offset (or writes offset-aware timestamps). - 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
dplush commentedon Oct 5, 2026
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.
LostInInaka commentedon Nov 3, 2026
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-memory3.15.1,TZ=Asia/Tokyo(UTC+9). No Windows, no Hermes-host involvement.
created_atis indeed naive UTC here too. The part I'd add:timestampin thesame row is not UTC — it is host-local wall-clock, so a single row carries two
clocks nine hours apart for the same instant.
Across the profile: 3,559 working-memory rows carry both columns, and the median
timestamp − created_atgap 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).
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 thesame writer stamps
timestampin local time, so this is not a consumer failingto 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 + 9hand a literal are indistinguishable oncewritten, exactly as the report says of
created_at.Downstream it is not just display.
recall()scores recency asexp(-age_hours / WORKING_MEMORY_TTL_HOURS)after parsing the stored string asUTC. With
timestampnine hours ahead of true UTC, a row written now computesan age of −9 h, so the decay factor exceeds 1:
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:
timestampthe same way ascreated_at(naive UTC, or offset-aware —either is fine as long as one row has one clock).
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 at0.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_atandvalid_untilmixing local wall-clock with SQLite UTC comparisons — same class,different column. This is that class on the field
recall()actually scores.dplush commentedon Nov 3, 2026
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.
LostInInaka commentedon Nov 4, 2026
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.0549at UTC+9, a bonus). On a UTC− host the same skew makes the row look older,so it produces an unearned penalty instead (
0.9478freshly written at UTC−5). Same bug, oppositedirection — 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_atdistinguishes them:timestamp − created_atA 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_utcis shared with
_parse_query_time, whose documented contract is "naive values are treated as UTC"._parse_query_time("2026-11-01T00:00:00")returned2026-10-31T15:00:00+00:00. Pinningnaive_is_local=Falseat 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_decaycan return > 1.0, and it enters the score as a multiplier, so the skewrewards the misdated row rather than merely failing to penalise it. Clamping to
1.0is soundregardless of the reader question (an age cannot be negative), costs one
min(), and does not repairor 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
Observed at
TZ=Asia/Tokyo:decay=1.0550for a fresh legacy row; blanket reader fixes legacy rows to1.0000but shifts the UTC-written row to0.9478(5.2% relative); the gap test yields1.0000forboth.
Happy to fold any of this into a docs note instead if that is the preferred shape.
dplush commentedon Nov 4, 2026
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.