Skip to content

Commit ef7d16f

Browse files
committed
Merge branch 'main' into pr-252
2 parents eb31cf1 + 7aff836 commit ef7d16f

21 files changed

Lines changed: 508 additions & 107 deletions

CLA.md

Lines changed: 63 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,71 @@
1-
Developer Certificate of Origin
2-
Version 1.1
3-
https://developercertificate.org/
1+
# Contributor License Agreement
42

5-
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
3+
## Copyright Assignment and License Grant
64

7-
Everyone is permitted to copy and distribute verbatim copies of this
8-
license document, but changing it is not allowed.
5+
By signing this Contributor License Agreement ("Agreement"), you accept and agree to the following terms and conditions
6+
for your present and future Contributions submitted
7+
to Basic Machines LLC. Except for the license granted herein to Basic Machines LLC and recipients of software
8+
distributed by Basic Machines LLC, you reserve all right,
9+
title, and interest in and to your Contributions.
910

10-
Developer's Certificate of Origin 1.1
11+
### 1. Definitions
1112

12-
By making a contribution to this project, I certify that:
13+
"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this
14+
Agreement with Basic Machines LLC.
1315

14-
(a) The contribution was created in whole or in part by me and I
15-
have the right to submit it under the open source license
16-
indicated in the file; or
16+
"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work,
17+
that is intentionally submitted by You to Basic
18+
Machines LLC for inclusion in, or documentation of, any of the products owned or managed by Basic Machines LLC (the "
19+
Work").
1720

18-
(b) The contribution is based upon previous work that, to the best
19-
of my knowledge, is covered under an appropriate open source
20-
license and I have the right under that license to submit that
21-
work with modifications, whether created in whole or in part
22-
by me, under the same open source license (unless I am
23-
permitted to submit under a different license), as indicated
24-
in the file; or
21+
### 2. Grant of Copyright License
2522

26-
(c) The contribution was provided directly to me by some other
27-
person who certified (a), (b) or (c) and I have not modified
28-
it.
23+
Subject to the terms and conditions of this Agreement, You hereby grant to Basic Machines LLC and to recipients of
24+
software distributed by Basic Machines LLC a perpetual,
25+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to use, copy, modify, merge, publish,
26+
distribute, sublicense, and/or sell copies of the
27+
Work, and to permit persons to whom the Work is furnished to do so.
2928

30-
(d) I understand and agree that this project and the contribution
31-
are public and that a record of the contribution (including all
32-
personal information I submit with it, including my sign-off) is
33-
maintained indefinitely and may be redistributed consistent with
34-
this project or the open source license(s) involved.
29+
### 3. Assignment of Copyright
30+
31+
You hereby assign to Basic Machines LLC all right, title, and interest worldwide in all Copyright covering your
32+
Contributions. Basic Machines LLC may license the
33+
Contributions under any license terms, including copyleft, permissive, commercial, or proprietary licenses.
34+
35+
### 4. Grant of Patent License
36+
37+
Subject to the terms and conditions of this Agreement, You hereby grant to Basic Machines LLC and to recipients of
38+
software distributed by Basic Machines LLC a perpetual,
39+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to
40+
make, have made, use, offer to sell, sell, import, and
41+
otherwise transfer the Work.
42+
43+
### 5. Developer Certificate of Origin
44+
45+
By making a Contribution to this project, You certify that:
46+
47+
(a) The Contribution was created in whole or in part by You and You have the right to submit it under this Agreement; or
48+
49+
(b) The Contribution is based upon previous work that, to the best of Your knowledge, is covered under an appropriate
50+
open source license and You have the right under that
51+
license to submit that work with modifications, whether created in whole or in part by You, under this Agreement; or
52+
53+
(c) The Contribution was provided directly to You by some other person who certified (a), (b) or (c) and You have not
54+
modified it.
55+
56+
(d) You understand and agree that this project and the Contribution are public and that a record of the Contribution (
57+
including all personal information You submit with
58+
it, including Your sign-off) is maintained indefinitely and may be redistributed consistent with this project or the
59+
open source license(s) involved.
60+
61+
### 6. Representations
62+
63+
You represent that you are legally entitled to grant the above license and assignment. If your employer(s) has rights to
64+
intellectual property that you create that
65+
includes your Contributions, you represent that you have received permission to make Contributions on behalf of that
66+
employer, or that your employer has waived such rights
67+
for your Contributions to Basic Machines LLC.
68+
69+
---
70+
71+
This Agreement is effective as of the date you first submit a Contribution to Basic Machines LLC.

src/basic_memory/api/routers/resource_router.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ async def write_resource(
188188
"content_type": content_type,
189189
"file_path": file_path,
190190
"checksum": checksum,
191-
"updated_at": datetime.fromtimestamp(file_stats.st_mtime),
191+
"updated_at": datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
192192
},
193193
)
194194
status_code = 200
@@ -200,8 +200,8 @@ async def write_resource(
200200
content_type=content_type,
201201
file_path=file_path,
202202
checksum=checksum,
203-
created_at=datetime.fromtimestamp(file_stats.st_ctime),
204-
updated_at=datetime.fromtimestamp(file_stats.st_mtime),
203+
created_at=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
204+
updated_at=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
205205
)
206206
entity = await entity_repository.add(entity)
207207
status_code = 201

