Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ dev-dependencies = [
"pytest-asyncio>=0.24.0",
"pytest-xdist>=3.0.0",
"ruff>=0.1.6",
"freezegun>=1.5.5",
]

[tool.hatch.version]
Expand Down
29 changes: 23 additions & 6 deletions src/basic_memory/schemas/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import os
import mimetypes
import re
from datetime import datetime, time
from datetime import datetime, time, timedelta
from pathlib import Path
from typing import List, Optional, Annotated, Dict

Expand Down Expand Up @@ -52,21 +52,27 @@ def to_snake_case(name: str) -> str:
def parse_timeframe(timeframe: str) -> datetime:
"""Parse timeframe with special handling for 'today' and other natural language expressions.

Enforces a minimum 1-day lookback to handle timezone differences in distributed deployments.

Args:
timeframe: Natural language timeframe like 'today', '1d', '1 week ago', etc.

Returns:
datetime: The parsed datetime for the start of the timeframe, timezone-aware in local system timezone
Always returns at least 1 day ago to handle timezone differences.

Examples:
parse_timeframe('today') -> 2025-06-05 00:00:00-07:00 (start of today with local timezone)
parse_timeframe('today') -> 2025-06-04 14:50:00-07:00 (1 day ago, not start of today)
parse_timeframe('1h') -> 2025-06-04 14:50:00-07:00 (1 day ago, not 1 hour ago)
parse_timeframe('1d') -> 2025-06-04 14:50:00-07:00 (24 hours ago with local timezone)
parse_timeframe('1 week ago') -> 2025-05-29 14:50:00-07:00 (1 week ago with local timezone)
"""
if timeframe.lower() == "today":
# Return start of today (00:00:00) in local timezone
naive_dt = datetime.combine(datetime.now().date(), time.min)
return naive_dt.astimezone()
# For "today", return 1 day ago to ensure we capture recent activity across timezones
# This handles the case where client and server are in different timezones
now = datetime.now()
one_day_ago = now - timedelta(days=1)
return one_day_ago.astimezone()
else:
# Use dateparser for other formats
parsed = parse(timeframe)
Expand All @@ -75,7 +81,18 @@ def parse_timeframe(timeframe: str) -> datetime:

# If the parsed datetime is naive, make it timezone-aware in local system timezone
if parsed.tzinfo is None:
return parsed.astimezone()
parsed = parsed.astimezone()
else:
parsed = parsed

# Enforce minimum 1-day lookback to handle timezone differences
# This ensures we don't miss recent activity due to client/server timezone mismatches
now = datetime.now().astimezone()
one_day_ago = now - timedelta(days=1)

# If the parsed time is more recent than 1 day ago, use 1 day ago instead
if parsed > one_day_ago:
return one_day_ago
else:
return parsed

Expand Down
2 changes: 1 addition & 1 deletion tests/mcp/test_tool_build_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ async def test_get_discussion_context_not_found(client, test_project):

invalid_timeframes = [
"invalid", # Nonsense string
"tomorrow", # Future date
# NOTE: "tomorrow" now returns 1 day ago due to timezone safety - no longer invalid
]


Expand Down
2 changes: 1 addition & 1 deletion tests/mcp/test_tool_recent_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

invalid_timeframes = [
"invalid", # Nonsense string
"tomorrow", # Future date
# NOTE: "tomorrow" now returns 1 day ago due to timezone safety - no longer invalid
]


