Skip to content

Commit 28cc522

Browse files
jope-bmclaudephernandez
authored
feat: Implement API v2 with ID-based endpoints (Phase 1) (#441)
Signed-off-by: Joe P <joe@basicmemory.com> Signed-off-by: phernandez <paul@basicmachines.co> Signed-off-by: Claude <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Paul Hernandez <60959+phernandez@users.noreply.github.com> Co-authored-by: phernandez <paul@basicmachines.co>
1 parent 9b7bbc7 commit 28cc522

44 files changed

Lines changed: 4875 additions & 20 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,5 @@ ENV/
5252

5353
# claude action
5454
claude-output
55-
**/.claude/settings.local.json
55+
**/.claude/settings.local.json
56+
.mcp.json

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,5 +264,6 @@ With GitHub integration, the development workflow includes:
264264
2. **Contribution tracking** - All of Claude's contributions are properly attributed in the Git history
265265
3. **Branch management** - Claude can create feature branches for implementations
266266
4. **Documentation maintenance** - Claude can keep documentation updated as the code evolves
267+
5. **Code Commits**: ALWAYS sign off commits with `git commit -s`
267268

268269
This level of integration represents a new paradigm in AI-human collaboration, where the AI assistant becomes a full-fledged team member rather than just a tool for generating code snippets.

src/basic_memory/api/app.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@
2020
search,
2121
prompt_router,
2222
)
23+
from basic_memory.api.v2.routers import (
24+
knowledge_router as v2_knowledge,
25+
project_router as v2_project,
26+
memory_router as v2_memory,
27+
search_router as v2_search,
28+
resource_router as v2_resource,
29+
directory_router as v2_directory,
30+
prompt_router as v2_prompt,
31+
importer_router as v2_importer,
32+
)
2333
from basic_memory.config import ConfigManager
2434
from basic_memory.services.initialization import initialize_file_sync, initialize_app
2535

@@ -66,8 +76,7 @@ async def lifespan(app: FastAPI): # pragma: no cover
6676
lifespan=lifespan,
6777
)
6878

69-
70-
# Include routers
79+
# Include v1 routers
7180
app.include_router(knowledge.router, prefix="/{project}")
7281
app.include_router(memory.router, prefix="/{project}")
7382
app.include_router(resource.router, prefix="/{project}")
@@ -77,7 +86,17 @@ async def lifespan(app: FastAPI): # pragma: no cover
7786
app.include_router(prompt_router.router, prefix="/{project}")
7887
app.include_router(importer_router.router, prefix="/{project}")
7988

80-
# Project resource router works accross projects
89+
# Include v2 routers (ID-based paths)
90+
app.include_router(v2_knowledge, prefix="/v2/projects/{project_id}")
91+
app.include_router(v2_memory, prefix="/v2/projects/{project_id}")
92+
app.include_router(v2_search, prefix="/v2/projects/{project_id}")
93+
app.include_router(v2_resource, prefix="/v2/projects/{project_id}")
94+
app.include_router(v2_directory, prefix="/v2/projects/{project_id}")
95+
app.include_router(v2_prompt, prefix="/v2/projects/{project_id}")
96+
app.include_router(v2_importer, prefix="/v2/projects/{project_id}")
97+
app.include_router(v2_project, prefix="/v2")
98+
99+
# Project resource router works across projects
81100
app.include_router(project.project_resource_router)
82101
app.include_router(management.router)
83102

src/basic_memory/api/routers/knowledge_router.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
"""Router for knowledge graph operations."""
1+
"""Router for knowledge graph operations.
2+
3+
⚠️ DEPRECATED: This v1 API is deprecated and will be removed on June 30, 2026.
4+
Please migrate to /v2/{project}/knowledge endpoints which use entity IDs instead
5+
of path-based identifiers for improved performance and stability.
6+
7+
Migration guide: See docs/migration/v1-to-v2.md
8+
"""
29

310
from typing import Annotated
411

@@ -25,7 +32,11 @@
2532
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest
2633
from basic_memory.schemas.base import Permalink, Entity
2734

28-
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
35+
router = APIRouter(
36+
prefix="/knowledge",
37+
tags=["knowledge"],
38+
deprecated=True, # Marks entire router as deprecated in OpenAPI docs
39+
)
2940

3041

3142
async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:

src/basic_memory/api/routers/project_router.py

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ async def get_project(
5050
) # pragma: no cover
5151