src/basic_memory/config.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -351,15 +351,22 @@ def setup_basic_memory_logging(): # pragma: no cover
351351
# print("Skipping duplicate logging setup")
352352
return
353353

354-
# Check for console logging environment variable
355-
console_logging = os.getenv("BASIC_MEMORY_CONSOLE_LOGGING", "false").lower() == "true"
354+
# Check for console logging environment variable - accept more truthy values
355+
console_logging_env = os.getenv("BASIC_MEMORY_CONSOLE_LOGGING", "false").lower()
356+
console_logging = console_logging_env in ("true", "1", "yes", "on")
357+
358+
# Check for log level environment variable first, fall back to config
359+
log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL")
360+
if not log_level:
361+
config_manager = ConfigManager()
362+
log_level = config_manager.config.log_level
356363

357364
config_manager = ConfigManager()
358365
config = get_project_config()
359366
setup_logging(
360367
env=config_manager.config.env,
361368
home_dir=user_home, # Use user home for logs
362-
log_level=config_manager.config.log_level,
369+
log_level=log_level,
363370
log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
364371
console=console_logging,
365372
)

src/basic_memory/importers/chatgpt_importer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def _format_chat_content(
9393
break
9494

9595
# Generate permalink
96-
date_prefix = datetime.fromtimestamp(created_at).strftime("%Y%m%d")
96+
date_prefix = datetime.fromtimestamp(created_at).astimezone().strftime("%Y%m%d")
9797
clean_title = clean_filename(conversation["title"])
9898

9999
# Format content

src/basic_memory/importers/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ def format_timestamp(timestamp: Any) -> str: # pragma: no cover
4343
except ValueError:
4444
try:
4545
# Try unix timestamp as string
46-
timestamp = datetime.fromtimestamp(float(timestamp))
46+
timestamp = datetime.fromtimestamp(float(timestamp)).astimezone()
4747
except ValueError:
4848
# Return as is if we can't parse it
4949
return timestamp
5050
elif isinstance(timestamp, (int, float)):
5151
# Unix timestamp
52-
timestamp = datetime.fromtimestamp(timestamp)
52+
timestamp = datetime.fromtimestamp(timestamp).astimezone()
5353

5454
if isinstance(timestamp, datetime):
5555
return timestamp.strftime("%Y-%m-%d %H:%M:%S")

src/basic_memory/markdown/entity_parser.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,6 @@ async def parse_file_content(self, absolute_path, file_content):
130130
content=post.content,
131131
observations=entity_content.observations,
132132
relations=entity_content.relations,
133-
created=datetime.fromtimestamp(file_stats.st_ctime),
134-
modified=datetime.fromtimestamp(file_stats.st_mtime),
133+
created=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
134+
modified=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
135135
)

src/basic_memory/markdown/plugins.py

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,56 +8,68 @@
88
# Observation handling functions
99
def is_observation(token: Token) -> bool:
1010
"""Check if token looks like our observation format."""
11+
import re
1112
if token.type != "inline": # pragma: no cover
1213
return False
13-
14-
content = token.content.strip()
14+
# Use token.tag which contains the actual content for test tokens, fallback to content
15+
content = (token.tag or token.content).strip()
1516
if not content: # pragma: no cover
1617
return False
17-
1818
# if it's a markdown_task, return false
1919
if content.startswith("[ ]") or content.startswith("[x]") or content.startswith("[-]"):
2020
return False
21-
22-
has_category = content.startswith("[") and "]" in content
21+
22+
# Exclude markdown links: [text](url)
23+
if re.match(r"^\[.*?\]\(.*?\)$", content):
24+
return False
25+
26+
# Exclude wiki links: [[text]]
27+
if re.match(r"^\[\[.*?\]\]$", content):
28+
return False
29+
30+
# Check for proper observation format: [category] content
31+
match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
2332
has_tags = "#" in content
24-
return has_category or has_tags
33+
return bool(match) or has_tags
2534

2635

