cli-memory-os 0.1.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 (50) hide show
  1. cli/__init__.py +1 -0
  2. cli/commands/__init__.py +1 -0
  3. cli/commands/benchmark.py +151 -0
  4. cli/commands/config_cmd.py +57 -0
  5. cli/commands/doctor.py +61 -0
  6. cli/commands/export.py +106 -0
  7. cli/commands/import_cmd.py +143 -0
  8. cli/commands/init.py +205 -0
  9. cli/commands/logs.py +38 -0
  10. cli/commands/monitor.py +63 -0
  11. cli/commands/plugins.py +37 -0
  12. cli/commands/start.py +24 -0
  13. cli/commands/status.py +92 -0
  14. cli/commands/stop.py +19 -0
  15. cli/commands/version.py +25 -0
  16. cli/commands/workspace.py +154 -0
  17. cli/main.py +496 -0
  18. cli/parser.py +188 -0
  19. cli_memory_os-0.1.0.dist-info/METADATA +231 -0
  20. cli_memory_os-0.1.0.dist-info/RECORD +50 -0
  21. cli_memory_os-0.1.0.dist-info/WHEEL +5 -0
  22. cli_memory_os-0.1.0.dist-info/entry_points.txt +2 -0
  23. cli_memory_os-0.1.0.dist-info/licenses/LICENSE +21 -0
  24. cli_memory_os-0.1.0.dist-info/top_level.txt +6 -0
  25. connectors/__init__.py +1 -0
  26. connectors/base.py +39 -0
  27. connectors/github.py +273 -0
  28. connectors/gmail.py +116 -0
  29. connectors/notion.py +156 -0
  30. connectors/registry.py +60 -0
  31. core/__init__.py +1 -0
  32. core/chunker.py +136 -0
  33. core/context_builder.py +407 -0
  34. core/embedder.py +42 -0
  35. core/llm.py +301 -0
  36. core/vector_store.py +629 -0
  37. infrastructure/__init__.py +1 -0
  38. infrastructure/compose.py +136 -0
  39. infrastructure/config.py +280 -0
  40. infrastructure/docker.py +61 -0
  41. infrastructure/health.py +338 -0
  42. infrastructure/observability.py +160 -0
  43. infrastructure/workspace.py +152 -0
  44. models/__init__.py +1 -0
  45. models/memory.py +28 -0
  46. storage/__init__.py +1 -0
  47. storage/db.py +528 -0
  48. storage/graph.py +324 -0
  49. storage/schema.sql +57 -0
  50. storage/tech_detector.py +117 -0
cli/main.py ADDED
@@ -0,0 +1,496 @@
1
+ """
2
+ Memory-OS CLI entry point.
3
+
4
+ Handles argument parsing, config loading, command routing,
5
+ and the interactive REPL.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ import sys
11
+
12
+ from cli.parser import build_parser
13
+
14
+
15
+ def setup_logging():
16
+ """Configure standardized rotating logging."""
17
+ from logging.handlers import RotatingFileHandler
18
+ from pathlib import Path
19
+
20
+ try:
21
+ from infrastructure.workspace import get_logs_path
22
+ logs_dir = get_logs_path()
23
+ except Exception:
24
+ logs_dir = Path("logs")
25
+
26
+ logs_dir.mkdir(parents=True, exist_ok=True)
27
+ log_file = logs_dir / "memory_os.log"
28
+
29
+ debug_mode = os.getenv("DEBUG", "false").lower() == "true"
30
+ level = logging.DEBUG if debug_mode else logging.INFO
31
+
32
+ file_handler = RotatingFileHandler(
33
+ log_file,
34
+ maxBytes=5 * 1024 * 1024,
35
+ backupCount=3,
36
+ encoding="utf-8"
37
+ )
38
+ file_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
39
+ file_handler.setLevel(level)
40
+
41
+ stream_handler = logging.StreamHandler(sys.stderr) if debug_mode else logging.NullHandler()
42
+
43
+ root_logger = logging.getLogger()
44
+ root_logger.setLevel(level)
45
+
46
+ for h in list(root_logger.handlers):
47
+ root_logger.removeHandler(h)
48
+
49
+ root_logger.addHandler(file_handler)
50
+ root_logger.addHandler(stream_handler)
51
+
52
+
53
+ def get_preview_snippet(content: str, query: str, length: int = 120) -> str:
54
+ """Extract a relevant preview snippet from content around the query match."""
55
+ if not content:
56
+ return ""
57
+ idx = content.lower().find(query.lower())
58
+ if idx != -1:
59
+ start = max(0, idx - 40)
60
+ if start > 0:
61
+ space_idx = content.find(" ", start, idx)
62
+ if space_idx != -1:
63
+ start = space_idx + 1
64
+ end = min(len(content), idx + len(query) + 80)
65
+ if end < len(content):
66
+ space_idx = content.rfind(" ", idx + len(query), end)
67
+ if space_idx != -1 and space_idx > idx + len(query):
68
+ end = space_idx
69
+
70
+ prefix = "..." if start > 0 else ""
71
+ suffix = "..." if end < len(content) else ""
72
+ return prefix + content[start:end].replace("\n", " ").replace("\r", " ").strip() + suffix
73
+ else:
74
+ snippet = content[:length].replace("\n", " ").replace("\r", " ").strip()
75
+ if len(content) > length:
76
+ snippet += "..."
77
+ return snippet
78
+
79
+
80
+ def print_menu():
81
+ """Print the interactive REPL menu."""
82
+ print("==================================================")
83
+ print("🧠 MEMORY-OS CLI")
84
+ print("==================================================")
85
+ print("Commands:")
86
+ print(" sync - Incremental sync from all sources")
87
+ print(" sync --rebuild - Full reset and sync from all sources")
88
+ print(" stats - Show database record counts")
89
+ print(" search <query> - Hybrid search across knowledge base")
90
+ print(" semantic-search <query> - Semantic search in vector index")
91
+ print(" ask <question> - Query RAG pipeline for grounded answer")
92
+ print(" projects - List all repositories")
93
+ print(" repo-info <repo> - Show details for repository")
94
+ print(" graph <repo> - Show graph relationships for repository")
95
+ print(" reset - Reset all local storage")
96
+ print(" exit - Exit Memory-OS")
97
+ print("==================================================")
98
+
99
+
100
+ def run_interactive():
101
+ """Run the interactive REPL loop (preserves all existing behavior)."""
102
+ from storage.db import (
103
+ init_db,
104
+ get_repo_count,
105
+ get_email_count,
106
+ get_repository_details,
107
+ get_repository_document_count,
108
+ get_all_repositories,
109
+ clear_all,
110
+ )
111
+ from core.vector_store import (
112
+ hybrid_search,
113
+ run_reindexing,
114
+ run_semantic_search,
115
+ close_qdrant_client,
116
+ )
117
+ from core.embedder import Embedder
118
+ from core.llm import run_hybrid_rag
119
+ from storage.graph import GraphStore
120
+ from connectors.github import sync_github
121
+ from connectors.gmail import sync_gmail
122
+ from connectors.notion import sync_notion
123
+
124
+ import sqlite3
125
+ try:
126
+ init_db()
127
+ except sqlite3.OperationalError as e:
128
+ import logging
129
+ logging.getLogger("repl").exception("Database operational error in REPL startup")
130
+ print("----------------------------------")
131
+ print("Workspace not initialized.")
132
+ print("")
133
+ print("Run:")
134
+ print("memory-os init")
135
+ print("----------------------------------")
136
+ return
137
+ except Exception as e:
138
+ import logging
139
+ logging.getLogger("repl").exception("Unexpected error in REPL startup database init")
140
+ print(f"❌ Failed to initialize database: {e}")
141
+ return
142
+
143
+ # Load SentenceTransformer model exactly once at startup
144
+ try:
145
+ print("Initializing embedding model...")
146
+ embedder = Embedder()
147
+ _ = embedder.model
148
+ print("System ready.")
149
+ except Exception as e:
150
+ import logging
151
+ logging.getLogger("repl").exception("Failed to initialize embedding model in REPL")
152
+ print(f"⚠️ Warning: Failed to initialize embedding model: {e}")
153
+ print("Model will download automatically on first query.")
154
+
155
+ print_menu()
156
+
157
+ while True:
158
+ try:
159
+ user_input = input("\nYou: ").strip()
160
+ if not user_input:
161
+ continue
162
+
163
+ parts = user_input.split(maxsplit=1)
164
+ cmd = parts[0].lower()
165
+ arg = parts[1].strip() if len(parts) > 1 else ""
166
+
167
+ # Natural Language Routing
168
+ VALID_COMMANDS = {
169
+ "exit", "sync", "stats", "search", "semantic-search", "ask",
170
+ "projects", "repo-info", "graph", "reset"
171
+ }
172
+ if cmd not in VALID_COMMANDS:
173
+ is_natural_language = (
174
+ user_input.rstrip().endswith("?") or
175
+ cmd in {"what", "how", "which", "who", "where", "why", "when",
176
+ "is", "are", "can", "do", "does", "tell", "show",
177
+ "explain", "describe", "find", "list", "get", "help", "please"}
178
+ )
179
+ if is_natural_language:
180
+ arg = user_input
181
+ cmd = "ask"
182
+
183
+ if cmd == "exit":
184
+ print("Closing resources...")
185
+ close_qdrant_client()
186
+ GraphStore().close()
187
+ print("Goodbye!")
188
+ break
189
+
190
+ elif cmd == "sync":
191
+ import time
192
+ start_time = time.perf_counter()
193
+ if arg == "--rebuild":
194
+ print("Performing full rebuild reset...")
195
+ clear_all()
196
+ close_qdrant_client()
197
+ run_reindexing()
198
+ GraphStore().clear_graph()
199
+
200
+ sync_github()
201
+ sync_gmail()
202
+ sync_notion()
203
+
204
+ run_reindexing()
205
+ GraphStore().extract_and_sync_graph()
206
+ duration = time.perf_counter() - start_time
207
+ print(f"\nRebuild complete. Total Duration: {duration:.2f}s")
208
+ logging.getLogger("main").info(f"Full rebuild completed in {duration:.2f}s")
209
+ else:
210
+ sync_github()
211
+ sync_gmail()
212
+ sync_notion()
213
+ run_reindexing()
214
+ GraphStore().extract_and_sync_graph()
215
+ duration = time.perf_counter() - start_time
216
+ print(f"\nSync complete. Total Duration: {duration:.2f}s")
217
+ logging.getLogger("main").info(f"Sync completed in {duration:.2f}s")
218
+
219
+ elif cmd == "stats":
220
+ repos = get_repo_count()
221
+ emails = get_email_count()
222
+ docs = get_repository_document_count()
223
+ print("========================================")
224
+ print("MEMORY-OS STATS")
225
+ print("========================================")
226
+ print(f"Repositories: {repos}")
227
+ print(f"Documents: {docs}")
228
+ print(f"Emails: {emails}")
229
+ print("========================================")
230
+
231
+ elif cmd == "search":
232
+ if not arg:
233
+ print("Usage: search <query>")
234
+ else:
235
+ results = hybrid_search(arg)
236
+ print("========================================")
237
+ print("HYBRID SEARCH RESULTS")
238
+ print("========================================")
239
+ if not results:
240
+ print("No matching results found.")
241
+ else:
242
+ for idx, item in enumerate(results, start=1):
243
+ t = item["type"]
244
+ if t == "repository":
245
+ print(f"{idx}. [Repository] {item['repo_name']} (Score: {item['score']})")
246
+ print(f" Language: {item['language']}")
247
+ desc = item['description'] or "No description."
248
+ desc_cleaned = desc.replace("\n", " ").replace("\r", " ").strip()
249
+ print(f" Description: {desc_cleaned}")
250
+ elif t == "document":
251
+ print(f"{idx}. [Document] {item['repo_name']} - {item['file_name']} (Score: {item['score']})")
252
+ preview = get_preview_snippet(item.get("content") or "", arg)
253
+ print(f" Preview: {preview}")
254
+ elif t == "email":
255
+ print(f"{idx}. [Email] {item['subject']} (Score: {item['score']})")
256
+ print(f" Sender: {item['sender']}")
257
+ preview = get_preview_snippet(item.get("snippet") or "", arg)
258
+ print(f" Preview: {preview}")
259
+ print("-" * 40)
260
+ print("========================================")
261
+
262
+ elif cmd == "semantic-search":
263
+ if not arg:
264
+ print("Usage: semantic-search <query>")
265
+ else:
266
+ results = run_semantic_search(arg, limit=5, source_filter=None)
267
+ print("========================================")
268
+ print("SEMANTIC SEARCH RESULTS")
269
+ print("========================================")
270
+ print(f"Query: {arg}")
271
+ if not results:
272
+ print("No results found.")
273
+ else:
274
+ for idx, res in enumerate(results, start=1):
275
+ print(f"{idx}. [Source: {res['source_type']}] (Score: {res['score']:.4f})")
276
+ print(f" Repository: {res['repository_name']}")
277
+ print(f" Document: {res['document_name']}")
278
+ print(f" Chunk Text: {res['chunk_text']}")
279
+ print("-" * 40)
280
+ print("========================================")
281
+
282
+ elif cmd == "ask":
283
+ if not arg:
284
+ print("Usage: ask <question>")
285
+ else:
286
+ rag_res = run_hybrid_rag(arg)
287
+ print("========================================")
288
+ print("ANSWER")
289
+ print("========================================")
290
+ print(rag_res["answer"])
291
+ print("\n========================================")
292
+ print("SOURCES")
293
+ print("========================================")
294
+ if rag_res["sources"]:
295
+ for s in rag_res["sources"]:
296
+ print(f"- {s}")
297
+ else:
298
+ print("None")
299
+ print("\n========================================")
300
+ print("REPOSITORIES USED")
301
+ print("========================================")
302
+ if rag_res["repositories"]:
303
+ for r in rag_res["repositories"]:
304
+ print(f"- {r}")
305
+ else:
306
+ print("None")
307
+ print("\n========================================")
308
+ print(f"Confidence: {rag_res['confidence']:.2f}")
309
+ print("========================================")
310
+
311
+ elif cmd == "repo-info":
312
+ if not arg:
313
+ print("Usage: repo-info <repository_name>")
314
+ else:
315
+ details = get_repository_details(arg)
316
+ if not details:
317
+ print(f"Repository '{arg}' not found.")
318
+ else:
319
+ print(f"Repository: {details['repo_name']}")
320
+ print(f"Description: {details['description']}")
321
+ print(f"Language: {details['language']}")
322
+ print(f"Stars: {details['stars']}")
323
+ print(f"Forks: {details['forks']}")
324
+ print(f"Last Updated: {details['updated_at'][:10] if details['updated_at'] else 'N/A'}")
325
+ print("Files synced:")
326
+ for f in sorted(details['files']):
327
+ print(f"- {f}")
328
+ if details['readme']:
329
+ print("\nREADME Preview:")
330
+ print(details['readme'][:400] + "...")
331
+
332
+ elif cmd == "projects":
333
+ repos = get_all_repositories()
334
+ print(f"Projects Found ({len(repos)}):")
335
+ for idx, r in enumerate(sorted(repos, key=lambda x: x["repo_name"].lower()), start=1):
336
+ print(f"{idx}. {r['repo_name']}")
337
+
338
+ elif cmd == "graph":
339
+ if not arg:
340
+ print("Usage: graph <repository_name>")
341
+ else:
342
+ g = GraphStore()
343
+ rels = g.get_node_relationships("Repository", arg)
344
+ print("========================================")
345
+ print(f"GRAPH RELATIONSHIPS FOR REPOSITORY: {arg}")
346
+ print("========================================")
347
+ if not rels:
348
+ print("No relationships found.")
349
+ else:
350
+ for r in sorted(rels):
351
+ print(f"- {r}")
352
+ print("========================================")
353
+
354
+ elif cmd == "reset":
355
+ confirm = input("Are you sure you want to reset all data? (y/N): ").strip().lower()
356
+ if confirm == "y":
357
+ clear_all()
358
+ close_qdrant_client()
359
+ run_reindexing()
360
+ GraphStore().clear_graph()
361
+ print("All data reset successfully.")
362
+ else:
363
+ print("Reset cancelled.")
364
+
365
+ else:
366
+ print(f"Unknown command: '{cmd}'. Type exit to quit or sync to pull latest data.")
367
+
368
+ except KeyboardInterrupt:
369
+ print("\nClosing resources...")
370
+ close_qdrant_client()
371
+ GraphStore().close()
372
+ print("Goodbye!")
373
+ break
374
+ except sqlite3.OperationalError as e:
375
+ import logging
376
+ logging.getLogger("repl").exception("Database operational error in REPL loop")
377
+ print("----------------------------------")
378
+ print("Workspace has not been initialized.")
379
+ print("")
380
+ print("Run:")
381
+ print("memory-os init")
382
+ print("----------------------------------")
383
+ except Exception as e:
384
+ import logging
385
+ logging.getLogger("repl").exception("Unexpected error during command execution in REPL")
386
+ print(f"Error during command execution: {e}")
387
+
388
+
389
+ def route_command(args):
390
+ """Route a parsed subcommand to its handler module."""
391
+ command = args.command
392
+
393
+ if command is None:
394
+ # No subcommand given — launch interactive REPL
395
+ run_interactive()
396
+ return
397
+
398
+ # Import and execute the appropriate command module
399
+ if command == "doctor":
400
+ from cli.commands.doctor import execute
401
+ execute(args)
402
+ elif command == "version":
403
+ from cli.commands.version import execute
404
+ execute(args)
405
+ elif command == "status":
406
+ from cli.commands.status import execute
407
+ execute(args)
408
+ elif command == "init":
409
+ from cli.commands.init import execute
410
+ execute(args)
411
+ elif command == "start":
412
+ from cli.commands.start import execute
413
+ execute(args)
414
+ elif command == "stop":
415
+ from cli.commands.stop import execute
416
+ execute(args)
417
+ elif command == "plugins":
418
+ from cli.commands.plugins import execute
419
+ execute(args)
420
+ elif command == "monitor":
421
+ from cli.commands.monitor import execute
422
+ execute(args)
423
+ elif command == "export":
424
+ from cli.commands.export import execute
425
+ execute(args)
426
+ elif command == "import":
427
+ from cli.commands.import_cmd import execute
428
+ execute(args)
429
+ elif command == "config":
430
+ from cli.commands.config_cmd import execute
431
+ execute(args)
432
+ elif command == "workspace":
433
+ from cli.commands.workspace import execute
434
+ execute(args)
435
+ elif command == "benchmark":
436
+ from cli.commands.benchmark import execute
437
+ execute(args)
438
+ elif command == "logs":
439
+ from cli.commands.logs import execute
440
+ execute(args)
441
+ else:
442
+ # For commands not yet implemented, show a helpful message
443
+ print(f"Command '{command}' is not yet implemented.")
444
+ print("Run 'memory-os --help' for available commands.")
445
+ print("Run 'memory-os' (no arguments) for the interactive REPL.")
446
+
447
+
448
+
449
+ def cli_entrypoint():
450
+ """Main CLI entry point. Called by `memory-os` console script and `python main.py`."""
451
+ # Force UTF-8 stdout on Windows
452
+ try:
453
+ sys.stdout.reconfigure(encoding='utf-8')
454
+ except Exception:
455
+ pass
456
+
457
+ setup_logging()
458
+
459
+ # Load configuration (TOML → .env → defaults)
460
+ try:
461
+ from infrastructure.config import load_config
462
+ load_config()
463
+ except Exception:
464
+ # Config loading is best-effort at this stage; .env fallback
465
+ # still works via load_dotenv in config.py
466
+ from dotenv import load_dotenv
467
+ load_dotenv()
468
+
469
+ parser = build_parser()
470
+ args = parser.parse_args()
471
+
472
+ import logging
473
+ import sqlite3
474
+ logger = logging.getLogger("cli.main")
475
+
476
+ try:
477
+ route_command(args)
478
+ except sqlite3.OperationalError as e:
479
+ logger.exception("Database operational error occurred")
480
+ print("----------------------------------")
481
+ print("Workspace has not been initialized.")
482
+ print("")
483
+ print("Run:")
484
+ print("memory-os init")
485
+ print("----------------------------------")
486
+ sys.exit(1)
487
+ except Exception as e:
488
+ logger.exception("Unexpected error in command execution")
489
+ print(f"❌ An unexpected error occurred: {e}")
490
+ print("Please check logs/memory_os.log for full details.")
491
+ sys.exit(1)
492
+
493
+
494
+ if __name__ == "__main__":
495
+ cli_entrypoint()
496
+
cli/parser.py ADDED
@@ -0,0 +1,188 @@
1
+ """
2
+ CLI argument parser.
3
+
4
+ Builds the argparse parser with subcommands for all Memory-OS commands.
5
+ Each subcommand routes to its corresponding module in cli/commands/.
6
+ """
7
+
8
+ import argparse
9
+
10
+
11
+ def build_parser() -> argparse.ArgumentParser:
12
+ """Build and return the Memory-OS CLI argument parser."""
13
+ parser = argparse.ArgumentParser(
14
+ prog="memory-os",
15
+ description="Memory-OS — CLI-based Personal Knowledge Operating System",
16
+ )
17
+
18
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
19
+
20
+ # ── init ──────────────────────────────────────────────────
21
+ subparsers.add_parser(
22
+ "init",
23
+ help="Initialize Memory-OS (workspace, config, Docker services)",
24
+ )
25
+
26
+ # ── sync ──────────────────────────────────────────────────
27
+ sync_parser = subparsers.add_parser(
28
+ "sync",
29
+ help="Sync data from connected sources",
30
+ )
31
+ sync_parser.add_argument(
32
+ "--rebuild", action="store_true",
33
+ help="Full reset and rebuild from all sources",
34
+ )
35
+ sync_parser.add_argument(
36
+ "--source", type=str, choices=["github", "gmail", "notion"],
37
+ help="Sync only a specific source",
38
+ )
39
+
40
+ # ── ask ────────────────────────────────────────────────────
41
+ ask_parser = subparsers.add_parser(
42
+ "ask",
43
+ help="Query the RAG pipeline with a question",
44
+ )
45
+ ask_parser.add_argument(
46
+ "question", nargs="+", type=str,
47
+ help="The question to ask",
48
+ )
49
+
50
+ # ── doctor ────────────────────────────────────────────────
51
+ subparsers.add_parser(
52
+ "doctor",
53
+ help="Diagnose system health and connectivity",
54
+ )
55
+
56
+ # ── status ────────────────────────────────────────────────
57
+ subparsers.add_parser(
58
+ "status",
59
+ help="Show workspace and service status",
60
+ )
61
+
62
+ # ── backup ────────────────────────────────────────────────
63
+ backup_parser = subparsers.add_parser(
64
+ "backup",
65
+ help="Create a timestamped backup of workspace data",
66
+ )
67
+ backup_parser.add_argument(
68
+ "--include-logs", action="store_true",
69
+ help="Include log files in the backup",
70
+ )
71
+
72
+ # ── export ────────────────────────────────────────────────
73
+ export_parser = subparsers.add_parser(
74
+ "export",
75
+ help="Export active workspace profile and configuration to a compressed file",
76
+ )
77
+ export_parser.add_argument(
78
+ "file", type=str,
79
+ help="Target archive file path (e.g. backup.zip)",
80
+ )
81
+
82
+ # ── import ────────────────────────────────────────────────
83
+ import_parser = subparsers.add_parser(
84
+ "import",
85
+ help="Import and restore a previously exported workspace archive",
86
+ )
87
+ import_parser.add_argument(
88
+ "file", type=str,
89
+ help="Workspace archive file path to import",
90
+ )
91
+
92
+
93
+ # ── graph ─────────────────────────────────────────────────
94
+ graph_parser = subparsers.add_parser(
95
+ "graph",
96
+ help="Show knowledge graph relationships for a repository",
97
+ )
98
+ graph_parser.add_argument(
99
+ "repo", type=str,
100
+ help="Repository name to inspect",
101
+ )
102
+
103
+ # ── update ────────────────────────────────────────────────
104
+ subparsers.add_parser(
105
+ "update",
106
+ help="Check and update Docker service images",
107
+ )
108
+
109
+ # ── start ─────────────────────────────────────────────────
110
+ subparsers.add_parser(
111
+ "start",
112
+ help="Start Docker services (Neo4j, Qdrant)",
113
+ )
114
+
115
+ # ── stop ──────────────────────────────────────────────────
116
+ subparsers.add_parser(
117
+ "stop",
118
+ help="Stop Docker services (preserves data)",
119
+ )
120
+
121
+ # ── version ───────────────────────────────────────────────
122
+ subparsers.add_parser(
123
+ "version",
124
+ help="Show Memory-OS version information",
125
+ )
126
+
127
+ # ── logs ──────────────────────────────────────────────────
128
+ logs_parser = subparsers.add_parser(
129
+ "logs",
130
+ help="View Memory-OS log output",
131
+ )
132
+ logs_parser.add_argument(
133
+ "--tail", type=int, default=50,
134
+ help="Number of log lines to show (default: 50)",
135
+ )
136
+
137
+ # ── migrate ───────────────────────────────────────────────
138
+ subparsers.add_parser(
139
+ "migrate",
140
+ help="Migrate existing data into the workspace",
141
+ )
142
+
143
+ # ── workspace ─────────────────────────────────────────────
144
+ workspace_parser = subparsers.add_parser(
145
+ "workspace",
146
+ help="Manage workspace profiles",
147
+ )
148
+ workspace_sub = workspace_parser.add_subparsers(dest="workspace_action")
149
+ ws_create = workspace_sub.add_parser("create", help="Create a new workspace profile")
150
+ ws_create.add_argument("name", type=str, help="Profile name")
151
+ ws_switch = workspace_sub.add_parser("switch", help="Switch active workspace profile")
152
+ ws_switch.add_argument("name", type=str, help="Profile name to switch to")
153
+ workspace_sub.add_parser("list", help="List all workspace profiles")
154
+ ws_delete = workspace_sub.add_parser("delete", help="Delete a workspace profile")
155
+ ws_delete.add_argument("name", type=str, help="Profile name to delete")
156
+
157
+ # ── config ────────────────────────────────────────────────
158
+ config_parser = subparsers.add_parser(
159
+ "config",
160
+ help="Inspect or modify configuration",
161
+ )
162
+ config_sub = config_parser.add_subparsers(dest="config_action")
163
+ config_sub.add_parser("show", help="Show full configuration")
164
+ config_get = config_sub.add_parser("get", help="Get a config value")
165
+ config_get.add_argument("key", type=str, help="Config key (e.g. groq.model)")
166
+ config_set = config_sub.add_parser("set", help="Set a config value")
167
+ config_set.add_argument("key", type=str, help="Config key (e.g. groq.model)")
168
+ config_set.add_argument("value", type=str, help="New value")
169
+
170
+ # ── plugins ───────────────────────────────────────────────
171
+ subparsers.add_parser(
172
+ "plugins",
173
+ help="List installed and available connector plugins",
174
+ )
175
+
176
+ # ── monitor ───────────────────────────────────────────────
177
+ subparsers.add_parser(
178
+ "monitor",
179
+ help="Show system observability and latency performance metrics",
180
+ )
181
+
182
+ # ── benchmark ─────────────────────────────────────────────
183
+ subparsers.add_parser(
184
+ "benchmark",
185
+ help="Perform speed run latency benchmarks on query pipelines",
186
+ )
187
+
188
+ return parser