Skip to content

Commit 530cbac

Browse files
authored
feat: fast edit entities, refactors for webui, enhance search (#532)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent e3ced49 commit 530cbac

85 files changed

Lines changed: 1851 additions & 7122 deletions

File tree

Some content is hidden

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

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,8 @@ list_directory(dir_name, depth) - Browse directory contents with filtering
396396
**Search & Discovery:**
397397
```
398398
search(query, page, page_size) - Search across your knowledge base
399+
search_notes(query, page, page_size, search_type, types, entity_types, after_date, metadata_filters, tags, status, project) - Search with filters
400+
search_by_metadata(filters, limit, offset, project) - Structured frontmatter search
399401
```
400402

401403
**Project Management:**

docs/ARCHITECTURE.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,15 +214,28 @@ Example tool using typed client:
214214

215215
```python
216216
@mcp.tool()
217-
async def search_notes(query: str, project: str | None = None) -> SearchResponse:
217+
async def search_notes(
218+
query: str,
219+
project: str | None = None,
220+
metadata_filters: dict | None = None,
221+
tags: list[str] | None = None,
222+
status: str | None = None,
223+
) -> SearchResponse:
218224
async with get_client() as client:
219225
active_project = await get_active_project(client, project)
220226

221227
# Import client inside function to avoid circular imports
222228
from basic_memory.mcp.clients import SearchClient
229+
from basic_memory.schemas.search import SearchQuery
223230

231+
search_query = SearchQuery(
232+
text=query,
233+
metadata_filters=metadata_filters,
234+
tags=tags,
235+
status=status,
236+
)
224237
search_client = SearchClient(client, active_project.external_id)
225-
return await search_client.search(query)
238+
return await search_client.search(search_query.model_dump())
226239
```
227240

228241
## Sync Coordination

docs/ai-assistant-guide-extended.md

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1038,6 +1038,35 @@ recent_decisions = await search_notes(
10381038
)
10391039
```
10401040

1041+
**Structured frontmatter filters**:
1042+
1043+
```python
1044+
# Filter by tags and status
1045+
results = await search_notes(
1046+
query="authentication",
1047+
tags=["security"],
1048+
status="in-progress",
1049+
project="main"
1050+
)
1051+
1052+
# Complex metadata filters (supports $in, $gt, $gte, $lt, $lte, $between)
1053+
results = await search_notes(
1054+
query="api design",
1055+
metadata_filters={
1056+
"type": "spec",
1057+
"priority": {"$in": ["high", "critical"]},
1058+
"tags": ["architecture"]
1059+
},
1060+
project="main"
1061+
)
1062+
1063+
# Metadata-only search
1064+
results = await search_by_metadata(
1065+
filters={"type": "spec", "status": "in-progress"},
1066+
project="main"
1067+
)
1068+
```
1069+
10411070
### Search Types
10421071

10431072
**Text search (default)**:
@@ -2861,7 +2890,7 @@ contents = await list_directory(
28612890

28622891
### Search & Discovery
28632892

2864-
**search_notes(query, page, page_size, search_type, types, entity_types, after_date, project)**
2893+
**search_notes(query, page, page_size, search_type, types, entity_types, after_date, metadata_filters, tags, status, project)**
28652894
- Search across knowledge base
28662895
- Parameters:
28672896
- `query` (required): Search query
@@ -2871,6 +2900,9 @@ contents = await list_directory(
28712900
- `types` (optional): Entity type filter
28722901
- `entity_types` (optional): Observation category filter
28732902
- `after_date` (optional): Date filter (ISO format)
2903+
- `metadata_filters` (optional): Structured frontmatter filters (dict)
2904+
- `tags` (optional): Frontmatter tags filter (list)
2905+
- `status` (optional): Frontmatter status filter (string)
28742906
- `project` (required unless default_project_mode): Target project
28752907
- Returns: Matching entities with scores
28762908
- Example:
@@ -2883,6 +2915,22 @@ results = await search_notes(
28832915
)
28842916
```
28852917

2918+
**search_by_metadata(filters, limit, offset, project)**
2919+
- Metadata-only search using structured frontmatter
2920+
- Parameters:
2921+
- `filters` (required): Dict of field -> value (supports $in, $gt/$gte/$lt/$lte, $between)
2922+
- `limit` (optional): Max results (default: 20)
2923+
- `offset` (optional): Pagination offset (default: 0)
2924+
- `project` (required unless default_project_mode): Target project
2925+
- Returns: Matching entities
2926+
- Example:
2927+
```python
2928+
results = await search_by_metadata(
2929+
filters={"type": "spec", "status": "in-progress"},
2930+
project="main"
2931+
)
2932+
```
2933+
28862934
### Project Management
28872935

28882936
**list_memory_projects()**
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Add structured metadata indexes for entity frontmatter
2+
3+
Revision ID: d7e8f9a0b1c2
4+
Revises: g9a0b3c4d5e6
5+
Create Date: 2026-01-31 12:00:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
from sqlalchemy import text
14+
15+
16+
def column_exists(connection, table: str, column: str) -> bool:
17+
"""Check if a column exists in a table (idempotent migration support)."""
18+
if connection.dialect.name == "postgresql":
19+
result = connection.execute(
20+
text(
21+
"SELECT 1 FROM information_schema.columns "
22+
"WHERE table_name = :table AND column_name = :column"
23+
),
24+
{"table": table, "column": column},
25+
)
26+
return result.fetchone() is not None
27+
# SQLite
28+
result = connection.execute(text(f"PRAGMA table_info({table})"))
29+
columns = [row[1] for row in result]
30+
return column in columns
31+
32+
33+
def index_exists(connection, index_name: str) -> bool:
34+
"""Check if an index exists (idempotent migration support)."""
35+
if connection.dialect.name == "postgresql":
36+
result = connection.execute(
37+
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
38+
{"index_name": index_name},
39+
)
40+
return result.fetchone() is not None
41+
# SQLite
42+
result = connection.execute(
43+
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
44+
{"index_name": index_name},
45+
)
46+
return result.fetchone() is not None
47+
48+
49+
# revision identifiers, used by Alembic.
50+
revision: str = "d7e8f9a0b1c2"
51+
down_revision: Union[str, None] = "6830751f5fb6"
52+
branch_labels: Union[str, Sequence[str], None] = None
53+
depends_on: Union[str, Sequence[str], None] = None
54+
55+
56+
def upgrade() -> None:
57+
"""Add JSONB/GiN indexes for Postgres and generated columns for SQLite."""
58+
connection = op.get_bind()
59+
dialect = connection.dialect.name
60+
61+
if dialect == "postgresql":
62+
# Ensure JSONB for efficient indexing
63+
result = connection.execute(
64+
text(
65+
"SELECT data_type FROM information_schema.columns "
66+
"WHERE table_name = 'entity' AND column_name = 'entity_metadata'"
67+
)
68+
).fetchone()
69+
if result and result[0] != "jsonb":
70+
op.execute(
71+
"ALTER TABLE entity ALTER COLUMN entity_metadata "
72+
"TYPE jsonb USING entity_metadata::jsonb"
73+
)
74+
75+
# General JSONB GIN index
76+
op.execute(
77+
"CREATE INDEX IF NOT EXISTS idx_entity_metadata_gin "
78+
"ON entity USING GIN (entity_metadata jsonb_path_ops)"
79+
)
80+
81+
# Common field indexes
82+
op.execute(
83+
"CREATE INDEX IF NOT EXISTS idx_entity_tags_json "
84+
"ON entity USING GIN ((entity_metadata -> 'tags'))"
85+
)
86+
op.execute(
87+
"CREATE INDEX IF NOT EXISTS idx_entity_frontmatter_type "
88+
"ON entity ((entity_metadata ->> 'type'))"
89+
)
90+
op.execute(
91+
"CREATE INDEX IF NOT EXISTS idx_entity_frontmatter_status "
92+
"ON entity ((entity_metadata ->> 'status'))"
93+
)
94+
return
95+
96+
# SQLite: add generated columns for common frontmatter fields
97+
if not column_exists(connection, "entity", "tags_json"):
98+
op.add_column(
99+
"entity",
100+
sa.Column(
101+
"tags_json",
102+
sa.Text(),
103+
sa.Computed("json_extract(entity_metadata, '$.tags')", persisted=True),
104+
),
105+
)
106+
if not column_exists(connection, "entity", "frontmatter_status"):
107+
op.add_column(
108+
"entity",
109+
sa.Column(
110+
"frontmatter_status",
111+
sa.Text(),
112+
sa.Computed("json_extract(entity_metadata, '$.status')", persisted=True),
113+
),
114+
)
115+
if not column_exists(connection, "entity", "frontmatter_type"):
116+
op.add_column(
117+
"entity",
118+
sa.Column(
119+
"frontmatter_type",
120+
sa.Text(),
121+
sa.Computed("json_extract(entity_metadata, '$.type')", persisted=True),
122+
),
123+
)
124+
125+
# Index generated columns
126+
if not index_exists(connection, "idx_entity_tags_json"):
127+
op.create_index("idx_entity_tags_json", "entity", ["tags_json"])
128+
if not index_exists(connection, "idx_entity_frontmatter_status"):
129+
op.create_index("idx_entity_frontmatter_status", "entity", ["frontmatter_status"])
130+
if not index_exists(connection, "idx_entity_frontmatter_type"):
131+
op.create_index("idx_entity_frontmatter_type", "entity", ["frontmatter_type"])
132+
133+
134+
def downgrade() -> None:
135+
"""Best-effort downgrade (drop indexes, revert JSONB on Postgres)."""
136+
connection = op.get_bind()
137+
dialect = connection.dialect.name
138+
139+
if dialect == "postgresql":
140+
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_status")
141+
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_type")
142+
op.execute("DROP INDEX IF EXISTS idx_entity_tags_json")
143+
op.execute("DROP INDEX IF EXISTS idx_entity_metadata_gin")
144+
op.execute(
145+
"ALTER TABLE entity ALTER COLUMN entity_metadata TYPE json USING entity_metadata::json"
146+
)
147+
return
148+
149+
# SQLite: drop indexes (dropping generated columns requires table rebuild)
150+
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_status")
151+
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_type")
152+
op.execute("DROP INDEX IF EXISTS idx_entity_tags_json")

src/basic_memory/api/app.py

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,6 @@
88

99
from basic_memory import __version__ as version
1010
from basic_memory.api.container import ApiContainer, set_container
11-
from basic_memory.api.routers import (
12-
directory_router,
13-
importer_router,
14-
knowledge,
15-
management,
16-
memory,
17-
project,
18-
resource,
19-
search,
20-
prompt_router,
21-
)
2211
from basic_memory.api.v2.routers import (
2312
knowledge_router as v2_knowledge,
2413
project_router as v2_project,
@@ -90,19 +79,10 @@ async def lifespan(app: FastAPI): # pragma: no cover
9079
app.include_router(v2_importer, prefix="/v2/projects/{project_id}")
9180
app.include_router(v2_project, prefix="/v2")
9281

93-
# Include v1 routers (/{project} is a catch-all, must come after specific prefixes)
94-
app.include_router(knowledge.router, prefix="/{project}")
95-
app.include_router(memory.router, prefix="/{project}")
96-
app.include_router(resource.router, prefix="/{project}")
97-
app.include_router(search.router, prefix="/{project}")
98-
app.include_router(project.project_router, prefix="/{project}")
99-
app.include_router(directory_router.router, prefix="/{project}")
100-
app.include_router(prompt_router.router, prefix="/{project}")
101-
app.include_router(importer_router.router, prefix="/{project}")
102-
103-
# Project resource router works across projects
104-
app.include_router(project.project_resource_router)
105-
app.include_router(management.router)
82+
# Legacy web app proxy paths (compat with /proxy/projects/projects)
83+
app.include_router(v2_project, prefix="/proxy/projects")
84+
85+
# V2 routers are the only public API surface
10686

10787

10888
@app.exception_handler(Exception)

src/basic_memory/api/routers/__init__.py

Lines changed: 0 additions & 11 deletions
This file was deleted.

0 commit comments

Comments
 (0)