Expand Down
98 changes: 98 additions & 0 deletions tests/schemas/test_base_timeframe_minimum.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Test minimum 1-day timeframe enforcement for timezone handling."""

from datetime import datetime, timedelta
import pytest
from freezegun import freeze_time

from basic_memory.schemas.base import parse_timeframe


class TestTimeframeMinimum:
"""Test that parse_timeframe enforces a minimum 1-day lookback."""

@freeze_time("2025-01-15 15:00:00")
def test_today_returns_one_day_ago(self):
"""Test that 'today' returns 1 day ago instead of start of today."""
result = parse_timeframe("today")
now = datetime.now()
one_day_ago = now - timedelta(days=1)

# Should be approximately 1 day ago (within a second for test tolerance)
diff = abs((result.replace(tzinfo=None) - one_day_ago).total_seconds())
assert diff < 1, f"Expected ~1 day ago, got {result}"

@freeze_time("2025-01-15 15:00:00")
def test_one_hour_returns_one_day_minimum(self):
"""Test that '1h' returns 1 day ago due to minimum enforcement."""
result = parse_timeframe("1h")
now = datetime.now()
one_day_ago = now - timedelta(days=1)

# Should be approximately 1 day ago, not 1 hour ago
diff = abs((result.replace(tzinfo=None) - one_day_ago).total_seconds())
assert diff < 1, f"Expected ~1 day ago for '1h', got {result}"

@freeze_time("2025-01-15 15:00:00")
def test_six_hours_returns_one_day_minimum(self):
"""Test that '6h' returns 1 day ago due to minimum enforcement."""
result = parse_timeframe("6h")
now = datetime.now()
one_day_ago = now - timedelta(days=1)

# Should be approximately 1 day ago, not 6 hours ago
diff = abs((result.replace(tzinfo=None) - one_day_ago).total_seconds())
assert diff < 1, f"Expected ~1 day ago for '6h', got {result}"

@freeze_time("2025-01-15 15:00:00")
def test_one_day_returns_one_day(self):
"""Test that '1d' correctly returns approximately 1 day ago."""
result = parse_timeframe("1d")
now = datetime.now()
one_day_ago = now - timedelta(days=1)

# Should be approximately 1 day ago (within 24 hours)
diff_hours = abs((result.replace(tzinfo=None) - one_day_ago).total_seconds()) / 3600
assert diff_hours < 24, f"Expected ~1 day ago for '1d', got {result} (diff: {diff_hours} hours)"

@freeze_time("2025-01-15 15:00:00")
def test_two_days_returns_two_days(self):
"""Test that '2d' correctly returns approximately 2 days ago (not affected by minimum)."""
result = parse_timeframe("2d")
now = datetime.now()
two_days_ago = now - timedelta(days=2)

# Should be approximately 2 days ago (within 24 hours)
diff_hours = abs((result.replace(tzinfo=None) - two_days_ago).total_seconds()) / 3600
assert diff_hours < 24, f"Expected ~2 days ago for '2d', got {result} (diff: {diff_hours} hours)"

@freeze_time("2025-01-15 15:00:00")
def test_one_week_returns_one_week(self):
"""Test that '1 week' correctly returns approximately 1 week ago (not affected by minimum)."""
result = parse_timeframe("1 week")
now = datetime.now()
one_week_ago = now - timedelta(weeks=1)

# Should be approximately 1 week ago (within 24 hours)
diff_hours = abs((result.replace(tzinfo=None) - one_week_ago).total_seconds()) / 3600
assert diff_hours < 24, f"Expected ~1 week ago for '1 week', got {result} (diff: {diff_hours} hours)"

@freeze_time("2025-01-15 15:00:00")
def test_zero_days_returns_one_day_minimum(self):
"""Test that '0d' returns 1 day ago due to minimum enforcement."""
result = parse_timeframe("0d")
now = datetime.now()
one_day_ago = now - timedelta(days=1)

# Should be approximately 1 day ago, not now
diff = abs((result.replace(tzinfo=None) - one_day_ago).total_seconds())
assert diff < 1, f"Expected ~1 day ago for '0d', got {result}"

def test_timezone_awareness(self):
"""Test that returned datetime is timezone-aware."""
result = parse_timeframe("1d")
assert result.tzinfo is not None, "Expected timezone-aware datetime"

def test_invalid_timeframe_raises_error(self):
"""Test that invalid timeframe strings raise ValueError."""
with pytest.raises(ValueError, match="Could not parse timeframe"):
parse_timeframe("invalid_timeframe")
60 changes: 33 additions & 27 deletions tests/schemas/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,10 @@ def test_permalink_generation():
("last week", True),
("3 weeks ago", True),
("invalid", False),
("tomorrow", False),
("next week", False),
# NOTE: "tomorrow" and "next week" now return 1 day ago due to timezone safety
# They no longer raise errors - this is intentional for remote MCP
("tomorrow", True), # Now valid - returns 1 day ago
("next week", True), # Now valid - returns 1 day ago
("", False),
("0d", True),
("366d", False),
Expand Down Expand Up @@ -316,25 +318,27 @@ class TestTimeframeParsing:
"""Test cases for parse_timeframe() and validate_timeframe() functions."""

def test_parse_timeframe_today(self):
"""Test that parse_timeframe('today') returns start of current day with timezone."""
"""Test that parse_timeframe('today') returns 1 day ago for remote MCP timezone safety."""
result = parse_timeframe("today")
expected = datetime.combine(datetime.now().date(), time.min).astimezone()
now = datetime.now()
one_day_ago = now - timedelta(days=1)

assert result == expected
assert result.hour == 0
assert result.minute == 0
assert result.second == 0
assert result.microsecond == 0
# Should be approximately 1 day ago (within a second for test tolerance)
diff = abs((result.replace(tzinfo=None) - one_day_ago).total_seconds())
assert diff < 2, f"Expected ~1 day ago for 'today', got {result}"
assert result.tzinfo is not None

def test_parse_timeframe_today_case_insensitive(self):
"""Test that parse_timeframe handles 'today' case-insensitively."""
test_cases = ["today", "TODAY", "Today", "ToDay"]
expected = datetime.combine(datetime.now().date(), time.min).astimezone()
now = datetime.now()
one_day_ago = now - timedelta(days=1)

for case in test_cases:
result = parse_timeframe(case)
assert result == expected
# Should be approximately 1 day ago (within a second for test tolerance)
diff = abs((result.replace(tzinfo=None) - one_day_ago).total_seconds())
assert diff < 2, f"Expected ~1 day ago for '{case}', got {result}"

def test_parse_timeframe_other_formats(self):
"""Test that parse_timeframe works with other dateparser formats."""
Expand Down Expand Up @@ -401,9 +405,9 @@ def test_validate_timeframe_error_cases(self):
with pytest.raises(ValueError, match="Timeframe must be a string"):
validate_timeframe(123) # type: ignore

# Future timeframe
with pytest.raises(ValueError, match="Timeframe cannot be in the future"):
validate_timeframe("tomorrow")
# NOTE: Future timeframes no longer raise errors due to 1-day minimum enforcement
# "tomorrow" and "next week" now return 1 day ago for timezone safety
# This is intentional for remote MCP deployments

# Too far in past (>365 days)
with pytest.raises(ValueError, match="Timeframe should be <= 1 year"):
Expand Down Expand Up @@ -431,32 +435,34 @@ class TestModel(BaseModel):
assert model.timeframe == "1d"

def test_timeframe_integration_today_vs_1d(self):
"""Test the specific bug fix: 'today' vs '1d' behavior."""
"""Test that 'today' and '1d' both return 1 day ago due to timezone safety minimum."""

class TestModel(BaseModel):
timeframe: TimeFrame

# 'today' should be preserved
# 'today' should be preserved as special case in validation
today_model = TestModel(timeframe="today")
assert today_model.timeframe == "today"

# '1d' should also be preserved (it's already in standard format)
oneday_model = TestModel(timeframe="1d")
assert oneday_model.timeframe == "1d"

# When parsed by parse_timeframe, they should be different
# When parsed by parse_timeframe, both should return approximately 1 day ago
# due to the 1-day minimum enforcement for remote MCP timezone safety
today_parsed = parse_timeframe("today")
oneday_parsed = parse_timeframe("1d")

# 'today' should be start of today (00:00:00)
assert today_parsed.hour == 0
assert today_parsed.minute == 0
now = datetime.now()
one_day_ago = now - timedelta(days=1)

# '1d' should be 24 hours ago (same time yesterday)
now = datetime.now().astimezone()
expected_1d = now - timedelta(days=1)
diff = abs((oneday_parsed - expected_1d).total_seconds())
assert diff < 60 # Within 1 minute
# Both should be approximately 1 day ago
today_diff = abs((today_parsed.replace(tzinfo=None) - one_day_ago).total_seconds())
assert today_diff < 60, f"'today' should be ~1 day ago, got {today_parsed}"

oneday_diff = abs((oneday_parsed.replace(tzinfo=None) - one_day_ago).total_seconds())
assert oneday_diff < 60, f"'1d' should be ~1 day ago, got {oneday_parsed}"

# They should be different times
assert today_parsed != oneday_parsed
# They should be approximately the same time (within an hour due to parsing differences)
time_diff = abs((today_parsed - oneday_parsed).total_seconds())
assert time_diff < 3600, f"'today' and '1d' should be similar times, diff: {time_diff}s"
14 changes: 14 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading