Skip to content

Commit d2bd75a

Browse files
authored
feat: add project_info tool (#19)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 90d5754 commit d2bd75a

20 files changed

Lines changed: 956 additions & 45 deletions

src/basic_memory/api/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from basic_memory import db
1616
from basic_memory.config import config as app_config
17-
from basic_memory.api.routers import knowledge, search, memory, resource
17+
from basic_memory.api.routers import knowledge, search, memory, resource, project_info
1818

1919

2020
@asynccontextmanager
@@ -43,6 +43,7 @@ async def lifespan(app: FastAPI): # pragma: no cover
4343
app.include_router(search.router)
4444
app.include_router(memory.router)
4545
app.include_router(resource.router)
46+
app.include_router(project_info.router)
4647

4748

4849
@app.exception_handler(Exception)

src/basic_memory/api/routers/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
from . import memory_router as memory
55
from . import resource_router as resource
66
from . import search_router as search
7+
from . import project_info_router as project_info
78

8-
__all__ = ["knowledge", "memory", "resource", "search"]
9+
__all__ = ["knowledge", "memory", "resource", "search", "project_info"]
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
"""Router for statistics and system information."""
2+
3+
import json
4+
from datetime import datetime
5+
6+
from fastapi import APIRouter
7+
from sqlalchemy import text
8+
9+
from basic_memory.config import config, config_manager
10+
from basic_memory.deps import (
11+
ProjectInfoRepositoryDep,
12+
)
13+
from basic_memory.repository.project_info_repository import ProjectInfoRepository
14+
from basic_memory.schemas import (
15+
ProjectInfoResponse,
16+
ProjectStatistics,
17+
ActivityMetrics,
18+
SystemStatus,
19+
)
20+
from basic_memory.sync.watch_service import WATCH_STATUS_JSON
21+
22+
router = APIRouter(prefix="/stats", tags=["statistics"])
23+
24+
25+
@router.get("/project-info", response_model=ProjectInfoResponse)
26+
async def get_project_info(
27+
repository: ProjectInfoRepositoryDep,
28+
) -> ProjectInfoResponse:
29+
"""Get comprehensive information about the current Basic Memory project."""
30+
# Get statistics
31+
statistics = await get_statistics(repository)
32+
33+
# Get activity metrics
34+
activity = await get_activity_metrics(repository)
35+
36+
# Get system status
37+
system = await get_system_status()
38+
39+
# Get project configuration information
40+
project_name = config.project
41+
project_path = str(config.home)
42+
available_projects = config_manager.projects
43+
default_project = config_manager.default_project
44+
45+
# Construct the response
46+
return ProjectInfoResponse(
47+
project_name=project_name,
48+
project_path=project_path,
49+
available_projects=available_projects,
50+
default_project=default_project,
51+
statistics=statistics,
52+
activity=activity,
53+
system=system,
54+
)
55+
56+
57+
async def get_statistics(repository: ProjectInfoRepository) -> ProjectStatistics:
58+
"""Get statistics about the current project."""
59+
# Get basic counts
60+
entity_count_result = await repository.execute_query(text("SELECT COUNT(*) FROM entity"))
61+
total_entities = entity_count_result.scalar() or 0
62+
63+
observation_count_result = await repository.execute_query(
64+
text("SELECT COUNT(*) FROM observation")
65+
)
66+
total_observations = observation_count_result.scalar() or 0
67+
68+
relation_count_result = await repository.execute_query(text("SELECT COUNT(*) FROM relation"))
69+
total_relations = relation_count_result.scalar() or 0
70+
71+
unresolved_count_result = await repository.execute_query(
72+
text("SELECT COUNT(*) FROM relation WHERE to_id IS NULL")
73+
)
74+
total_unresolved = unresolved_count_result.scalar() or 0
75+
76+
# Get entity counts by type
77+
entity_types_result = await repository.execute_query(
78+
text("SELECT entity_type, COUNT(*) FROM entity GROUP BY entity_type")
79+
)
80+
entity_types = {row[0]: row[1] for row in entity_types_result.fetchall()}
81+
82+
# Get observation counts by category
83+
category_result = await repository.execute_query(
84+
text("SELECT category, COUNT(*) FROM observation GROUP BY category")
85+
)
86+
observation_categories = {row[0]: row[1] for row in category_result.fetchall()}
87+
88+
# Get relation counts by type
89+
relation_types_result = await repository.execute_query(
90+
text("SELECT relation_type, COUNT(*) FROM relation GROUP BY relation_type")
91+
)
92+
relation_types = {row[0]: row[1] for row in relation_types_result.fetchall()}
93+
94+
# Find most connected entities (most outgoing relations)
95+
connected_result = await repository.execute_query(
96+
text("""
97+
SELECT e.id, e.title, e.permalink, COUNT(r.id) AS relation_count
98+
FROM entity e
99+
JOIN relation r ON e.id = r.from_id
100+
GROUP BY e.id
101+
ORDER BY relation_count DESC
102+
LIMIT 10
103+
""")
104+
)
105+
most_connected = [
106+
{"id": row[0], "title": row[1], "permalink": row[2], "relation_count": row[3]}
107+
for row in connected_result.fetchall()
108+
]
109+
110+
# Count isolated entities (no relations)
111+
isolated_result = await repository.execute_query(
112+
text("""
113+
SELECT COUNT(e.id)
114+
FROM entity e
115+
LEFT JOIN relation r1 ON e.id = r1.from_id
116+
LEFT JOIN relation r2 ON e.id = r2.to_id
117+
WHERE r1.id IS NULL AND r2.id IS NULL
118+
""")
119+
)
120+
isolated_count = isolated_result.scalar() or 0
121+
122+
return ProjectStatistics(
123+
total_entities=total_entities,
124+
total_observations=total_observations,
125+
total_relations=total_relations,
126+
total_unresolved_relations=total_unresolved,
127+
entity_types=entity_types,
128+
observation_categories=observation_categories,
129+
relation_types=relation_types,
130+
most_connected_entities=most_connected,
131+
isolated_entities=isolated_count,
132+
)
133+
134+
135+
async def get_activity_metrics(repository: ProjectInfoRepository) -> ActivityMetrics:
136+
"""Get activity metrics for the current project."""
137+
# Get recently created entities
138+
created_result = await repository.execute_query(
139+
text("""
140+
SELECT id, title, permalink, entity_type, created_at
141+
FROM entity
142+
ORDER BY created_at DESC
143+
LIMIT 10
144+
""")
145+
)
146+
recently_created = [
147+
{
148+
"id": row[0],
149+
"title": row[1],
150+
"permalink": row[2],
151+
"entity_type": row[3],
152+
"created_at": row[4],
153+
}
154+
for row in created_result.fetchall()
155+
]
156+
157+
# Get recently updated entities
158+
updated_result = await repository.execute_query(
159+
text("""
160+
SELECT id, title, permalink, entity_type, updated_at
161+
FROM entity
162+
ORDER BY updated_at DESC
163+
LIMIT 10
164+
""")
165+
)
166+
recently_updated = [
167+
{
168+
"id": row[0],
169+
"title": row[1],
170+
"permalink": row[2],
171+
"entity_type": row[3],
172+
"updated_at": row[4],
173+
}
174+
for row in updated_result.fetchall()
175+
]
176+
177+
# Get monthly growth over the last 6 months
178+
# Calculate the start of 6 months ago
179+
now = datetime.now()
180+
six_months_ago = datetime(
181+
now.year - (1 if now.month <= 6 else 0), ((now.month - 6) % 12) or 12, 1
182+
)
183+
184+
# Query for monthly entity creation
185+
entity_growth_result = await repository.execute_query(
186+
text(f"""
187+
SELECT
188+
strftime('%Y-%m', created_at) AS month,
189+
COUNT(*) AS count
190+
FROM entity
191+
WHERE created_at >= '{six_months_ago.isoformat()}'
192+
GROUP BY month
193+
ORDER BY month
194+
""")
195+
)
196+
entity_growth = {row[0]: row[1] for row in entity_growth_result.fetchall()}
197+
198+
# Query for monthly observation creation
199+
observation_growth_result = await repository.execute_query(
200+
text(f"""
201+
SELECT
202+
strftime('%Y-%m', created_at) AS month,
203+
COUNT(*) AS count
204+
FROM observation
205+
INNER JOIN entity ON observation.entity_id = entity.id
206+
WHERE entity.created_at >= '{six_months_ago.isoformat()}'
207+
GROUP BY month
208+
ORDER BY month
209+
""")
210+
)
211+
observation_growth = {row[0]: row[1] for row in observation_growth_result.fetchall()}
212+
213+
# Query for monthly relation creation
214+
relation_growth_result = await repository.execute_query(
215+
text(f"""
216+
SELECT
217+
strftime('%Y-%m', created_at) AS month,
218+
COUNT(*) AS count
219+
FROM relation
220+
INNER JOIN entity ON relation.from_id = entity.id
221+
WHERE entity.created_at >= '{six_months_ago.isoformat()}'
222+
GROUP BY month
223+
ORDER BY month
224+
""")
225+
)
226+
relation_growth = {row[0]: row[1] for row in relation_growth_result.fetchall()}
227+
228+
# Combine all monthly growth data
229+
monthly_growth = {}
230+
for month in set(
231+
list(entity_growth.keys()) + list(observation_growth.keys()) + list(relation_growth.keys())
232+
):
233+
monthly_growth[month] = {
234+
"entities": entity_growth.get(month, 0),
235+
"observations": observation_growth.get(month, 0),
236+
"relations": relation_growth.get(month, 0),
237+
"total": (
238+
entity_growth.get(month, 0)
239+
+ observation_growth.get(month, 0)
240+
+ relation_growth.get(month, 0)
241+
),
242+
}
243+
244+
return ActivityMetrics(
245+
recently_created=recently_created,
246+
recently_updated=recently_updated,
247+
monthly_growth=monthly_growth,
248+
)
249+
250+
251+
async def get_system_status() -> SystemStatus:
252+
"""Get system status information."""
253+
import basic_memory
254+
255+
# Get database information
256+
db_path = config.database_path
257+
db_size = db_path.stat().st_size if db_path.exists() else 0
258+
db_size_readable = f"{db_size / (1024 * 1024):.2f} MB"
259+
260+
# Get watch service status if available
261+
watch_status = None
262+
watch_status_path = config.home / ".basic-memory" / WATCH_STATUS_JSON
263+
if watch_status_path.exists():
264+
try:
265+
watch_status = json.loads(watch_status_path.read_text())
266+
except Exception: # pragma: no cover
267+
pass
268+
269+
return SystemStatus(
270+
version=basic_memory.__version__,
271+
database_path=str(db_path),
272+
database_size=db_size_readable,
273+
watch_status=watch_status,
274+
timestamp=datetime.now(),
275+
)

src/basic_memory/cli/commands/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""CLI commands for basic-memory."""
22

33
from . import status, sync, db, import_memory_json, mcp, import_claude_conversations
4-
from . import import_claude_projects, import_chatgpt, tool, project
4+
from . import import_claude_projects, import_chatgpt, tool, project, project_info
55

66
__all__ = [
77
"status",
@@ -14,4 +14,5 @@
1414
"import_chatgpt",
1515
"tool",
1616
"project",
17+
"project_info",
1718
]

0 commit comments

Comments
 (0)