Skip to content
43 changes: 40 additions & 3 deletions src/basic_memory/indexing/accepted_note_write_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ class AcceptedPreparedEntityTarget(Protocol):
content_type: str
permalink: str | None
file_path: RuntimeFilePath
created_at: datetime
updated_at: datetime
last_updated_by: str | None

Expand Down Expand Up @@ -501,6 +502,30 @@ async def prepare_accepted_note_move(
return result


def _timestamp_from_metadata(
entity_metadata: EntityMetadata, key: str, *, fallback: datetime
) -> datetime:
"""Read a created/modified timestamp from prepared frontmatter — file as source of truth.

BM's write path stamps created/modified into frontmatter as ISO strings (#238/#684), so a
cloud-accepted note's DB timestamp must come from that value rather than the request time;
otherwise the markdown shows one date while search/recent-activity show another. Falls back
to ``fallback`` only when the key is absent — a present-but-malformed value fails fast
(never silently substituted).
"""
value = entity_metadata.get(key) if entity_metadata else None
if value is None:
return fallback
if isinstance(value, datetime):
return value if value.tzinfo else value.astimezone()
if isinstance(value, str):
# A naive date-only value (e.g. "2024-03-15") is assumed local, matching
# EntityParser._frontmatter_timestamp so the cloud and local paths agree.
parsed = datetime.fromisoformat(value)
return parsed if parsed.tzinfo else parsed.astimezone()
return fallback


def apply_accepted_prepared_entity_fields(
entity: AcceptedPreparedEntityTarget,
entity_fields: AcceptedPreparedEntityFields,
Expand All @@ -515,7 +540,16 @@ def apply_accepted_prepared_entity_fields(
entity.content_type = entity_fields.content_type
entity.permalink = entity_fields.permalink
entity.file_path = entity_fields.file_path
entity.updated_at = updated_at
# Honor the note's own timestamps (file is source of truth); the request time is only a
# fallback for modified. created_at is reconciled from frontmatter too — if the file's
# created changed it should win — but defaults to the entity's existing value so a normal
# update never moves it.
entity.created_at = _timestamp_from_metadata(
entity_fields.entity_metadata, "created", fallback=entity.created_at
)
entity.updated_at = _timestamp_from_metadata(
entity_fields.entity_metadata, "modified", fallback=updated_at
)
entity.last_updated_by = user_profile_value


Expand All @@ -528,15 +562,18 @@ def accepted_pending_entity_write_from_prepared(
) -> AcceptedPendingEntityWrite:
"""Map prepared Basic Memory entity fields to the pending entity DB write."""
fields = prepared.entity_fields
# File is the source of truth: seed created_at/updated_at from the note's own frontmatter
# timestamps (#238/#684) so a cloud-accepted note's DB row matches its markdown, with the
# request time only as a fallback when the frontmatter omits them.
return AcceptedPendingEntityWrite(
title=fields.title,
note_type=fields.note_type,
entity_metadata=fields.entity_metadata,
content_type=fields.content_type,
permalink=fields.permalink,
file_path=fields.file_path,
created_at=now,
updated_at=now,
created_at=_timestamp_from_metadata(fields.entity_metadata, "created", fallback=now),
updated_at=_timestamp_from_metadata(fields.entity_metadata, "modified", fallback=now),
created_by=user_profile_value,
last_updated_by=user_profile_value,
external_id=external_id,
Expand Down
29 changes: 25 additions & 4 deletions src/basic_memory/indexing/batch_indexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,16 @@ async def _persist_markdown_file(
session=session,
)
prepared = await self._reconcile_persisted_permalink(prepared, entity)
metadata_updates = self._entity_metadata_updates(prepared.file, prepared.final_checksum)
# Markdown timestamps come from the parsed frontmatter via
# upsert_entity_from_markdown (file-stat fallback), not raw file stats (#238/#684).
metadata_updates = self._entity_metadata_updates(
prepared.file, prepared.final_checksum, include_semantic_timestamps=False
)
# Re-assert the parsed timestamps on this UPDATE. They must be set explicitly:
# updated_at's onupdate=now default would otherwise clobber the note's modified time
# on every reindex, since this UPDATE does not otherwise touch that column.
metadata_updates["created_at"] = entity.created_at
metadata_updates["updated_at"] = entity.updated_at
updated = await self.entity_repository.update_fields(
session,
entity.id,
Expand Down Expand Up @@ -717,17 +726,29 @@ def _entity_metadata_updates(
checksum: str,
*,
include_created_at: bool = True,
include_semantic_timestamps: bool = True,
) -> dict[str, object]:
# Trigger: persisting index metadata for a file.
# Why: file_path/checksum/size/mtime are always file-derived change-detection state.
# But a markdown note's semantic created_at/updated_at are owned by its parsed
# frontmatter (the parser already falls back to file stat times when frontmatter
# omits them, #238/#684), so re-deriving them from raw file stats here would clobber
# file-authored timestamps on every reindex. The file stays the source of truth
# either way — this only decides which file-derived value wins.
# Outcome: markdown callers pass include_semantic_timestamps=False so the timestamps
# set by upsert_entity_from_markdown stand; non-markdown files (no frontmatter) keep
# file stats as their created_at/updated_at.
updates: dict[str, object] = {
"file_path": file.path,
"checksum": checksum,
"size": file.size,
}
if include_created_at and file.created_at is not None:
updates["created_at"] = file.created_at
if file.last_modified is not None:
updates["updated_at"] = file.last_modified
updates["mtime"] = file.last_modified.timestamp()
if include_semantic_timestamps:
updates["updated_at"] = file.last_modified
if include_created_at and include_semantic_timestamps and file.created_at is not None:
updates["created_at"] = file.created_at
if file.content_type is not None:
updates["content_type"] = file.content_type
return updates
Expand Down
8 changes: 7 additions & 1 deletion src/basic_memory/indexing/note_materialization_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from loguru import logger

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm.attributes import flag_modified

from basic_memory import db
from basic_memory.indexing.note_content_reconciler import (
Expand Down Expand Up @@ -502,9 +503,14 @@ async def publish_written_file_state(
file_path=written_file.file_path,
file_checksum=written_file.file_checksum,
)
entity.updated_at = written_file.file_updated_at
# The physical write time is bookkeeping (mtime), not the note's semantic modified —
# its frontmatter owns that, and the accepted write already set entity.updated_at from
# it (#238/#684, file-as-source-of-truth). flag_modified forces updated_at into this
# flush with its current (semantic) value so the column's onupdate=now default does not
# overwrite it during materialization.
entity.mtime = written_file.file_updated_at.timestamp()
entity.size = len(prepared_write.markdown_content.encode("utf-8"))
flag_modified(entity, "updated_at")
await session.flush()
return publish_plan.result

Expand Down
44 changes: 41 additions & 3 deletions src/basic_memory/markdown/entity_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@

md = MarkdownIt().use(observation_plugin).use(relation_plugin)

# Frontmatter keys accepted for the created/modified timestamps, canonical name first (#238).
# `date` and the `_at` aliases are read-time conveniences for notes imported from other tools;
# BM always writes the canonical `created`/`modified` keys (see parse_markdown_content below).
_CREATED_KEYS = ("created", "created_at", "date")
_MODIFIED_KEYS = ("modified", "updated_at")


def normalize_frontmatter_value(value: Any) -> Any:
"""Normalize frontmatter values to safe types for processing.
Expand Down Expand Up @@ -184,6 +190,31 @@ def parse_date(self, value: Any) -> Optional[datetime]:
return parsed
return None

def _frontmatter_timestamp(
self, metadata: dict[str, Any], keys: tuple[str, ...]
) -> Optional[datetime]:
"""Resolve a created/modified timestamp from frontmatter, preferring the first alias.

A naive result (e.g. from a date-only value like ``2024-03-15``) is assumed to be in
the local timezone rather than UTC (#238).
"""
for key in keys:
value = metadata.get(key)
if value is None:
continue
parsed = self.parse_date(value)
if parsed:
return parsed if parsed.tzinfo else parsed.astimezone()
# Trigger: a present timestamp value that parse_date cannot interpret.
# Why: silently falling back to file stats would leave the bad value on disk while
# the DB shows a different time — a file/DB disagreement the fail-fast rule
# forbids. But the canonical key (keys[0]: created/modified) is BM-written and
# must be valid; the trailing aliases (date/created_at/updated_at) are lenient
# read conveniences for imported notes, so a bad alias is skipped, not fatal.
if key == keys[0]:
raise ValueError(f"Invalid '{key}' timestamp in frontmatter: {value!r}")
return None

async def parse_file(self, path: Path | str) -> EntityMarkdown:
"""Parse markdown file into EntityMarkdown."""

Expand Down Expand Up @@ -299,10 +330,17 @@ async def parse_markdown_content(
entity_frontmatter = EntityFrontmatter(metadata=metadata)
entity_content = parse(post.content)

# Use provided timestamps or current time as fallback
# A user- or BM-supplied timestamp in frontmatter always wins over file stat times,
# so imported/backdated notes (#238) and BM's own written timestamps (#684) survive
# re-parsing. Fall back to file stats, then current time, when frontmatter has neither.
now = datetime.now().astimezone()
created = datetime.fromtimestamp(ctime).astimezone() if ctime else now
modified = datetime.fromtimestamp(mtime).astimezone() if mtime else now
created = self._frontmatter_timestamp(metadata, _CREATED_KEYS)
if created is None:
created = datetime.fromtimestamp(ctime).astimezone() if ctime else now

modified = self._frontmatter_timestamp(metadata, _MODIFIED_KEYS)
if modified is None:
modified = datetime.fromtimestamp(mtime).astimezone() if mtime else now
Comment on lines +337 to +343

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve parsed frontmatter timestamps in indexing

When notes are written through the v2/MCP path, this parsed timestamp is not what ends up on the entity row: materialization re-indexes the file and index_changed_markdown_file() later updates created_at/updated_at from file_metadata (src/basic_memory/index/local_dependencies.py:515-522). I confirmed through the new write_note integration path that a file containing created: 2024-03-15 returns a created_at at write time, so #238 is still broken for the main API/MCP workflow; carry the parsed markdown timestamps into the final metadata update instead of overwriting them with stat times.

Useful? React with 👍 / 👎.


return EntityMarkdown(
frontmatter=entity_frontmatter,
Expand Down
44 changes: 43 additions & 1 deletion src/basic_memory/markdown/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Utilities for converting between markdown and entity models."""

import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Optional

Expand All @@ -9,7 +10,10 @@

from basic_memory.file_utils import has_frontmatter, remove_frontmatter, parse_frontmatter
from basic_memory.markdown import EntityMarkdown
from basic_memory.markdown.entity_parser import normalize_frontmatter_metadata
from basic_memory.markdown.entity_parser import (
normalize_frontmatter_metadata,
normalize_frontmatter_value,
)
from basic_memory.models import Entity
from basic_memory.models import Observation as ObservationModel

Expand Down Expand Up @@ -81,6 +85,44 @@ def entity_model_from_markdown(
return model


def apply_default_timestamps(
metadata: dict[str, Any],
*,
incoming_metadata: dict[str, Any],
fallback_created: datetime,
now: datetime,
) -> None:
"""Fill in missing `created`/`modified` frontmatter timestamps, in place.

`metadata` is the full frontmatter about to be written; it may already carry
a `created`/`modified` value inherited from a prior save. `incoming_metadata`
is the subset of that state contributed by *this* write request, used to
distinguish a fresh user-supplied value from one merely carried over from an
earlier write.

`created` is preserved once set (by BM or by the user) and only filled from
`fallback_created` the first time a note gets one. `modified` is bumped to
`now` on every write BM performs, unless this request explicitly supplied its
own `modified` value (a deliberate override, e.g. importing historical data).
"""
# Treat a null value the same as a missing key: `created:`/`modified:` with no value must
# still get the managed default rather than being written back to disk as YAML null.
if metadata.get("created") is None:
metadata["created"] = fallback_created.isoformat()
if incoming_metadata.get("modified") is None:
metadata["modified"] = now.isoformat()

# Normalize to ISO strings so both timestamps round-trip identically and match how BM
# already serializes every other frontmatter date (#236). A user-supplied value arrives
# as a YAML-parsed date/datetime object; left as-is it dumps unquoted and reparses to a
# date object, while BM's own isoformat() values are strings that SafeDumper quotes —
# normalizing both here removes that quoted/unquoted split on disk.
for key in ("created", "modified"):
value = metadata.get(key)
if value is not None:
metadata[key] = normalize_frontmatter_value(value)


async def schema_to_markdown(schema: Any) -> Post:
"""
Convert schema to markdown Post object.
Expand Down
6 changes: 5 additions & 1 deletion src/basic_memory/models/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ class Entity(Base):
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now().astimezone(),
onupdate=lambda: datetime.now().astimezone(),
# No onupdate=now: updated_at is the note's semantic "modified", owned by its markdown
# frontmatter / file mtime and set explicitly on every content write (parse + accepted
# write). Auto-stamping on every row UPDATE let incidental writes (checksum, reindex,
# cloud materialization) clobber a file-authored timestamp — file is source of truth
# (#238/#684).
)

# Who created this entity (cloud user_profile_id UUID, null for local/CLI usage)
Expand Down
Loading
Loading