omni-cortex 1.0.4__py3-none-any.whl → 1.2.0__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.
Files changed (22) hide show
  1. omni_cortex-1.2.0.data/data/share/omni-cortex/dashboard/backend/chat_service.py +290 -0
  2. {omni_cortex-1.0.4.data → omni_cortex-1.2.0.data}/data/share/omni-cortex/dashboard/backend/database.py +78 -0
  3. omni_cortex-1.2.0.data/data/share/omni-cortex/dashboard/backend/image_service.py +533 -0
  4. omni_cortex-1.2.0.data/data/share/omni-cortex/dashboard/backend/logging_config.py +92 -0
  5. {omni_cortex-1.0.4.data → omni_cortex-1.2.0.data}/data/share/omni-cortex/dashboard/backend/main.py +324 -42
  6. {omni_cortex-1.0.4.data → omni_cortex-1.2.0.data}/data/share/omni-cortex/dashboard/backend/models.py +93 -0
  7. omni_cortex-1.2.0.data/data/share/omni-cortex/dashboard/backend/project_config.py +170 -0
  8. {omni_cortex-1.0.4.data → omni_cortex-1.2.0.data}/data/share/omni-cortex/dashboard/backend/project_scanner.py +45 -22
  9. {omni_cortex-1.0.4.dist-info → omni_cortex-1.2.0.dist-info}/METADATA +26 -2
  10. omni_cortex-1.2.0.dist-info/RECORD +20 -0
  11. omni_cortex-1.0.4.data/data/share/omni-cortex/dashboard/backend/chat_service.py +0 -140
  12. omni_cortex-1.0.4.dist-info/RECORD +0 -17
  13. {omni_cortex-1.0.4.data → omni_cortex-1.2.0.data}/data/share/omni-cortex/dashboard/backend/pyproject.toml +0 -0
  14. {omni_cortex-1.0.4.data → omni_cortex-1.2.0.data}/data/share/omni-cortex/dashboard/backend/uv.lock +0 -0
  15. {omni_cortex-1.0.4.data → omni_cortex-1.2.0.data}/data/share/omni-cortex/dashboard/backend/websocket_manager.py +0 -0
  16. {omni_cortex-1.0.4.data → omni_cortex-1.2.0.data}/data/share/omni-cortex/hooks/post_tool_use.py +0 -0
  17. {omni_cortex-1.0.4.data → omni_cortex-1.2.0.data}/data/share/omni-cortex/hooks/pre_tool_use.py +0 -0
  18. {omni_cortex-1.0.4.data → omni_cortex-1.2.0.data}/data/share/omni-cortex/hooks/stop.py +0 -0
  19. {omni_cortex-1.0.4.data → omni_cortex-1.2.0.data}/data/share/omni-cortex/hooks/subagent_stop.py +0 -0
  20. {omni_cortex-1.0.4.dist-info → omni_cortex-1.2.0.dist-info}/WHEEL +0 -0
  21. {omni_cortex-1.0.4.dist-info → omni_cortex-1.2.0.dist-info}/entry_points.txt +0 -0
  22. {omni_cortex-1.0.4.dist-info → omni_cortex-1.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -3,6 +3,7 @@
3
3
 
4
4
  import asyncio
5
5
  import json
6
+ import traceback
6
7
  from contextlib import asynccontextmanager
7
8
  from datetime import datetime
8
9
  from pathlib import Path
@@ -37,10 +38,34 @@ from database import (
37
38
  search_memories,
38
39
  update_memory,
39
40
  )
40
- from models import ChatRequest, ChatResponse, FilterParams, MemoryUpdate, ProjectInfo
41
+ from logging_config import log_success, log_error
42
+ from models import (
43
+ ChatRequest,
44
+ ChatResponse,
45
+ ConversationSaveRequest,
46
+ ConversationSaveResponse,
47
+ FilterParams,
48
+ MemoryUpdate,
49
+ ProjectInfo,
50
+ ProjectRegistration,
51
+ BatchImageGenerationRequest,
52
+ BatchImageGenerationResponse,
53
+ ImageRefineRequest,
54
+ SingleImageRequestModel,
55
+ SingleImageResponseModel,
56
+ )
57
+ from project_config import (
58
+ load_config,
59
+ add_registered_project,
60
+ remove_registered_project,
61
+ toggle_favorite,
62
+ add_scan_directory,
63
+ remove_scan_directory,
64
+ )
41
65
  from project_scanner import scan_projects
42
66
  from websocket_manager import manager
43
67
  import chat_service
68
+ from image_service import image_service, ImagePreset, SingleImageRequest
44
69
 
45
70
 
46
71
  class DatabaseChangeHandler(FileSystemEventHandler):
@@ -145,6 +170,70 @@ async def list_projects():
145
170
  return scan_projects()
146
171
 
147
172
 
173
+ # --- Project Management Endpoints ---
174
+
175
+
176
+ @app.get("/api/projects/config")
177
+ async def get_project_config():
178
+ """Get project configuration (scan dirs, counts)."""
179
+ config = load_config()
180
+ return {
181
+ "scan_directories": config.scan_directories,
182
+ "registered_count": len(config.registered_projects),
183
+ "favorites_count": len(config.favorites),
184
+ }
185
+
186
+
187
+ @app.post("/api/projects/register")
188
+ async def register_project(body: ProjectRegistration):
189
+ """Manually register a project by path."""
190
+ success = add_registered_project(body.path, body.display_name)
191
+ if not success:
192
+ raise HTTPException(400, "Invalid path or already registered")
193
+ return {"success": True}
194
+
195
+
196
+ @app.delete("/api/projects/register")
197
+ async def unregister_project(path: str = Query(..., description="Project path to unregister")):
198
+ """Remove a registered project."""
199
+ success = remove_registered_project(path)
200
+ if not success:
201
+ raise HTTPException(404, "Project not found")
202
+ return {"success": True}
203
+
204
+
205
+ @app.post("/api/projects/favorite")
206
+ async def toggle_project_favorite(path: str = Query(..., description="Project path to toggle favorite")):
207
+ """Toggle favorite status for a project."""
208
+ is_favorite = toggle_favorite(path)
209
+ return {"is_favorite": is_favorite}
210
+
211
+
212
+ @app.post("/api/projects/scan-directories")
213
+ async def add_scan_dir(directory: str = Query(..., description="Directory path to add")):
214
+ """Add a directory to auto-scan list."""
215
+ success = add_scan_directory(directory)
216
+ if not success:
217
+ raise HTTPException(400, "Invalid directory or already added")
218
+ return {"success": True}
219
+
220
+
221
+ @app.delete("/api/projects/scan-directories")
222
+ async def remove_scan_dir(directory: str = Query(..., description="Directory path to remove")):
223
+ """Remove a directory from auto-scan list."""
224
+ success = remove_scan_directory(directory)
225
+ if not success:
226
+ raise HTTPException(404, "Directory not found")
227
+ return {"success": True}
228
+
229
+
230
+ @app.post("/api/projects/refresh")
231
+ async def refresh_projects():
232
+ """Force rescan of all project directories."""
233
+ projects = scan_projects()
234
+ return {"count": len(projects)}
235
+
236
+
148
237
  @app.get("/api/memories")
149
238
  async def list_memories(
150
239
  project: str = Query(..., description="Path to the database file"),
@@ -160,23 +249,30 @@ async def list_memories(
160
249
  offset: int = 0,
161
250
  ):
162
251
  """Get memories with filtering and pagination."""
163
- if not Path(project).exists():
164
- raise HTTPException(status_code=404, detail="Database not found")
165
-
166
- filters = FilterParams(
167
- memory_type=memory_type,
168
- status=status,
169
- tags=tags.split(",") if tags else None,
170
- search=search,
171
- min_importance=min_importance,
172
- max_importance=max_importance,
173
- sort_by=sort_by,
174
- sort_order=sort_order,
175
- limit=limit,
176
- offset=offset,
177
- )
252
+ try:
253
+ if not Path(project).exists():
254
+ log_error("/api/memories", FileNotFoundError("Database not found"), project=project)
255
+ raise HTTPException(status_code=404, detail="Database not found")
256
+
257
+ filters = FilterParams(
258
+ memory_type=memory_type,
259
+ status=status,
260
+ tags=tags.split(",") if tags else None,
261
+ search=search,
262
+ min_importance=min_importance,
263
+ max_importance=max_importance,
264
+ sort_by=sort_by,
265
+ sort_order=sort_order,
266
+ limit=limit,
267
+ offset=offset,
268
+ )
178
269
 
179
- return get_memories(project, filters)
270
+ memories = get_memories(project, filters)
271
+ log_success("/api/memories", count=len(memories), offset=offset, filters=bool(search or memory_type))
272
+ return memories
273
+ except Exception as e:
274
+ log_error("/api/memories", e, project=project)
275
+ raise
180
276
 
181
277
 
182
278
  # NOTE: These routes MUST be defined before /api/memories/{memory_id} to avoid path conflicts
@@ -237,16 +333,25 @@ async def update_memory_endpoint(
237
333
  project: str = Query(..., description="Path to the database file"),
238
334
  ):
239
335
  """Update a memory."""
240
- if not Path(project).exists():
241
- raise HTTPException(status_code=404, detail="Database not found")
242
-
243
- updated = update_memory(project, memory_id, updates)
244
- if not updated:
245
- raise HTTPException(status_code=404, detail="Memory not found")
246
-
247
- # Notify connected clients
248
- await manager.broadcast("memory_updated", updated.model_dump(by_alias=True))
249
- return updated
336
+ try:
337
+ if not Path(project).exists():
338
+ log_error("/api/memories/update", FileNotFoundError("Database not found"), memory_id=memory_id)
339
+ raise HTTPException(status_code=404, detail="Database not found")
340
+
341
+ updated = update_memory(project, memory_id, updates)
342
+ if not updated:
343
+ log_error("/api/memories/update", ValueError("Memory not found"), memory_id=memory_id)
344
+ raise HTTPException(status_code=404, detail="Memory not found")
345
+
346
+ # Notify connected clients
347
+ await manager.broadcast("memory_updated", updated.model_dump(by_alias=True))
348
+ log_success("/api/memories/update", memory_id=memory_id, fields_updated=len(updates.model_dump(exclude_unset=True)))
349
+ return updated
350
+ except HTTPException:
351
+ raise
352
+ except Exception as e:
353
+ log_error("/api/memories/update", e, memory_id=memory_id)
354
+ raise
250
355
 
251
356
 
252
357
  @app.delete("/api/memories/{memory_id}")
@@ -255,16 +360,25 @@ async def delete_memory_endpoint(
255
360
  project: str = Query(..., description="Path to the database file"),
256
361
  ):
257
362
  """Delete a memory."""
258
- if not Path(project).exists():
259
- raise HTTPException(status_code=404, detail="Database not found")
260
-
261
- deleted = delete_memory(project, memory_id)
262
- if not deleted:
263
- raise HTTPException(status_code=404, detail="Memory not found")
264
-
265
- # Notify connected clients
266
- await manager.broadcast("memory_deleted", {"id": memory_id})
267
- return {"message": "Memory deleted", "id": memory_id}
363
+ try:
364
+ if not Path(project).exists():
365
+ log_error("/api/memories/delete", FileNotFoundError("Database not found"), memory_id=memory_id)
366
+ raise HTTPException(status_code=404, detail="Database not found")
367
+
368
+ deleted = delete_memory(project, memory_id)
369
+ if not deleted:
370
+ log_error("/api/memories/delete", ValueError("Memory not found"), memory_id=memory_id)
371
+ raise HTTPException(status_code=404, detail="Memory not found")
372
+
373
+ # Notify connected clients
374
+ await manager.broadcast("memory_deleted", {"id": memory_id})
375
+ log_success("/api/memories/delete", memory_id=memory_id)
376
+ return {"message": "Memory deleted", "id": memory_id}
377
+ except HTTPException:
378
+ raise
379
+ except Exception as e:
380
+ log_error("/api/memories/delete", e, memory_id=memory_id)
381
+ raise
268
382
 
269
383
 
270
384
  @app.get("/api/memories/stats/summary")
@@ -454,16 +568,184 @@ async def chat_with_memories(
454
568
  project: str = Query(..., description="Path to the database file"),
455
569
  ):
456
570
  """Ask a natural language question about memories."""
571
+ try:
572
+ if not Path(project).exists():
573
+ log_error("/api/chat", FileNotFoundError("Database not found"), question=request.question[:50])
574
+ raise HTTPException(status_code=404, detail="Database not found")
575
+
576
+ result = await chat_service.ask_about_memories(
577
+ project,
578
+ request.question,
579
+ request.max_memories,
580
+ )
581
+
582
+ log_success("/api/chat", question_len=len(request.question), sources=len(result.get("sources", [])))
583
+ return ChatResponse(**result)
584
+ except HTTPException:
585
+ raise
586
+ except Exception as e:
587
+ log_error("/api/chat", e, question=request.question[:50])
588
+ raise
589
+
590
+
591
+ @app.get("/api/chat/stream")
592
+ async def stream_chat(
593
+ project: str = Query(..., description="Path to the database file"),
594
+ question: str = Query(..., description="The question to ask"),
595
+ max_memories: int = Query(10, ge=1, le=50),
596
+ ):
597
+ """SSE endpoint for streaming chat responses."""
598
+ from fastapi.responses import StreamingResponse
599
+
457
600
  if not Path(project).exists():
458
601
  raise HTTPException(status_code=404, detail="Database not found")
459
602
 
460
- result = await chat_service.ask_about_memories(
461
- project,
462
- request.question,
463
- request.max_memories,
603
+ async def event_generator():
604
+ try:
605
+ async for event in chat_service.stream_ask_about_memories(project, question, max_memories):
606
+ yield f"data: {json.dumps(event)}\n\n"
607
+ except Exception as e:
608
+ yield f"data: {json.dumps({'type': 'error', 'data': str(e)})}\n\n"
609
+
610
+ return StreamingResponse(
611
+ event_generator(),
612
+ media_type="text/event-stream",
613
+ headers={
614
+ "Cache-Control": "no-cache",
615
+ "Connection": "keep-alive",
616
+ "X-Accel-Buffering": "no",
617
+ }
464
618
  )
465
619
 
466
- return ChatResponse(**result)
620
+
621
+ @app.post("/api/chat/save", response_model=ConversationSaveResponse)
622
+ async def save_chat_conversation(
623
+ request: ConversationSaveRequest,
624
+ project: str = Query(..., description="Path to the database file"),
625
+ ):
626
+ """Save a chat conversation as a memory."""
627
+ try:
628
+ if not Path(project).exists():
629
+ log_error("/api/chat/save", FileNotFoundError("Database not found"))
630
+ raise HTTPException(status_code=404, detail="Database not found")
631
+
632
+ result = await chat_service.save_conversation(
633
+ project,
634
+ [msg.model_dump() for msg in request.messages],
635
+ request.referenced_memory_ids,
636
+ request.importance or 60,
637
+ )
638
+
639
+ log_success("/api/chat/save", memory_id=result["memory_id"], messages=len(request.messages))
640
+ return ConversationSaveResponse(**result)
641
+ except HTTPException:
642
+ raise
643
+ except Exception as e:
644
+ log_error("/api/chat/save", e)
645
+ raise
646
+
647
+
648
+ # --- Image Generation Endpoints ---
649
+
650
+
651
+ @app.get("/api/image/status")
652
+ async def get_image_status():
653
+ """Check if image generation is available."""
654
+ return {
655
+ "available": image_service.is_available(),
656
+ "message": "Image generation ready" if image_service.is_available()
657
+ else "Configure GEMINI_API_KEY and install google-genai for image generation",
658
+ }
659
+
660
+
661
+ @app.get("/api/image/presets")
662
+ async def get_image_presets():
663
+ """Get available image preset templates."""
664
+ return {"presets": image_service.get_presets()}
665
+
666
+
667
+ @app.post("/api/image/generate-batch", response_model=BatchImageGenerationResponse)
668
+ async def generate_images_batch(
669
+ request: BatchImageGenerationRequest,
670
+ db_path: str = Query(..., alias="project", description="Path to the database file"),
671
+ ):
672
+ """Generate multiple images with different presets/prompts."""
673
+ # Validate image count
674
+ if len(request.images) not in [1, 2, 4]:
675
+ return BatchImageGenerationResponse(
676
+ success=False,
677
+ errors=["Must request 1, 2, or 4 images"]
678
+ )
679
+
680
+ # Build memory context
681
+ memory_context = ""
682
+ if request.memory_ids:
683
+ memory_context = image_service.build_memory_context(db_path, request.memory_ids)
684
+
685
+ # Build chat context
686
+ chat_context = image_service.build_chat_context(request.chat_messages)
687
+
688
+ # Convert request models to internal format
689
+ image_requests = [
690
+ SingleImageRequest(
691
+ preset=ImagePreset(img.preset),
692
+ custom_prompt=img.custom_prompt,
693
+ aspect_ratio=img.aspect_ratio,
694
+ image_size=img.image_size
695
+ )
696
+ for img in request.images
697
+ ]
698
+
699
+ result = await image_service.generate_batch(
700
+ requests=image_requests,
701
+ memory_context=memory_context,
702
+ chat_context=chat_context,
703
+ use_search_grounding=request.use_search_grounding
704
+ )
705
+
706
+ return BatchImageGenerationResponse(
707
+ success=result.success,
708
+ images=[
709
+ SingleImageResponseModel(
710
+ success=img.success,
711
+ image_data=img.image_data,
712
+ text_response=img.text_response,
713
+ thought_signature=img.thought_signature,
714
+ image_id=img.image_id,
715
+ error=img.error,
716
+ index=img.index
717
+ )
718
+ for img in result.images
719
+ ],
720
+ errors=result.errors
721
+ )
722
+
723
+
724
+ @app.post("/api/image/refine", response_model=SingleImageResponseModel)
725
+ async def refine_image(request: ImageRefineRequest):
726
+ """Refine an existing generated image with a new prompt."""
727
+ result = await image_service.refine_image(
728
+ image_id=request.image_id,
729
+ refinement_prompt=request.refinement_prompt,
730
+ aspect_ratio=request.aspect_ratio,
731
+ image_size=request.image_size
732
+ )
733
+
734
+ return SingleImageResponseModel(
735
+ success=result.success,
736
+ image_data=result.image_data,
737
+ text_response=result.text_response,
738
+ thought_signature=result.thought_signature,
739
+ image_id=result.image_id,
740
+ error=result.error
741
+ )
742
+
743
+
744
+ @app.post("/api/image/clear-conversation")
745
+ async def clear_image_conversation(image_id: Optional[str] = None):
746
+ """Clear image conversation history. If image_id provided, clear only that image."""
747
+ image_service.clear_conversation(image_id)
748
+ return {"status": "cleared", "image_id": image_id}
467
749
 
468
750
 
469
751
  # --- WebSocket Endpoint ---
@@ -15,6 +15,31 @@ class ProjectInfo(BaseModel):
15
15
  last_modified: Optional[datetime] = None
16
16
  memory_count: int = 0
17
17
  is_global: bool = False
18
+ is_favorite: bool = False
19
+ is_registered: bool = False
20
+ display_name: Optional[str] = None
21
+
22
+
23
+ class ScanDirectory(BaseModel):
24
+ """A directory being scanned for projects."""
25
+
26
+ path: str
27
+ project_count: int = 0
28
+
29
+
30
+ class ProjectRegistration(BaseModel):
31
+ """Request to register a project."""
32
+
33
+ path: str
34
+ display_name: Optional[str] = None
35
+
36
+
37
+ class ProjectConfigResponse(BaseModel):
38
+ """Response with project configuration."""
39
+
40
+ scan_directories: list[str]
41
+ registered_count: int
42
+ favorites_count: int
18
43
 
19
44
 
20
45
  class Memory(BaseModel):
@@ -138,3 +163,71 @@ class ChatResponse(BaseModel):
138
163
  answer: str
139
164
  sources: list[ChatSource]
140
165
  error: Optional[str] = None
166
+
167
+
168
+ class ConversationMessage(BaseModel):
169
+ """A message in a conversation."""
170
+
171
+ role: str # 'user' or 'assistant'
172
+ content: str
173
+ timestamp: str
174
+
175
+
176
+ class ConversationSaveRequest(BaseModel):
177
+ """Request to save a conversation as memory."""
178
+
179
+ messages: list[ConversationMessage]
180
+ referenced_memory_ids: Optional[list[str]] = None
181
+ importance: Optional[int] = Field(default=60, ge=1, le=100)
182
+
183
+
184
+ class ConversationSaveResponse(BaseModel):
185
+ """Response after saving a conversation."""
186
+
187
+ memory_id: str
188
+ summary: str
189
+
190
+
191
+ # --- Image Generation Models ---
192
+
193
+
194
+ class SingleImageRequestModel(BaseModel):
195
+ """Request for a single image in a batch."""
196
+ preset: str = "custom" # Maps to ImagePreset enum
197
+ custom_prompt: str = ""
198
+ aspect_ratio: str = "16:9"
199
+ image_size: str = "2K"
200
+
201
+
202
+ class BatchImageGenerationRequest(BaseModel):
203
+ """Request for generating multiple images."""
204
+ images: list[SingleImageRequestModel] # 1, 2, or 4 images
205
+ memory_ids: list[str] = []
206
+ chat_messages: list[dict] = [] # Recent chat for context
207
+ use_search_grounding: bool = False
208
+
209
+
210
+ class ImageRefineRequest(BaseModel):
211
+ """Request for refining an existing image."""
212
+ image_id: str
213
+ refinement_prompt: str
214
+ aspect_ratio: Optional[str] = None
215
+ image_size: Optional[str] = None
216
+
217
+
218
+ class SingleImageResponseModel(BaseModel):
219
+ """Response for a single generated image."""
220
+ success: bool
221
+ image_data: Optional[str] = None # Base64 encoded
222
+ text_response: Optional[str] = None
223
+ thought_signature: Optional[str] = None
224
+ image_id: Optional[str] = None
225
+ error: Optional[str] = None
226
+ index: int = 0
227
+
228
+
229
+ class BatchImageGenerationResponse(BaseModel):
230
+ """Response for batch image generation."""
231
+ success: bool
232
+ images: list[SingleImageResponseModel] = []
233
+ errors: list[str] = []
@@ -0,0 +1,170 @@
1
+ """Project configuration manager for user preferences."""
2
+
3
+ import json
4
+ import platform
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from pydantic import BaseModel
10
+
11
+
12
+ class RegisteredProject(BaseModel):
13
+ """A manually registered project."""
14
+
15
+ path: str
16
+ display_name: Optional[str] = None
17
+ added_at: datetime
18
+
19
+
20
+ class RecentProject(BaseModel):
21
+ """A recently accessed project."""
22
+
23
+ path: str
24
+ last_accessed: datetime
25
+
26
+
27
+ class ProjectConfig(BaseModel):
28
+ """User project configuration."""
29
+
30
+ version: int = 1
31
+ scan_directories: list[str] = []
32
+ registered_projects: list[RegisteredProject] = []
33
+ favorites: list[str] = []
34
+ recent: list[RecentProject] = []
35
+
36
+
37
+ CONFIG_PATH = Path.home() / ".omni-cortex" / "projects.json"
38
+
39
+
40
+ def get_default_scan_dirs() -> list[str]:
41
+ """Return platform-appropriate default scan directories."""
42
+ home = Path.home()
43
+
44
+ dirs = [
45
+ str(home / "projects"),
46
+ str(home / "Projects"),
47
+ str(home / "code"),
48
+ str(home / "Code"),
49
+ str(home / "dev"),
50
+ str(home / "workspace"),
51
+ ]
52
+
53
+ if platform.system() == "Windows":
54
+ dirs.insert(0, "D:/Projects")
55
+
56
+ return [d for d in dirs if Path(d).exists()]
57
+
58
+
59
+ def load_config() -> ProjectConfig:
60
+ """Load config from disk, creating defaults if missing."""
61
+ if CONFIG_PATH.exists():
62
+ try:
63
+ data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
64
+ return ProjectConfig(**data)
65
+ except Exception:
66
+ pass
67
+
68
+ # Create default config
69
+ config = ProjectConfig(scan_directories=get_default_scan_dirs())
70
+ save_config(config)
71
+ return config
72
+
73
+
74
+ def save_config(config: ProjectConfig) -> None:
75
+ """Save config to disk."""
76
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
77
+ CONFIG_PATH.write_text(config.model_dump_json(indent=2), encoding="utf-8")
78
+
79
+
80
+ def add_registered_project(path: str, display_name: Optional[str] = None) -> bool:
81
+ """Register a new project by path."""
82
+ config = load_config()
83
+
84
+ # Validate path has cortex.db
85
+ db_path = Path(path) / ".omni-cortex" / "cortex.db"
86
+ if not db_path.exists():
87
+ return False
88
+
89
+ # Check if already registered
90
+ if any(p.path == path for p in config.registered_projects):
91
+ return False
92
+
93
+ config.registered_projects.append(
94
+ RegisteredProject(path=path, display_name=display_name, added_at=datetime.now())
95
+ )
96
+ save_config(config)
97
+ return True
98
+
99
+
100
+ def remove_registered_project(path: str) -> bool:
101
+ """Remove a registered project."""
102
+ config = load_config()
103
+ original_len = len(config.registered_projects)
104
+ config.registered_projects = [
105
+ p for p in config.registered_projects if p.path != path
106
+ ]
107
+
108
+ if len(config.registered_projects) < original_len:
109
+ save_config(config)
110
+ return True
111
+ return False
112
+
113
+
114
+ def toggle_favorite(path: str) -> bool:
115
+ """Toggle favorite status for a project. Returns new favorite status."""
116
+ config = load_config()
117
+
118
+ if path in config.favorites:
119
+ config.favorites.remove(path)
120
+ is_favorite = False
121
+ else:
122
+ config.favorites.append(path)
123
+ is_favorite = True
124
+
125
+ save_config(config)
126
+ return is_favorite
127
+
128
+
129
+ def update_recent(path: str) -> None:
130
+ """Update recent projects list."""
131
+ config = load_config()
132
+
133
+ # Remove if already in list
134
+ config.recent = [r for r in config.recent if r.path != path]
135
+
136
+ # Add to front
137
+ config.recent.insert(0, RecentProject(path=path, last_accessed=datetime.now()))
138
+
139
+ # Keep only last 10
140
+ config.recent = config.recent[:10]
141
+
142
+ save_config(config)
143
+
144
+
145
+ def add_scan_directory(directory: str) -> bool:
146
+ """Add a directory to scan list."""
147
+ config = load_config()
148
+
149
+ # Expand user path
150
+ expanded = str(Path(directory).expanduser())
151
+
152
+ if not Path(expanded).is_dir():
153
+ return False
154
+
155
+ if expanded not in config.scan_directories:
156
+ config.scan_directories.append(expanded)
157
+ save_config(config)
158
+ return True
159
+ return False
160
+
161
+
162
+ def remove_scan_directory(directory: str) -> bool:
163
+ """Remove a directory from scan list."""
164
+ config = load_config()
165
+
166
+ if directory in config.scan_directories:
167
+ config.scan_directories.remove(directory)
168
+ save_config(config)
169
+ return True
170
+ return False