basic-memory 0.1.1__py3-none-any.whl → 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (77) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/README +1 -0
  3. basic_memory/alembic/env.py +75 -0
  4. basic_memory/alembic/migrations.py +29 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  7. basic_memory/api/__init__.py +2 -1
  8. basic_memory/api/app.py +26 -24
  9. basic_memory/api/routers/knowledge_router.py +28 -26
  10. basic_memory/api/routers/memory_router.py +17 -11
  11. basic_memory/api/routers/search_router.py +6 -12
  12. basic_memory/cli/__init__.py +1 -1
  13. basic_memory/cli/app.py +0 -1
  14. basic_memory/cli/commands/__init__.py +3 -3
  15. basic_memory/cli/commands/db.py +25 -0
  16. basic_memory/cli/commands/import_memory_json.py +35 -31
  17. basic_memory/cli/commands/mcp.py +20 -0
  18. basic_memory/cli/commands/status.py +10 -6
  19. basic_memory/cli/commands/sync.py +5 -56
  20. basic_memory/cli/main.py +5 -38
  21. basic_memory/config.py +3 -3
  22. basic_memory/db.py +15 -22
  23. basic_memory/deps.py +3 -4
  24. basic_memory/file_utils.py +36 -35
  25. basic_memory/markdown/entity_parser.py +13 -30
  26. basic_memory/markdown/markdown_processor.py +7 -7
  27. basic_memory/markdown/plugins.py +109 -123
  28. basic_memory/markdown/schemas.py +7 -8
  29. basic_memory/markdown/utils.py +70 -121
  30. basic_memory/mcp/__init__.py +1 -1
  31. basic_memory/mcp/async_client.py +0 -2
  32. basic_memory/mcp/server.py +3 -27
  33. basic_memory/mcp/tools/__init__.py +5 -3
  34. basic_memory/mcp/tools/knowledge.py +2 -2
  35. basic_memory/mcp/tools/memory.py +8 -4
  36. basic_memory/mcp/tools/search.py +2 -1
  37. basic_memory/mcp/tools/utils.py +1 -1
  38. basic_memory/models/__init__.py +1 -2
  39. basic_memory/models/base.py +3 -3
  40. basic_memory/models/knowledge.py +23 -60
  41. basic_memory/models/search.py +1 -1
  42. basic_memory/repository/__init__.py +5 -3
  43. basic_memory/repository/entity_repository.py +34 -98
  44. basic_memory/repository/relation_repository.py +0 -7
  45. basic_memory/repository/repository.py +2 -39
  46. basic_memory/repository/search_repository.py +20 -25
  47. basic_memory/schemas/__init__.py +4 -4
  48. basic_memory/schemas/base.py +21 -62
  49. basic_memory/schemas/delete.py +2 -3
  50. basic_memory/schemas/discovery.py +4 -1
  51. basic_memory/schemas/memory.py +12 -13
  52. basic_memory/schemas/request.py +4 -23
  53. basic_memory/schemas/response.py +10 -9
  54. basic_memory/schemas/search.py +4 -7
  55. basic_memory/services/__init__.py +2 -7
  56. basic_memory/services/context_service.py +116 -110
  57. basic_memory/services/entity_service.py +25 -62
  58. basic_memory/services/exceptions.py +1 -0
  59. basic_memory/services/file_service.py +73 -109
  60. basic_memory/services/link_resolver.py +9 -9
  61. basic_memory/services/search_service.py +22 -15
  62. basic_memory/services/service.py +3 -24
  63. basic_memory/sync/__init__.py +2 -2
  64. basic_memory/sync/file_change_scanner.py +3 -7
  65. basic_memory/sync/sync_service.py +35 -40
  66. basic_memory/sync/utils.py +6 -38
  67. basic_memory/sync/watch_service.py +26 -5
  68. basic_memory/utils.py +42 -33
  69. {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/METADATA +2 -7
  70. basic_memory-0.1.2.dist-info/RECORD +78 -0
  71. basic_memory/mcp/main.py +0 -21
  72. basic_memory/mcp/tools/ai_edit.py +0 -84
  73. basic_memory/services/database_service.py +0 -159
  74. basic_memory-0.1.1.dist-info/RECORD +0 -74
  75. {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/WHEEL +0 -0
  76. {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/entry_points.txt +0 -0
  77. {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/licenses/LICENSE +0 -0
@@ -13,7 +13,7 @@ class Observation(BaseModel):
13
13
  content: str
14
14
  tags: Optional[List[str]] = None
15
15
  context: Optional[str] = None
16
-
16
+
17
17
  def __str__(self) -> str:
18
18
  obs_string = f"- [{self.category}] {self.content}"
19
19
  if self.context:
@@ -27,7 +27,7 @@ class Relation(BaseModel):
27
27
  type: str
28
28
  target: str
29
29
  context: Optional[str] = None
30
-
30
+
31
31
  def __str__(self) -> str:
32
32
  rel_string = f"- {self.type} [[{self.target}]]"
33
33
  if self.context:
@@ -38,24 +38,23 @@ class Relation(BaseModel):
38
38
  class EntityFrontmatter(BaseModel):
39
39
  """Required frontmatter fields for an entity."""
40
40
 
41
- metadata: Optional[dict] = None
41
+ metadata: dict = {}
42
42
 
43
43
  @property
44
44
  def tags(self) -> List[str]:
45
- return self.metadata.get("tags") if self.metadata else []
45
+ return self.metadata.get("tags") if self.metadata else [] # pyright: ignore
46
46
 
47
47
  @property
48
48
  def title(self) -> str:
49
- return self.metadata.get("title") if self.metadata else None
49
+ return self.metadata.get("title") if self.metadata else None # pyright: ignore
50
50
 
51
51
  @property
52
52
  def type(self) -> str:
53
- return self.metadata.get("type", "note") if self.metadata else "note"
53
+ return self.metadata.get("type", "note") if self.metadata else "note" # pyright: ignore
54
54
 
55
55
  @property
56
56
  def permalink(self) -> str:
57
- return self.metadata.get("permalink") if self.metadata else None
58
-
57
+ return self.metadata.get("permalink") if self.metadata else None # pyright: ignore
59
58
 
60
59
 
61
60
  class EntityMarkdown(BaseModel):
@@ -1,144 +1,93 @@
1
+ """Utilities for converting between markdown and entity models."""
2
+
1
3
  from pathlib import Path
2
- from typing import Optional
4
+ from typing import Optional, Any
3
5
 
4
6
  from frontmatter import Post
5
7
 
6
- from basic_memory.markdown import EntityMarkdown, EntityFrontmatter, Observation, Relation
7
- from basic_memory.markdown.entity_parser import parse
8
- from basic_memory.models import Entity, ObservationCategory, Observation as ObservationModel
8
+ from basic_memory.markdown import EntityMarkdown
9
+ from basic_memory.models import Entity, Observation as ObservationModel
9
10
  from basic_memory.utils import generate_permalink
10
11
 
11
12
 
12
- def entity_model_to_markdown(entity: Entity, content: Optional[str] = None) -> EntityMarkdown:
13
+ def entity_model_from_markdown(
14
+ file_path: Path, markdown: EntityMarkdown, entity: Optional[Entity] = None
15
+ ) -> Entity:
13
16
  """
14
- Converts an entity model to its Markdown representation, including metadata,
15
- observations, relations, and content. Ensures that observations and relations
16
- from the provided content are synchronized with the entity model. Removes
17
- duplicate or unmatched observations and relations from the content to maintain
18
- consistency.
19
-
20
- :param entity: An instance of the Entity class containing metadata, observations,
21
- relations, and other properties of the entity.
22
- :type entity: Entity
23
- :param content: Optional raw Markdown-formatted content to be parsed for semantic
24
- information like observations or relations.
25
- :type content: Optional[str]
26
- :return: An instance of the EntityMarkdown class containing the entity's
27
- frontmatter, observations, relations, and sanitized content formatted
28
- in Markdown.
29
- :rtype: EntityMarkdown
17
+ Convert markdown entity to model. Does not include relations.
18
+
19
+ Args:
20
+ file_path: Path to the markdown file
21
+ markdown: Parsed markdown entity
22
+ entity: Optional existing entity to update
23
+
24
+ Returns:
25
+ Entity model populated from markdown
26
+
27
+ Raises:
28
+ ValueError: If required datetime fields are missing from markdown
30
29
  """
31
- metadata = entity.entity_metadata or {}
32
- metadata["type"] = entity.entity_type or "note"
33
- metadata["title"] = entity.title
34
- metadata["permalink"] = entity.permalink
35
-
36
- # convert model to markdown
37
- entity_observations = [
38
- Observation(
39
- category=obs.category,
30
+
31
+ if not markdown.created or not markdown.modified: # pragma: no cover
32
+ raise ValueError("Both created and modified dates are required in markdown")
33
+
34
+ # Generate permalink if not provided
35
+ permalink = markdown.frontmatter.permalink or generate_permalink(file_path)
36
+
37
+ # Create or update entity
38
+ model = entity or Entity()
39
+
40
+ # Update basic fields
41
+ model.title = markdown.frontmatter.title
42
+ model.entity_type = markdown.frontmatter.type
43
+ model.permalink = permalink
44
+ model.file_path = str(file_path)
45
+ model.content_type = "text/markdown"
46
+ model.created_at = markdown.created
47
+ model.updated_at = markdown.modified
48
+
49
+ # Handle metadata - ensure all values are strings and filter None
50
+ metadata = markdown.frontmatter.metadata or {}
51
+ model.entity_metadata = {k: str(v) for k, v in metadata.items() if v is not None}
52
+
53
+ # Convert observations
54
+ model.observations = [
55
+ ObservationModel(
40
56
  content=obs.content,
41
- tags=obs.tags if obs.tags else None,
57
+ category=obs.category,
42
58
  context=obs.context,
59
+ tags=obs.tags,
43
60
  )
44
- for obs in entity.observations
61
+ for obs in markdown.observations
45
62
  ]
46
63
 
47
- entity_relations = [
48
- Relation(
49
- type=r.relation_type,
50
- target=r.to_entity.title if r.to_entity else r.to_name,
51
- context=r.context,
52
- )
53
- for r in entity.outgoing_relations
54
- ]
55
-
56
- observations = entity_observations
57
- relations = entity_relations
58
-
59
- # parse the content to see if it has semantic info (observations/relations)
60
- entity_content = parse(content) if content else None
61
-
62
- if entity_content:
63
- # remove if they are already in the content
64
- observations = [o for o in entity_observations if o not in entity_content.observations]
65
- relations = [r for r in entity_relations if r not in entity_content.relations]
66
-
67
- # remove from the content if not present in the db entity
68
- for o in entity_content.observations:
69
- if o not in entity_observations:
70
- content = content.replace(str(o), "")
71
-
72
- for r in entity_content.relations:
73
- if r not in entity_relations:
74
- content = content.replace(str(r), "")
75
-
76
- return EntityMarkdown(
77
- frontmatter=EntityFrontmatter(metadata=metadata),
78
- content=content,
79
- observations=observations,
80
- relations=relations,
81
- created = entity.created_at,
82
- modified = entity.updated_at,
83
- )
64
+ return model
84
65
 
85
66
 
86
- def entity_model_from_markdown(file_path: Path, markdown: EntityMarkdown, entity: Optional[Entity] = None) -> Entity:
67
+ async def schema_to_markdown(schema: Any) -> Post:
87
68
  """
88
- Convert markdown entity to model.
89
- Does not include relations.
69
+ Convert schema to markdown Post object.
90
70
 
91
71
  Args:
92
- markdown: Parsed markdown entity
93
- include_relations: Whether to include relations. Set False for first sync pass.
94
- """
72
+ schema: Schema to convert (must have title, entity_type, and permalink attributes)
95
73
 
96
- # Validate/default category
97
- def get_valid_category(obs):
98
- if not obs.category or obs.category not in [c.value for c in ObservationCategory]:
99
- return ObservationCategory.NOTE.value
100
- return obs.category
101
-
102
- permalink = markdown.frontmatter.permalink or generate_permalink(file_path)
103
- model = entity or Entity()
104
-
105
- model.title=markdown.frontmatter.title
106
- model.entity_type=markdown.frontmatter.type
107
- model.permalink=permalink
108
- model.file_path=str(file_path)
109
- model.content_type="text/markdown"
110
- model.created_at=markdown.created
111
- model.updated_at=markdown.modified
112
- model.entity_metadata={k:str(v) for k,v in markdown.frontmatter.metadata.items()}
113
- model.observations=[
114
- ObservationModel(
115
- content=obs.content,
116
- category=get_valid_category(obs),
117
- context=obs.context,
118
- tags=obs.tags,
119
- )
120
- for obs in markdown.observations
121
- ]
122
-
123
- return model
124
-
125
- async def schema_to_markdown(schema):
126
- """
127
- Convert schema to markdown.
128
- :param schema: the schema to convert
129
- :return: Post
74
+ Returns:
75
+ Post object with frontmatter metadata
130
76
  """
131
- # Create Post object
77
+ # Extract content and metadata
132
78
  content = schema.content or ""
133
- frontmatter_metadata = schema.entity_metadata or {}
134
-
135
- # remove from map so we can define ordering in frontmatter
136
- if "type" in frontmatter_metadata:
137
- del frontmatter_metadata["type"]
138
- if "title" in frontmatter_metadata:
139
- del frontmatter_metadata["title"]
140
- if "permalink" in frontmatter_metadata:
141
- del frontmatter_metadata["permalink"]
142
-
143
- post = Post(content, title=schema.title, type=schema.entity_type, permalink=schema.permalink, **frontmatter_metadata)
79
+ frontmatter_metadata = dict(schema.entity_metadata or {})
80
+
81
+ # Remove special fields for ordered frontmatter
82
+ for field in ["type", "title", "permalink"]:
83
+ frontmatter_metadata.pop(field, None)
84
+
85
+ # Create Post with ordered fields
86
+ post = Post(
87
+ content,
88
+ title=schema.title,
89
+ type=schema.entity_type,
90
+ permalink=schema.permalink,
91
+ **frontmatter_metadata,
92
+ )
144
93
  return post
@@ -1 +1 @@
1
- """MCP server for basic-memory."""
1
+ """MCP server for basic-memory."""
@@ -6,5 +6,3 @@ BASE_URL = "http://test"
6
6
 
7
7
  # Create shared async client
8
8
  client = AsyncClient(transport=ASGITransport(app=fastapi_app), base_url=BASE_URL)
9
-
10
-
@@ -1,39 +1,15 @@
1
1
  """Enhanced FastMCP server instance for Basic Memory."""
2
- import sys
3
2
 
4
- from loguru import logger
5
3
  from mcp.server.fastmcp import FastMCP
6
- from mcp.server.fastmcp.utilities.logging import configure_logging
7
4
 
8
- from basic_memory.config import config
5
+ from basic_memory.utils import setup_logging
9
6
 
10
7
  # mcp console logging
11
- configure_logging(level="INFO")
8
+ # configure_logging(level='INFO')
12
9
 
13
10
 
14
- def setup_logging(home_dir: str = config.home, log_file: str = ".basic-memory/basic-memory.log"):
15
- """Configure file logging to the basic-memory home directory."""
16
- log = f"{home_dir}/{log_file}"
17
-
18
- # Add file handler with rotation
19
- logger.add(
20
- log,
21
- rotation="100 MB",
22
- retention="10 days",
23
- backtrace=True,
24
- diagnose=True,
25
- enqueue=True,
26
- colorize=False,
27
- )
28
-
29
- # Add stderr handler
30
- logger.add(
31
- sys.stderr,
32
- colorize=True,
33
- )
34
-
35
11
  # start our out file logging
36
- setup_logging()
12
+ setup_logging(log_file=".basic-memory/basic-memory.log")
37
13
 
38
14
  # Create the shared server instance
39
15
  mcp = FastMCP("Basic Memory")
@@ -7,8 +7,10 @@ all tools with the MCP server.
7
7
 
8
8
  # Import tools to register them with MCP
9
9
  from basic_memory.mcp.tools.memory import build_context, recent_activity
10
- #from basic_memory.mcp.tools.ai_edit import ai_edit
10
+
11
+ # from basic_memory.mcp.tools.ai_edit import ai_edit
11
12
  from basic_memory.mcp.tools.notes import read_note, write_note
13
+ from basic_memory.mcp.tools.search import search
12
14
 
13
15
  from basic_memory.mcp.tools.knowledge import (
14
16
  delete_entities,
@@ -26,9 +28,9 @@ __all__ = [
26
28
  # memory tools
27
29
  "build_context",
28
30
  "recent_activity",
29
- #notes
31
+ # notes
30
32
  "read_note",
31
33
  "write_note",
32
34
  # file edit
33
- #"ai_edit",
35
+ # "ai_edit",
34
36
  ]
@@ -2,7 +2,7 @@
2
2
 
3
3
  from basic_memory.mcp.server import mcp
4
4
  from basic_memory.mcp.tools.utils import call_get, call_post
5
- from basic_memory.schemas.base import PathId
5
+ from basic_memory.schemas.base import Permalink
6
6
  from basic_memory.schemas.request import (
7
7
  GetEntitiesRequest,
8
8
  )
@@ -16,7 +16,7 @@ from basic_memory.mcp.async_client import client
16
16
  @mcp.tool(
17
17
  description="Get complete information about a specific entity including observations and relations",
18
18
  )
19
- async def get_entity(permalink: PathId) -> EntityResponse:
19
+ async def get_entity(permalink: Permalink) -> EntityResponse:
20
20
  """Get a specific entity info by its permalink.
21
21
 
22
22
  Args:
@@ -7,9 +7,13 @@ from loguru import logger
7
7
  from basic_memory.mcp.async_client import client
8
8
  from basic_memory.mcp.server import mcp
9
9
  from basic_memory.mcp.tools.utils import call_get
10
- from basic_memory.schemas.memory import GraphContext, MemoryUrl, memory_url, memory_url_path, normalize_memory_url
10
+ from basic_memory.schemas.memory import (
11
+ GraphContext,
12
+ MemoryUrl,
13
+ memory_url_path,
14
+ normalize_memory_url,
15
+ )
11
16
  from basic_memory.schemas.base import TimeFrame
12
- from basic_memory.schemas.search import SearchItemType
13
17
 
14
18
 
15
19
  @mcp.tool(
@@ -84,7 +88,7 @@ async def build_context(
84
88
  """,
85
89
  )
86
90
  async def recent_activity(
87
- type: List[Literal["entity", "observation", "relation"]] = None,
91
+ type: List[Literal["entity", "observation", "relation"]] = [],
88
92
  depth: Optional[int] = 1,
89
93
  timeframe: Optional[TimeFrame] = "7d",
90
94
  max_results: int = 10,
@@ -136,7 +140,7 @@ async def recent_activity(
136
140
  "timeframe": timeframe,
137
141
  "max_results": max_results,
138
142
  }
139
- if type:
143
+ if type:
140
144
  params["type"] = type
141
145
 
142
146
  response = await call_get(
@@ -1,4 +1,5 @@
1
1
  """Search tools for Basic Memory MCP server."""
2
+
2
3
  from loguru import logger
3
4
 
4
5
  from basic_memory.mcp.server import mcp
@@ -24,5 +25,5 @@ async def search(query: SearchQuery) -> SearchResponse:
24
25
  SearchResponse with search results and metadata
25
26
  """
26
27
  logger.info(f"Searching for {query.text}")
27
- response = await call_post(client,"/search/", json=query.model_dump())
28
+ response = await call_post(client, "/search/", json=query.model_dump())
28
29
  return SearchResponse.model_validate(response.json())
@@ -45,7 +45,7 @@ async def call_get(
45
45
  return response
46
46
  except HTTPStatusError as e:
47
47
  logger.error(f"Error calling GET {url}: {e}")
48
- raise ToolError(f"Error calling tool: {e}. Response: {response.text}") from e
48
+ raise ToolError(f"Error calling tool: {e}.") from e
49
49
 
50
50
 
51
51
  async def call_put(
@@ -2,7 +2,7 @@
2
2
 
3
3
  import basic_memory
4
4
  from basic_memory.models.base import Base
5
- from basic_memory.models.knowledge import Entity, Observation, Relation, ObservationCategory
5
+ from basic_memory.models.knowledge import Entity, Observation, Relation
6
6
 
7
7
  SCHEMA_VERSION = basic_memory.__version__ + "-" + "003"
8
8
 
@@ -10,6 +10,5 @@ __all__ = [
10
10
  "Base",
11
11
  "Entity",
12
12
  "Observation",
13
- "ObservationCategory",
14
13
  "Relation",
15
14
  ]
@@ -1,10 +1,10 @@
1
1
  """Base model class for SQLAlchemy models."""
2
- from sqlalchemy import String, Integer
2
+
3
3
  from sqlalchemy.ext.asyncio import AsyncAttrs
4
- from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
4
+ from sqlalchemy.orm import DeclarativeBase
5
5
 
6
6
 
7
7
  class Base(AsyncAttrs, DeclarativeBase):
8
8
  """Base class for all models"""
9
- pass
10
9
 
10
+ pass
@@ -1,6 +1,5 @@
1
1
  """Knowledge graph models."""
2
2
 
3
- import re
4
3
  from datetime import datetime
5
4
  from typing import Optional
6
5
 
@@ -14,17 +13,15 @@ from sqlalchemy import (
14
13
  Index,
15
14
  JSON,
16
15
  )
17
- from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
16
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
18
17
 
19
18
  from basic_memory.models.base import Base
20
- from enum import Enum
21
19
 
22
20
  from basic_memory.utils import generate_permalink
23
21
 
24
22
 
25
23
  class Entity(Base):
26
- """
27
- Core entity in the knowledge graph.
24
+ """Core entity in the knowledge graph.
28
25
 
29
26
  Entities represent semantic nodes maintained by the AI layer. Each entity:
30
27
  - Has a unique numeric ID (database-generated)
@@ -79,44 +76,15 @@ class Entity(Base):
79
76
 
80
77
  @property
81
78
  def relations(self):
79
+ """Get all relations (incoming and outgoing) for this entity."""
82
80
  return self.incoming_relations + self.outgoing_relations
83
81
 
84
- @validates("permalink")
85
- def validate_permalink(self, key, value):
86
- """Validate permalink format.
87
-
88
- Requirements:
89
- 1. Must be valid URI path component
90
- 2. Only lowercase letters, numbers, and hyphens (no underscores)
91
- 3. Path segments separated by forward slashes
92
- 4. No leading/trailing hyphens in segments
93
- """
94
- if not value:
95
- raise ValueError("Permalink must not be None")
96
-
97
- if not re.match(r"^[a-z0-9][a-z0-9\-/]*[a-z0-9]$", value):
98
- raise ValueError(
99
- f"Invalid permalink format: {value}. "
100
- "Use only lowercase letters, numbers, and hyphens."
101
- )
102
- return value
103
-
104
82
  def __repr__(self) -> str:
105
83
  return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"
106
84
 
107
85
 
108
- class ObservationCategory(str, Enum):
109
- TECH = "tech"
110
- DESIGN = "design"
111
- FEATURE = "feature"
112
- NOTE = "note"
113
- ISSUE = "issue"
114
- TODO = "todo"
115
-
116
-
117
86
  class Observation(Base):
118
- """
119
- An observation about an entity.
87
+ """An observation about an entity.
120
88
 
121
89
  Observations are atomic facts or notes about an entity.
122
90
  """
@@ -130,13 +98,8 @@ class Observation(Base):
130
98
  id: Mapped[int] = mapped_column(Integer, primary_key=True)
131
99
  entity_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
132
100
  content: Mapped[str] = mapped_column(Text)
133
- category: Mapped[str] = mapped_column(
134
- String,
135
- nullable=False,
136
- default=ObservationCategory.NOTE.value,
137
- server_default=ObservationCategory.NOTE.value,
138
- )
139
- context: Mapped[str] = mapped_column(Text, nullable=True)
101
+ category: Mapped[str] = mapped_column(String, nullable=False, default="note")
102
+ context: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
140
103
  tags: Mapped[Optional[list[str]]] = mapped_column(
141
104
  JSON, nullable=True, default=list, server_default="[]"
142
105
  )
@@ -146,23 +109,21 @@ class Observation(Base):
146
109
 
147
110
  @property
148
111
  def permalink(self) -> str:
149
- """
150
- Create synthetic permalink for the observation
151
- We can construct these because observations are always
152
- defined in and owned by a single entity
112
+ """Create synthetic permalink for the observation.
113
+
114
+ We can construct these because observations are always defined in
115
+ and owned by a single entity.
153
116
  """
154
117
  return generate_permalink(
155
118
  f"{self.entity.permalink}/observations/{self.category}/{self.content}"
156
119
  )
157
120
 
158
- def __repr__(self) -> str:
121
+ def __repr__(self) -> str: # pragma: no cover
159
122
  return f"Observation(id={self.id}, entity_id={self.entity_id}, content='{self.content}')"
160
123
 
161
124
 
162
125
  class Relation(Base):
163
- """
164
- A directed relation between two entities.
165
- """
126
+ """A directed relation between two entities."""
166
127
 
167
128
  __tablename__ = "relation"
168
129
  __table_args__ = (
@@ -174,12 +135,12 @@ class Relation(Base):
174
135
 
175
136
  id: Mapped[int] = mapped_column(Integer, primary_key=True)
176
137
  from_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
177
- to_id: Mapped[int] = mapped_column(
138
+ to_id: Mapped[Optional[int]] = mapped_column(
178
139
  Integer, ForeignKey("entity.id", ondelete="CASCADE"), nullable=True
179
140
  )
180
141
  to_name: Mapped[str] = mapped_column(String)
181
142
  relation_type: Mapped[str] = mapped_column(String)
182
- context: Mapped[str] = mapped_column(Text, nullable=True)
143
+ context: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
183
144
 
184
145
  # Relationships
185
146
  from_entity = relationship(
@@ -189,15 +150,17 @@ class Relation(Base):
189
150
 
190
151
  @property
191
152
  def permalink(self) -> str:
192
- """Create relation permalink showing the semantic connection:
193
- source/relation_type/target
194
- e.g., "specs/search/implements/features/search-ui"
195
- """
153
+ """Create relation permalink showing the semantic connection.
196
154
 
155
+ Format: source/relation_type/target
156
+ Example: "specs/search/implements/features/search-ui"
157
+ """
158
+ if self.to_entity:
159
+ return generate_permalink(
160
+ f"{self.from_entity.permalink}/{self.relation_type}/{self.to_entity.permalink}"
161
+ )
197
162
  return generate_permalink(
198
- f"{self.from_entity.permalink}/{self.relation_type}/{self.to_entity.permalink}"
199
- if self.to_entity
200
- else f"{self.from_entity.permalink}/{self.relation_type}/{self.to_name}"
163
+ f"{self.from_entity.permalink}/{self.relation_type}/{self.to_name}"
201
164
  )
202
165
 
203
166
  def __repr__(self) -> str:
@@ -31,4 +31,4 @@ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
31
31
  tokenize='unicode61 tokenchars 0x2F', -- Hex code for /
32
32
  prefix='1,2,3,4' -- Support longer prefixes for paths
33
33
  );
34
- """)
34
+ """)
@@ -2,6 +2,8 @@ from .entity_repository import EntityRepository
2
2
  from .observation_repository import ObservationRepository
3
3
  from .relation_repository import RelationRepository
4
4
 
5
- __all__ = ["EntityRepository", "ObservationRepository", "RelationRepository", ]
6
-
7
-
5
+ __all__ = [
6
+ "EntityRepository",
7
+ "ObservationRepository",
8
+ "RelationRepository",
9
+ ]