2736
def parse_observation(token: Token) -> Dict[str, Any]:
2837
"""Extract observation parts from token."""
29-
# Strip bullet point if present
30-
content = token.content.strip()
31-
32-
# Parse [category]
38+
import re
39+
# Use token.tag which contains the actual content for test tokens, fallback to content
40+
content = (token.tag or token.content).strip()
41+
42+
# Parse [category] with regex
43+
match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
3344
category = None
34-
if content.startswith("["):
35-
end = content.find("]")
36-
if end != -1:
37-
category = content[1:end].strip() or None # Convert empty to None
38-
content = content[end + 1 :].strip()
39-
45+
if match:
46+
category = match.group(1).strip()
47+
content = match.group(2).strip()
48+
else:
49+
# Handle empty brackets [] followed by content
50+
empty_match = re.match(r"^\[\]\s+(.+)", content)
51+
if empty_match:
52+
content = empty_match.group(1).strip()
53+
4054
# Parse (context)
4155
context = None
4256
if content.endswith(")"):
4357
start = content.rfind("(")
4458
if start != -1:
4559
context = content[start + 1 : -1].strip()
4660
content = content[:start].strip()
47-
61+
4862
# Extract tags and keep original content
4963
tags = []
5064
parts = content.split()
5165
for part in parts:
5266
if part.startswith("#"):
53-
# Handle multiple #tags stuck together
5467
if "#" in part[1:]:
55-
# Split on # but keep non-empty tags
5668
subtags = [t for t in part.split("#") if t]
5769
tags.extend(subtags)
5870
else:
5971
tags.append(part[1:])
60-
72+
6173
return {
6274
"category": category,
6375
"content": content,
@@ -72,14 +84,16 @@ def is_explicit_relation(token: Token) -> bool:
7284
if token.type != "inline": # pragma: no cover
7385
return False
7486

75-
content = token.content.strip()
87+
# Use token.tag which contains the actual content for test tokens, fallback to content
88+
content = (token.tag or token.content).strip()
7689
return "[[" in content and "]]" in content
7790

7891

7992
def parse_relation(token: Token) -> Dict[str, Any] | None:
8093
"""Extract relation parts from token."""
8194
# Remove bullet point if present
82-
content = token.content.strip()
95+
# Use token.tag which contains the actual content for test tokens, fallback to content
96+
content = (token.tag or token.content).strip()
8397

8498
# Extract [[target]]
8599
target = None
@@ -213,10 +227,12 @@ def relation_rule(state: Any) -> None:
213227
token.meta["relations"] = [rel]
214228

215229
# Always check for inline links in any text
216-
elif "[[" in token.content:
217-
rels = parse_inline_relations(token.content)
218-
if rels:
219-
token.meta["relations"] = token.meta.get("relations", []) + rels
230+
else:
231+
content = token.tag or token.content
232+
if "[[" in content:
233+
rels = parse_inline_relations(content)
234+
if rels:
235+
token.meta["relations"] = token.meta.get("relations", []) + rels
220236

221237
# Add the rule after inline processing
222238
md.core.ruler.after("inline", "relations", relation_rule)

src/basic_memory/mcp/tools/build_context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ async def build_context(
111111
metadata=MemoryMetadata(
112112
depth=depth or 1,
113113
timeframe=timeframe,
114-
generated_at=datetime.now(),
114+
generated_at=datetime.now().astimezone(),
115115
primary_count=0,
116116
related_count=0,
117117
uri=migration_status, # Include status in metadata

src/basic_memory/models/knowledge.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Knowledge graph models."""
22

3-
from datetime import datetime
3+
from datetime import datetime, timezone
4+
from basic_memory.utils import ensure_timezone_aware
45
from typing import Optional
56

67
from sqlalchemy import (
@@ -73,8 +74,8 @@ class Entity(Base):
7374
checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
7475

7576
# Metadata and tracking
76-
created_at: Mapped[datetime] = mapped_column(DateTime)
77-
updated_at: Mapped[datetime] = mapped_column(DateTime)
77+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now().astimezone())
78+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now().astimezone(), onupdate=lambda: datetime.now().astimezone())
7879

7980
# Relationships
8081
project = relationship("Project", back_populates="entities")
@@ -103,6 +104,16 @@ def relations(self):
103104
def is_markdown(self):
104105
"""Check if the entity is a markdown file."""
105106
return self.content_type == "text/markdown"
107+
108+
def __getattribute__(self, name):
109+
"""Override attribute access to ensure datetime fields are timezone-aware."""
110+
value = super().__getattribute__(name)
111+
112+
# Ensure datetime fields are timezone-aware
113+
if name in ('created_at', 'updated_at') and isinstance(value, datetime):
114+
return ensure_timezone_aware(value)
115+
116+
return value
106117

107118
def __repr__(self) -> str:
108119
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"

src/basic_memory/models/project.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ class Project(Base):
5252
is_default: Mapped[Optional[bool]] = mapped_column(Boolean, default=None, nullable=True)
5353

5454
# Timestamps
55-
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(UTC))
55+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
5656
updated_at: Mapped[datetime] = mapped_column(
57-
DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
57+
DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
5858
)
5959

6060
# Define relationships to entities, observations, and relations

0 commit comments

Comments
 (0)