omni-cortex 1.17.1__py3-none-any.whl → 1.17.3__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 (87) hide show
  1. omni_cortex/__init__.py +3 -0
  2. omni_cortex/_bundled/dashboard/backend/.env.example +12 -0
  3. omni_cortex/_bundled/dashboard/backend/backfill_summaries.py +280 -0
  4. omni_cortex/_bundled/dashboard/backend/chat_service.py +631 -0
  5. omni_cortex/_bundled/dashboard/backend/database.py +1773 -0
  6. omni_cortex/_bundled/dashboard/backend/image_service.py +552 -0
  7. omni_cortex/_bundled/dashboard/backend/logging_config.py +122 -0
  8. omni_cortex/_bundled/dashboard/backend/main.py +1888 -0
  9. omni_cortex/_bundled/dashboard/backend/models.py +472 -0
  10. omni_cortex/_bundled/dashboard/backend/project_config.py +170 -0
  11. omni_cortex/_bundled/dashboard/backend/project_scanner.py +164 -0
  12. omni_cortex/_bundled/dashboard/backend/prompt_security.py +111 -0
  13. omni_cortex/_bundled/dashboard/backend/pyproject.toml +23 -0
  14. omni_cortex/_bundled/dashboard/backend/security.py +104 -0
  15. omni_cortex/_bundled/dashboard/backend/uv.lock +1110 -0
  16. omni_cortex/_bundled/dashboard/backend/websocket_manager.py +104 -0
  17. omni_cortex/_bundled/hooks/post_tool_use.py +497 -0
  18. omni_cortex/_bundled/hooks/pre_tool_use.py +277 -0
  19. omni_cortex/_bundled/hooks/session_utils.py +186 -0
  20. omni_cortex/_bundled/hooks/stop.py +219 -0
  21. omni_cortex/_bundled/hooks/subagent_stop.py +120 -0
  22. omni_cortex/_bundled/hooks/user_prompt.py +220 -0
  23. omni_cortex/categorization/__init__.py +9 -0
  24. omni_cortex/categorization/auto_tags.py +166 -0
  25. omni_cortex/categorization/auto_type.py +165 -0
  26. omni_cortex/config.py +141 -0
  27. omni_cortex/dashboard.py +238 -0
  28. omni_cortex/database/__init__.py +24 -0
  29. omni_cortex/database/connection.py +137 -0
  30. omni_cortex/database/migrations.py +210 -0
  31. omni_cortex/database/schema.py +212 -0
  32. omni_cortex/database/sync.py +421 -0
  33. omni_cortex/decay/__init__.py +7 -0
  34. omni_cortex/decay/importance.py +147 -0
  35. omni_cortex/embeddings/__init__.py +35 -0
  36. omni_cortex/embeddings/local.py +442 -0
  37. omni_cortex/models/__init__.py +20 -0
  38. omni_cortex/models/activity.py +265 -0
  39. omni_cortex/models/agent.py +144 -0
  40. omni_cortex/models/memory.py +395 -0
  41. omni_cortex/models/relationship.py +206 -0
  42. omni_cortex/models/session.py +290 -0
  43. omni_cortex/resources/__init__.py +1 -0
  44. omni_cortex/search/__init__.py +22 -0
  45. omni_cortex/search/hybrid.py +197 -0
  46. omni_cortex/search/keyword.py +204 -0
  47. omni_cortex/search/ranking.py +127 -0
  48. omni_cortex/search/semantic.py +232 -0
  49. omni_cortex/server.py +360 -0
  50. omni_cortex/setup.py +284 -0
  51. omni_cortex/tools/__init__.py +13 -0
  52. omni_cortex/tools/activities.py +453 -0
  53. omni_cortex/tools/memories.py +536 -0
  54. omni_cortex/tools/sessions.py +311 -0
  55. omni_cortex/tools/utilities.py +477 -0
  56. omni_cortex/utils/__init__.py +13 -0
  57. omni_cortex/utils/formatting.py +282 -0
  58. omni_cortex/utils/ids.py +72 -0
  59. omni_cortex/utils/timestamps.py +129 -0
  60. omni_cortex/utils/truncation.py +111 -0
  61. {omni_cortex-1.17.1.dist-info → omni_cortex-1.17.3.dist-info}/METADATA +1 -1
  62. omni_cortex-1.17.3.dist-info/RECORD +86 -0
  63. omni_cortex-1.17.1.dist-info/RECORD +0 -26
  64. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/dashboard/backend/.env.example +0 -0
  65. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/dashboard/backend/backfill_summaries.py +0 -0
  66. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/dashboard/backend/chat_service.py +0 -0
  67. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/dashboard/backend/database.py +0 -0
  68. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/dashboard/backend/image_service.py +0 -0
  69. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/dashboard/backend/logging_config.py +0 -0
  70. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/dashboard/backend/main.py +0 -0
  71. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/dashboard/backend/models.py +0 -0
  72. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/dashboard/backend/project_config.py +0 -0
  73. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/dashboard/backend/project_scanner.py +0 -0
  74. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/dashboard/backend/prompt_security.py +0 -0
  75. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/dashboard/backend/pyproject.toml +0 -0
  76. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/dashboard/backend/security.py +0 -0
  77. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/dashboard/backend/uv.lock +0 -0
  78. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/dashboard/backend/websocket_manager.py +0 -0
  79. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/hooks/post_tool_use.py +0 -0
  80. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/hooks/pre_tool_use.py +0 -0
  81. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/hooks/session_utils.py +0 -0
  82. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/hooks/stop.py +0 -0
  83. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/hooks/subagent_stop.py +0 -0
  84. {omni_cortex-1.17.1.data → omni_cortex-1.17.3.data}/data/share/omni-cortex/hooks/user_prompt.py +0 -0
  85. {omni_cortex-1.17.1.dist-info → omni_cortex-1.17.3.dist-info}/WHEEL +0 -0
  86. {omni_cortex-1.17.1.dist-info → omni_cortex-1.17.3.dist-info}/entry_points.txt +0 -0
  87. {omni_cortex-1.17.1.dist-info → omni_cortex-1.17.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,472 @@
1
+ """Pydantic models for the dashboard API."""
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class ProjectInfo(BaseModel):
10
+ """Information about a project with omni-cortex database."""
11
+
12
+ name: str
13
+ path: str
14
+ db_path: str
15
+ last_modified: Optional[datetime] = None
16
+ memory_count: int = 0
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
43
+
44
+
45
+ class Memory(BaseModel):
46
+ """Memory record from the database."""
47
+
48
+ id: str
49
+ content: str
50
+ context: Optional[str] = None
51
+ memory_type: str = Field(default="other", validation_alias="type")
52
+ status: str = "fresh"
53
+ importance_score: int = 50
54
+ access_count: int = 0
55
+ created_at: datetime
56
+ last_accessed: Optional[datetime] = None
57
+ tags: list[str] = []
58
+
59
+ model_config = {"populate_by_name": True}
60
+
61
+
62
+ class MemoryStats(BaseModel):
63
+ """Statistics about memories in a database."""
64
+
65
+ total_count: int
66
+ by_type: dict[str, int]
67
+ by_status: dict[str, int]
68
+ avg_importance: float
69
+ total_access_count: int
70
+ tags: list[dict[str, int | str]]
71
+
72
+
73
+ class FilterParams(BaseModel):
74
+ """Query filter parameters."""
75
+
76
+ memory_type: Optional[str] = None
77
+ status: Optional[str] = None
78
+ tags: Optional[list[str]] = None
79
+ search: Optional[str] = None
80
+ min_importance: Optional[int] = None
81
+ max_importance: Optional[int] = None
82
+ sort_by: str = "last_accessed"
83
+ sort_order: str = "desc"
84
+ limit: int = 50
85
+ offset: int = 0
86
+
87
+
88
+ class AggregateMemoryRequest(BaseModel):
89
+ """Request for aggregate memory data across projects."""
90
+
91
+ projects: list[str] = Field(..., description="List of project db paths")
92
+ filters: Optional[FilterParams] = None
93
+
94
+
95
+ class AggregateStatsRequest(BaseModel):
96
+ """Request for aggregate statistics."""
97
+
98
+ projects: list[str] = Field(..., description="List of project db paths")
99
+
100
+
101
+ class AggregateStatsResponse(BaseModel):
102
+ """Aggregate statistics across multiple projects."""
103
+
104
+ total_count: int
105
+ total_access_count: int
106
+ avg_importance: float
107
+ by_type: dict[str, int]
108
+ by_status: dict[str, int]
109
+ project_count: int
110
+
111
+
112
+ class AggregateChatRequest(BaseModel):
113
+ """Request for chat across multiple projects."""
114
+
115
+ projects: list[str] = Field(..., description="List of project db paths")
116
+ question: str = Field(..., min_length=1, max_length=2000)
117
+ max_memories_per_project: int = Field(default=5, ge=1, le=20)
118
+
119
+
120
+ class Activity(BaseModel):
121
+ """Activity log record."""
122
+
123
+ id: str
124
+ session_id: Optional[str] = None
125
+ event_type: str
126
+ tool_name: Optional[str] = None
127
+ tool_input: Optional[str] = None
128
+ tool_output: Optional[str] = None
129
+ success: bool = True
130
+ error_message: Optional[str] = None
131
+ duration_ms: Optional[int] = None
132
+ file_path: Optional[str] = None
133
+ timestamp: datetime
134
+ # Command analytics fields
135
+ command_name: Optional[str] = None
136
+ command_scope: Optional[str] = None
137
+ mcp_server: Optional[str] = None
138
+ skill_name: Optional[str] = None
139
+ # Natural language summary fields
140
+ summary: Optional[str] = None
141
+ summary_detail: Optional[str] = None
142
+
143
+
144
+ class Session(BaseModel):
145
+ """Session record."""
146
+
147
+ id: str
148
+ project_path: str
149
+ started_at: datetime
150
+ ended_at: Optional[datetime] = None
151
+ summary: Optional[str] = None
152
+ activity_count: int = 0
153
+
154
+
155
+ class TimelineEntry(BaseModel):
156
+ """Entry in the timeline view."""
157
+
158
+ timestamp: datetime
159
+ entry_type: str # "memory" or "activity"
160
+ data: dict
161
+
162
+
163
+ class MemoryCreateRequest(BaseModel):
164
+ """Create request for a new memory."""
165
+
166
+ content: str = Field(..., min_length=1, max_length=50000)
167
+ memory_type: str = Field(default="general")
168
+ context: Optional[str] = None
169
+ importance_score: int = Field(default=50, ge=1, le=100)
170
+ tags: list[str] = Field(default_factory=list)
171
+
172
+
173
+ class MemoryUpdate(BaseModel):
174
+ """Update request for a memory."""
175
+
176
+ content: Optional[str] = None
177
+ context: Optional[str] = None
178
+ memory_type: Optional[str] = Field(None, validation_alias="type")
179
+ status: Optional[str] = None
180
+ importance_score: Optional[int] = Field(None, ge=1, le=100)
181
+ tags: Optional[list[str]] = None
182
+
183
+ model_config = {"populate_by_name": True}
184
+
185
+
186
+ class WSEvent(BaseModel):
187
+ """WebSocket event message."""
188
+
189
+ event_type: str
190
+ data: dict
191
+ timestamp: datetime = Field(default_factory=datetime.now)
192
+
193
+
194
+ class ChatRequest(BaseModel):
195
+ """Request for the chat endpoint."""
196
+
197
+ question: str = Field(..., min_length=1, max_length=2000)
198
+ max_memories: int = Field(default=10, ge=1, le=50)
199
+ use_style: bool = Field(default=False)
200
+
201
+
202
+ class ChatSource(BaseModel):
203
+ """Source memory reference in chat response."""
204
+
205
+ id: str
206
+ type: str
207
+ content_preview: str
208
+ tags: list[str]
209
+ project_path: Optional[str] = None
210
+ project_name: Optional[str] = None
211
+
212
+
213
+ class ChatResponse(BaseModel):
214
+ """Response from the chat endpoint."""
215
+
216
+ answer: str
217
+ sources: list[ChatSource]
218
+ error: Optional[str] = None
219
+
220
+
221
+ class ConversationMessage(BaseModel):
222
+ """A message in a conversation."""
223
+
224
+ role: str # 'user' or 'assistant'
225
+ content: str
226
+ timestamp: str
227
+
228
+
229
+ class ConversationSaveRequest(BaseModel):
230
+ """Request to save a conversation as memory."""
231
+
232
+ messages: list[ConversationMessage]
233
+ referenced_memory_ids: Optional[list[str]] = None
234
+ importance: Optional[int] = Field(default=60, ge=1, le=100)
235
+
236
+
237
+ class ConversationSaveResponse(BaseModel):
238
+ """Response after saving a conversation."""
239
+
240
+ memory_id: str
241
+ summary: str
242
+
243
+
244
+ # --- Image Generation Models ---
245
+
246
+
247
+ class SingleImageRequestModel(BaseModel):
248
+ """Request for a single image in a batch."""
249
+ preset: str = "custom" # Maps to ImagePreset enum
250
+ custom_prompt: str = ""
251
+ aspect_ratio: str = "16:9"
252
+ image_size: str = "2K"
253
+
254
+
255
+ class BatchImageGenerationRequest(BaseModel):
256
+ """Request for generating multiple images."""
257
+ images: list[SingleImageRequestModel] # 1, 2, or 4 images
258
+ memory_ids: list[str] = []
259
+ chat_messages: list[dict] = [] # Recent chat for context
260
+ use_search_grounding: bool = False
261
+
262
+
263
+ class ImageRefineRequest(BaseModel):
264
+ """Request for refining an existing image."""
265
+ image_id: str
266
+ refinement_prompt: str
267
+ aspect_ratio: Optional[str] = None
268
+ image_size: Optional[str] = None
269
+
270
+
271
+ class SingleImageResponseModel(BaseModel):
272
+ """Response for a single generated image."""
273
+ success: bool
274
+ image_data: Optional[str] = None # Base64 encoded
275
+ text_response: Optional[str] = None
276
+ thought_signature: Optional[str] = None
277
+ image_id: Optional[str] = None
278
+ error: Optional[str] = None
279
+ index: int = 0
280
+
281
+
282
+ class BatchImageGenerationResponse(BaseModel):
283
+ """Response for batch image generation."""
284
+ success: bool
285
+ images: list[SingleImageResponseModel] = []
286
+ errors: list[str] = []
287
+
288
+
289
+ # --- User Messages & Style Profile Models ---
290
+
291
+
292
+ class UserMessage(BaseModel):
293
+ """User message record from the database."""
294
+
295
+ id: str
296
+ session_id: Optional[str] = None
297
+ timestamp: Optional[str] = None # Backward compatibility
298
+ created_at: Optional[str] = None # Frontend expects created_at
299
+ content: str
300
+ word_count: Optional[int] = None
301
+ char_count: Optional[int] = None
302
+ line_count: Optional[int] = None
303
+ has_code_blocks: bool = False
304
+ has_questions: bool = False
305
+ has_commands: bool = False
306
+ tone: Optional[str] = None # Primary tone for frontend
307
+ tone_indicators: list[str] = []
308
+ project_path: Optional[str] = None
309
+
310
+
311
+ class UserMessageFilters(BaseModel):
312
+ """Query filter parameters for user messages."""
313
+
314
+ session_id: Optional[str] = None
315
+ search: Optional[str] = None
316
+ has_code_blocks: Optional[bool] = None
317
+ has_questions: Optional[bool] = None
318
+ has_commands: Optional[bool] = None
319
+ tone_filter: Optional[str] = None
320
+ sort_by: str = "timestamp"
321
+ sort_order: str = "desc"
322
+ limit: int = Field(default=50, ge=1, le=500)
323
+ offset: int = Field(default=0, ge=0)
324
+
325
+
326
+ class UserMessagesResponse(BaseModel):
327
+ """Response containing user messages with pagination info."""
328
+
329
+ messages: list[UserMessage]
330
+ total_count: int
331
+ limit: int
332
+ offset: int
333
+ has_more: bool = False # Whether more results are available
334
+
335
+
336
+ class StyleSample(BaseModel):
337
+ """A sample message for style preview."""
338
+
339
+ id: str
340
+ timestamp: str
341
+ content_preview: str
342
+ word_count: Optional[int] = None
343
+ has_code_blocks: bool = False
344
+ has_questions: bool = False
345
+ tone_indicators: list[str] = []
346
+
347
+
348
+ class StyleProfile(BaseModel):
349
+ """User style profile for aggregated style analysis."""
350
+
351
+ id: str
352
+ project_path: Optional[str] = None
353
+ total_messages: int = 0
354
+ avg_word_count: Optional[float] = None
355
+ avg_char_count: Optional[float] = None
356
+ common_phrases: Optional[list[str]] = None
357
+ vocabulary_richness: Optional[float] = None
358
+ formality_score: Optional[float] = None
359
+ question_frequency: Optional[float] = None
360
+ command_frequency: Optional[float] = None
361
+ code_block_frequency: Optional[float] = None
362
+ punctuation_style: Optional[dict] = None
363
+ greeting_patterns: Optional[list[str]] = None
364
+ instruction_style: Optional[dict] = None
365
+ sample_messages: Optional[list[str]] = None
366
+ created_at: str
367
+ updated_at: str
368
+
369
+
370
+ class BulkDeleteRequest(BaseModel):
371
+ """Request body for bulk delete operations."""
372
+
373
+ message_ids: list[str] = Field(..., min_length=1, max_length=100)
374
+
375
+
376
+ # --- Response Composer Models ---
377
+
378
+
379
+ class ComposeRequest(BaseModel):
380
+ """Request for composing a response in user's style."""
381
+
382
+ incoming_message: str = Field(..., min_length=1, max_length=5000)
383
+ context_type: str = Field(default="general") # skool_post, dm, email, comment, general
384
+ template: Optional[str] = None # answer, guide, redirect, acknowledge
385
+ tone_level: int = Field(default=50, ge=0, le=100) # 0=casual, 100=professional
386
+ include_memories: bool = Field(default=True)
387
+ custom_instructions: Optional[str] = Field(default=None, max_length=2000)
388
+ include_explanation: bool = Field(default=False)
389
+
390
+
391
+ class ComposeResponse(BaseModel):
392
+ """Response from compose endpoint."""
393
+
394
+ id: str
395
+ response: str
396
+ sources: list[ChatSource]
397
+ style_applied: bool
398
+ tone_level: int
399
+ template_used: Optional[str]
400
+ incoming_message: str
401
+ context_type: str
402
+ created_at: str
403
+ custom_instructions: Optional[str] = None
404
+ explanation: Optional[str] = None
405
+
406
+
407
+ # --- Agent & ADW Models ---
408
+
409
+
410
+ class Agent(BaseModel):
411
+ """Agent from the agents table."""
412
+
413
+ id: str
414
+ name: Optional[str] = None
415
+ type: str # 'main', 'subagent', 'tool'
416
+ first_seen: datetime
417
+ last_seen: datetime
418
+ total_activities: int
419
+ recent_activity_count: int = 0 # Activities in last hour
420
+ is_active: bool = False # Has activity in last 5 minutes
421
+
422
+
423
+ class AgentToolStats(BaseModel):
424
+ """Tool usage breakdown for an agent."""
425
+
426
+ tool_name: str
427
+ count: int
428
+ avg_duration_ms: float
429
+ success_rate: float
430
+
431
+
432
+ class AgentStats(BaseModel):
433
+ """Detailed stats for a single agent."""
434
+
435
+ agent: Agent
436
+ tool_breakdown: list[AgentToolStats]
437
+ files_touched: list[str]
438
+ parent_agent_id: Optional[str] = None # If subagent, who spawned it
439
+ adw_phase: Optional[str] = None # Which ADW phase this agent ran in
440
+
441
+
442
+ class ADWPhaseInfo(BaseModel):
443
+ """Info about a single ADW phase."""
444
+
445
+ name: str # 'plan', 'build', 'validate', 'release'
446
+ status: str # 'pending', 'running', 'completed', 'failed', 'skipped'
447
+ duration_seconds: Optional[float] = None
448
+ agent_ids: list[str] = [] # Agents that ran in this phase
449
+
450
+
451
+ class ADWState(BaseModel):
452
+ """Full ADW state with agent correlation."""
453
+
454
+ adw_id: str
455
+ task_description: str
456
+ created_at: datetime
457
+ current_phase: str
458
+ completed_phases: list[str]
459
+ status: str # 'running', 'completed', 'failed'
460
+ phases: list[ADWPhaseInfo]
461
+ project_path: str
462
+
463
+
464
+ class ADWListItem(BaseModel):
465
+ """Summary for ADW list."""
466
+
467
+ adw_id: str
468
+ created_at: datetime
469
+ status: str
470
+ current_phase: str
471
+ phases_completed: int
472
+ phases_total: int
@@ -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