5252
return ProjectItem(
53+
id=found_project.id,
5354
name=found_project.name,
5455
path=normalize_project_path(found_project.path),
5556
is_default=found_project.is_default or False,
@@ -80,9 +81,17 @@ async def update_project(
8081
raise HTTPException(status_code=400, detail="Path must be absolute")
8182

8283
# Get original project info for the response
84+
old_project = await project_service.get_project(name)
85+
if not old_project:
86+
raise HTTPException(
87+
status_code=400, detail=f"Project '{name}' not found in configuration"
88+
)
89+
8390
old_project_info = ProjectItem(
84-
name=name,
85-
path=project_service.projects.get(name, ""),
91+
id=old_project.id,
92+
name=old_project.name,
93+
path=old_project.path,
94+
is_default=old_project.is_default or False,
8695
)
8796

8897
if path:
@@ -91,14 +100,21 @@ async def update_project(
91100
await project_service.update_project(name, is_active=is_active)
92101

93102
# Get updated project info
94-
updated_path = path if path else project_service.projects.get(name, "")
103+
updated_project = await project_service.get_project(name)
104+
if not updated_project:
105+
raise HTTPException(status_code=404, detail=f"Project '{name}' not found after update")
95106

96107
return ProjectStatusResponse(
97108
message=f"Project '{name}' updated successfully",
98109
status="success",
99110
default=(name == project_service.default_project),
100111
old_project=old_project_info,
101-
new_project=ProjectItem(name=name, path=updated_path),
112+
new_project=ProjectItem(
113+
id=updated_project.id,
114+
name=updated_project.name,
115+
path=updated_project.path,
116+
is_default=updated_project.is_default or False,
117+
),
102118
)
103119
except ValueError as e:
104120
raise HTTPException(status_code=400, detail=str(e))
@@ -186,6 +202,7 @@ async def list_projects(
186202

187203
project_items = [
188204
ProjectItem(
205+
id=project.id,
189206
name=project.name,
190207
path=normalize_project_path(project.path),
191208
is_default=project.is_default or False,
@@ -232,6 +249,7 @@ async def add_project(
232249
status="success",
233250
default=existing_project.is_default or False,
234251
new_project=ProjectItem(
252+
id=existing_project.id,
235253
name=existing_project.name,
236254
path=existing_project.path,
237255
is_default=existing_project.is_default or False,
@@ -250,12 +268,20 @@ async def add_project(
250268
project_data.name, project_data.path, set_default=project_data.set_default
251269
)
252270

271+
# Fetch the newly created project to get its ID
272+
new_project = await project_service.get_project(project_data.name)
273+
if not new_project:
274+
raise HTTPException(status_code=500, detail="Failed to retrieve newly created project")
275+
253276
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
254277
message=f"Project '{project_data.name}' added successfully",
255278
status="success",
256279
default=project_data.set_default,
257280
new_project=ProjectItem(
258-
name=project_data.name, path=project_data.path, is_default=project_data.set_default
281+
id=new_project.id,
282+
name=new_project.name,
283+
path=new_project.path,
284+
is_default=new_project.is_default or False,
259285
),
260286
)
261287
except ValueError as e: # pragma: no cover
@@ -306,7 +332,12 @@ async def remove_project(
306332
message=f"Project '{name}' removed successfully",
307333
status="success",
308334
default=False,
309-
old_project=ProjectItem(name=old_project.name, path=old_project.path),
335+
old_project=ProjectItem(
336+
id=old_project.id,
337+
name=old_project.name,
338+
path=old_project.path,
339+
is_default=old_project.is_default or False,
340+
),
310341
new_project=None,
311342
)
312343
except ValueError as e: # pragma: no cover
@@ -349,8 +380,14 @@ async def set_default_project(
349380
message=f"Project '{name}' set as default successfully",
350381
status="success",
351382
default=True,
352-
old_project=ProjectItem(name=default_name, path=default_project.path),
383+
old_project=ProjectItem(
384+
id=default_project.id,
385+
name=default_name,
386+
path=default_project.path,
387+
is_default=False,
388+
),
353389
new_project=ProjectItem(
390+
id=new_default_project.id,
354391
name=name,
355392
path=new_default_project.path,
356393
is_default=True,
@@ -378,7 +415,12 @@ async def get_default_project(
378415
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
379416
)
380417

381-
return ProjectItem(name=default_project.name, path=default_project.path, is_default=True)
418+
return ProjectItem(
419+
id=default_project.id,
420+
name=default_project.name,
421+
path=default_project.path,
422+
is_default=True,
423+
)
382424

383425

384426
# Synchronize projects between config and database

src/basic_memory/api/routers/utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ async def to_summary(item: SearchIndexRow | ContextResultRow):
2929
match item.type:
3030
case SearchItemType.ENTITY:
3131
return EntitySummary(
32+
entity_id=item.id,
3233
title=item.title, # pyright: ignore
3334
permalink=item.permalink,
3435
content=item.content,
@@ -37,6 +38,8 @@ async def to_summary(item: SearchIndexRow | ContextResultRow):
3738
)
3839
case SearchItemType.OBSERVATION:
3940
return ObservationSummary(
41+
observation_id=item.id,
42+
entity_id=item.entity_id, # pyright: ignore
4043
title=item.title, # pyright: ignore
4144
file_path=item.file_path,
4245
category=item.category, # pyright: ignore
@@ -48,12 +51,16 @@ async def to_summary(item: SearchIndexRow | ContextResultRow):
4851
from_entity = await entity_repository.find_by_id(item.from_id) # pyright: ignore
4952
to_entity = await entity_repository.find_by_id(item.to_id) if item.to_id else None
5053
return RelationSummary(
54+
relation_id=item.id,
55+
entity_id=item.entity_id, # pyright: ignore
5156
title=item.title, # pyright: ignore
5257
file_path=item.file_path,
5358
permalink=item.permalink, # pyright: ignore
5459
relation_type=item.relation_type, # pyright: ignore
5560
from_entity=from_entity.title if from_entity else None,
61+
from_entity_id=item.from_id, # pyright: ignore
5662
to_entity=to_entity.title if to_entity else None,
63+
to_entity_id=item.to_id,
5764
created_at=item.created_at,
5865
)
5966
case _: # pragma: no cover
@@ -111,6 +118,21 @@ async def to_search_results(entity_service: EntityService, results: List[SearchI
111118
search_results = []
112119
for r in results:
113120
entities = await entity_service.get_entities_by_id([r.entity_id, r.from_id, r.to_id]) # pyright: ignore
121+
122+
# Determine which IDs to set based on type
123+
entity_id = None
124+
observation_id = None
125+
relation_id = None
126+
127+
if r.type == SearchItemType.ENTITY:
128+
entity_id = r.id
129+
elif r.type == SearchItemType.OBSERVATION:
130+
observation_id = r.id
131+
entity_id = r.entity_id # Parent entity
132+
elif r.type == SearchItemType.RELATION:
133+
relation_id = r.id
134+
entity_id = r.entity_id # Parent entity
135+
114136
search_results.append(
115137
SearchResult(
116138
title=r.title, # pyright: ignore
@@ -121,6 +143,9 @@ async def to_search_results(entity_service: EntityService, results: List[SearchI
121143
content=r.content,
122144
file_path=r.file_path,
123145
metadata=r.metadata,
146+
entity_id=entity_id,
147+
observation_id=observation_id,
148+
relation_id=relation_id,
124149
category=r.category,
125150
from_entity=entities[0].permalink if entities else None,
126151
to_entity=entities[1].permalink if len(entities) > 1 else None,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""API v2 module - ID-based entity references.
2+
3+
Version 2 of the Basic Memory API uses integer entity IDs as the primary
4+
identifier for improved performance and stability.
5+
6+
Key changes from v1:
7+
- Entity lookups use integer IDs instead of paths/permalinks
8+
- Direct database queries instead of cascading resolution
9+
- Stable references that don't change with file moves
10+
- Better caching support
11+
12+
All v2 routers are registered with the /v2 prefix.
13+
"""
14+
15+
from basic_memory.api.v2.routers import (
16+
knowledge_router,
17+
memory_router,
18+
project_router,
19+
resource_router,
20+
search_router,
21+
directory_router,
22+
prompt_router,
23+
importer_router,
24+
)
25+
26+
__all__ = [
27+
"knowledge_router",
28+
"memory_router",
29+
"project_router",
30+
"resource_router",
31+
"search_router",
32+
"directory_router",
33+
"prompt_router",
34+
"importer_router",
35+
]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""V2 API routers."""
2+
3+
from basic_memory.api.v2.routers.knowledge_router import router as knowledge_router
4+
from basic_memory.api.v2.routers.project_router import router as project_router
5+
from basic_memory.api.v2.routers.memory_router import router as memory_router
6+
from basic_memory.api.v2.routers.search_router import router as search_router
7+
from basic_memory.api.v2.routers.resource_router import router as resource_router
8+
from basic_memory.api.v2.routers.directory_router import router as directory_router
9+
from basic_memory.api.v2.routers.prompt_router import router as prompt_router
10+
from basic_memory.api.v2.routers.importer_router import router as importer_router
11+
12+
__all__ = [
13+
"knowledge_router",
14+
"project_router",
15+
"memory_router",
16+
"search_router",
17+
"resource_router",
18+
"directory_router",
19+
"prompt_router",
20+
"importer_router",
21+
]

0 commit comments

Comments
 (0)