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
@@ -0,0 +1,136 @@
1
+ """
2
+ Infrastructure: Docker Compose orchestration.
3
+
4
+ Wraps docker compose commands. Environment variables (ports, paths,
5
+ passwords) are exported by config.py before these functions are called,
6
+ so docker-compose.yml can reference them via ${VAR} syntax.
7
+ """
8
+
9
+ import os
10
+ import subprocess
11
+ import time
12
+ import json
13
+ import logging
14
+
15
+ logger = logging.getLogger("infrastructure.compose")
16
+
17
+
18
+ def _get_compose_file() -> str:
19
+ """Return path to the project's docker-compose.yml."""
20
+ return os.path.join(os.path.dirname(os.path.dirname(__file__)), "docker-compose.yml")
21
+
22
+
23
+ def _run_compose(args: list[str], project_dir: str | None = None) -> subprocess.CompletedProcess:
24
+ """Run a docker compose command with the project compose file."""
25
+ from infrastructure.workspace import get_neo4j_path, get_qdrant_path
26
+
27
+ compose_file = _get_compose_file()
28
+ cmd = ["docker", "compose", "-f", compose_file] + args
29
+
30
+ env = os.environ.copy()
31
+ env["MEMORY_OS_NEO4J_PATH"] = str(get_neo4j_path())
32
+ env["MEMORY_OS_QDRANT_PATH"] = str(get_qdrant_path())
33
+
34
+ return subprocess.run(
35
+ cmd,
36
+ capture_output=True,
37
+ text=True,
38
+ timeout=120,
39
+ env=env,
40
+ cwd=project_dir,
41
+ )
42
+
43
+
44
+
45
+ def compose_up(project_dir: str | None = None) -> bool:
46
+ """Start services with docker compose up -d."""
47
+ result = _run_compose(["up", "-d"], project_dir)
48
+ if result.returncode != 0:
49
+ logger.error(f"docker compose up failed: {result.stderr}")
50
+ return False
51
+ return True
52
+
53
+
54
+ def compose_down(project_dir: str | None = None) -> bool:
55
+ """Stop and remove containers with docker compose down."""
56
+ result = _run_compose(["down"], project_dir)
57
+ if result.returncode != 0:
58
+ logger.error(f"docker compose down failed: {result.stderr}")
59
+ return False
60
+ return True
61
+
62
+
63
+ def compose_stop(project_dir: str | None = None) -> bool:
64
+ """Stop containers without removing them (preserves data)."""
65
+ result = _run_compose(["stop"], project_dir)
66
+ if result.returncode != 0:
67
+ logger.error(f"docker compose stop failed: {result.stderr}")
68
+ return False
69
+ return True
70
+
71
+
72
+ def compose_status(project_dir: str | None = None) -> list[dict]:
73
+ """Get container status via docker compose ps --format json."""
74
+ result = _run_compose(["ps", "--format", "json"], project_dir)
75
+ if result.returncode != 0:
76
+ return []
77
+ try:
78
+ # docker compose ps --format json may return one JSON object per line
79
+ containers = []
80
+ for line in result.stdout.strip().splitlines():
81
+ line = line.strip()
82
+ if line:
83
+ containers.append(json.loads(line))
84
+ return containers
85
+ except (json.JSONDecodeError, ValueError):
86
+ return []
87
+
88
+
89
+ def wait_for_services(timeout: int = 60) -> bool:
90
+ """Poll Neo4j and Qdrant health endpoints until ready or timeout.
91
+
92
+ Returns True if all services are healthy within the timeout.
93
+ """
94
+ import urllib.request
95
+ import urllib.error
96
+
97
+ neo4j_port = os.getenv("NEO4J_HTTP_PORT", "7474")
98
+ qdrant_port = os.getenv("QDRANT_PORT", "6333")
99
+
100
+ neo4j_url = f"http://localhost:{neo4j_port}"
101
+ qdrant_url = f"http://localhost:{qdrant_port}/healthz"
102
+
103
+ start = time.time()
104
+ neo4j_ready = False
105
+ qdrant_ready = False
106
+
107
+ while time.time() - start < timeout:
108
+ # Check Qdrant
109
+ if not qdrant_ready:
110
+ try:
111
+ req = urllib.request.urlopen(qdrant_url, timeout=3)
112
+ if req.status == 200:
113
+ qdrant_ready = True
114
+ except Exception:
115
+ pass
116
+
117
+ # Check Neo4j
118
+ if not neo4j_ready:
119
+ try:
120
+ req = urllib.request.urlopen(neo4j_url, timeout=3)
121
+ if req.status == 200:
122
+ neo4j_ready = True
123
+ except Exception:
124
+ pass
125
+
126
+ if neo4j_ready and qdrant_ready:
127
+ return True
128
+
129
+ time.sleep(2)
130
+
131
+ logger.warning(
132
+ f"Service health timeout after {timeout}s. "
133
+ f"Neo4j={'ready' if neo4j_ready else 'not ready'}, "
134
+ f"Qdrant={'ready' if qdrant_ready else 'not ready'}"
135
+ )
136
+ return False
@@ -0,0 +1,280 @@
1
+ """
2
+ Infrastructure: Configuration management.
3
+
4
+ Implements config priority chain:
5
+ CLI Flags -> config.toml -> .env -> Defaults
6
+
7
+ Config is loaded once at startup and exported to os.environ so existing
8
+ modules (llm.py, graph.py, vector_store.py) work without changes.
9
+ """
10
+
11
+ import os
12
+ import tomllib
13
+ from pathlib import Path
14
+ from dotenv import load_dotenv
15
+
16
+ # Default configuration values
17
+ DEFAULTS = {
18
+ "workspace": "~/.memory-os",
19
+ "active_profile": "default",
20
+ "composio": {
21
+ "api_key": "",
22
+ "user_id": "",
23
+ },
24
+ "groq": {
25
+ "api_key": "",
26
+ "model": "llama-3.3-70b-versatile",
27
+ },
28
+ "neo4j": {
29
+ "uri": "bolt://localhost:7687",
30
+ "user": "neo4j",
31
+ "password": "",
32
+ "port_http": 7474,
33
+ "port_bolt": 7687,
34
+ },
35
+ "qdrant": {
36
+ "url": "http://localhost:6333",
37
+ "port": 6333,
38
+ },
39
+ "embeddings": {
40
+ "model": "all-MiniLM-L6-v2",
41
+ },
42
+ }
43
+
44
+ _config = None
45
+
46
+
47
+ def _get_config_path() -> Path:
48
+ """Return the path to the config.toml file."""
49
+ workspace_root = Path(os.path.expanduser(
50
+ os.getenv("MEMORY_OS_WORKSPACE", "~/.memory-os")
51
+ ))
52
+ return workspace_root / "config.toml"
53
+
54
+
55
+ def _deep_merge(base: dict, override: dict) -> dict:
56
+ """Recursively merge override into base, returning a new dict."""
57
+ merged = dict(base)
58
+ for key, value in override.items():
59
+ if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
60
+ merged[key] = _deep_merge(merged[key], value)
61
+ else:
62
+ merged[key] = value
63
+ return merged
64
+
65
+
66
+ def load_config(cli_overrides: dict | None = None) -> dict:
67
+ """Load configuration using the priority chain.
68
+
69
+ Priority (highest to lowest):
70
+ 1. cli_overrides dict
71
+ 2. ~/.memory-os/config.toml
72
+ 3. .env file (loaded into os.environ)
73
+ 4. DEFAULTS
74
+
75
+ After merging, key values are exported to os.environ so that
76
+ existing modules pick them up transparently.
77
+ """
78
+ global _config
79
+
80
+ # Start with defaults
81
+ config = dict(DEFAULTS)
82
+ for k, v in DEFAULTS.items():
83
+ if isinstance(v, dict):
84
+ config[k] = dict(v)
85
+
86
+ # Layer 3: Load .env (lowest priority, loaded into os.environ)
87
+ load_dotenv(override=False)
88
+
89
+ # Layer 2: Load config.toml if it exists
90
+ config_path = _get_config_path()
91
+ if config_path.exists():
92
+ with open(config_path, "rb") as f:
93
+ toml_config = tomllib.load(f)
94
+ config = _deep_merge(config, toml_config)
95
+
96
+ # Layer 1: CLI overrides (highest priority)
97
+ if cli_overrides:
98
+ config = _deep_merge(config, cli_overrides)
99
+
100
+ # Export key values to os.environ for backwards compatibility
101
+ _export_to_environ(config)
102
+
103
+ _config = config
104
+ return config
105
+
106
+
107
+ def _export_to_environ(config: dict):
108
+ """Export configuration values to os.environ.
109
+
110
+ This ensures existing modules that read os.getenv() continue
111
+ to work without modification.
112
+ """
113
+ # Groq
114
+ groq = config.get("groq", {})
115
+ if groq.get("api_key"):
116
+ os.environ.setdefault("GROQ_API_KEY", groq["api_key"])
117
+ if groq.get("model"):
118
+ os.environ.setdefault("GROQ_MODEL", groq["model"])
119
+
120
+ # Composio
121
+ composio = config.get("composio", {})
122
+ if composio.get("api_key"):
123
+ os.environ.setdefault("COMPOSIO_API_KEY", composio["api_key"])
124
+
125
+ # Neo4j
126
+ neo4j = config.get("neo4j", {})
127
+ if neo4j.get("uri"):
128
+ os.environ.setdefault("NEO4J_URI", neo4j["uri"])
129
+ if neo4j.get("user"):
130
+ os.environ.setdefault("NEO4J_USER", neo4j["user"])
131
+ if neo4j.get("password"):
132
+ os.environ.setdefault("NEO4J_PASSWORD", neo4j["password"])
133
+ # Port env vars for docker-compose
134
+ os.environ.setdefault("NEO4J_HTTP_PORT", str(neo4j.get("port_http", 7474)))
135
+ os.environ.setdefault("NEO4J_BOLT_PORT", str(neo4j.get("port_bolt", 7687)))
136
+
137
+ # Qdrant
138
+ qdrant = config.get("qdrant", {})
139
+ if qdrant.get("url"):
140
+ os.environ.setdefault("QDRANT_URL", qdrant["url"])
141
+ os.environ.setdefault("QDRANT_PORT", str(qdrant.get("port", 6333)))
142
+
143
+
144
+ def get_config() -> dict:
145
+ """Return the currently loaded config, loading it if necessary."""
146
+ global _config
147
+ if _config is None:
148
+ load_config()
149
+ return _config
150
+
151
+
152
+ def get(section: str, key: str, default=None):
153
+ """Read a specific config value. Example: get('groq', 'model')"""
154
+ config = get_config()
155
+ section_dict = config.get(section, {})
156
+ if isinstance(section_dict, dict):
157
+ return section_dict.get(key, default)
158
+ return default
159
+
160
+
161
+ def save_config(config_dict: dict):
162
+ """Write configuration to ~/.memory-os/config.toml."""
163
+ import tomllib # read-only; we write manually for now
164
+ config_path = _get_config_path()
165
+ config_path.parent.mkdir(parents=True, exist_ok=True)
166
+
167
+ lines = []
168
+ for key, value in config_dict.items():
169
+ if isinstance(value, dict):
170
+ lines.append(f"\n[{key}]")
171
+ for k, v in value.items():
172
+ lines.append(f'{k} = {_toml_value(v)}')
173
+ else:
174
+ lines.append(f'{key} = {_toml_value(value)}')
175
+
176
+ config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
177
+
178
+
179
+ def set_value(section: str, key: str, value):
180
+ """Update a single config key and save."""
181
+ config = get_config()
182
+ if section not in config:
183
+ config[section] = {}
184
+ config[section][key] = value
185
+ save_config(config)
186
+
187
+
188
+ def get_dotted(key: str, default=None):
189
+ """Fetch value from config dictionary using dotted path, e.g., 'groq.model' or 'workspace'."""
190
+ config = get_config()
191
+ parts = key.split(".")
192
+
193
+ current = config
194
+ for part in parts:
195
+ if isinstance(current, dict) and part in current:
196
+ current = current[part]
197
+ else:
198
+ return default
199
+ return current
200
+
201
+
202
+ def set_dotted(key: str, value) -> tuple[bool, str]:
203
+ """Set value in config dictionary using dotted path, e.g., 'groq.model' or 'workspace'.
204
+ Performs type and schema validations. Returns (success, error_message).
205
+ """
206
+ config = get_config()
207
+ parts = key.split(".")
208
+
209
+ if len(parts) == 1:
210
+ t_key = parts[0]
211
+ if t_key not in DEFAULTS:
212
+ return False, f"Invalid configuration key '{t_key}'"
213
+ if t_key == "workspace" and not value:
214
+ return False, "Workspace path cannot be empty"
215
+ config[t_key] = value
216
+ elif len(parts) == 2:
217
+ section, prop = parts[0], parts[1]
218
+ if section not in DEFAULTS or prop not in DEFAULTS[section]:
219
+ return False, f"Invalid configuration key '{key}'"
220
+
221
+ default_val = DEFAULTS[section][prop]
222
+ if isinstance(default_val, int):
223
+ try:
224
+ value = int(value)
225
+ if value <= 0:
226
+ raise ValueError()
227
+ except ValueError:
228
+ return False, f"Configuration key '{key}' must be a positive integer"
229
+ elif isinstance(default_val, bool):
230
+ if str(value).lower() in ("true", "1", "yes", "y"):
231
+ value = True
232
+ elif str(value).lower() in ("false", "0", "no", "n"):
233
+ value = False
234
+ else:
235
+ return False, f"Configuration key '{key}' must be a boolean value"
236
+
237
+ if section not in config:
238
+ config[section] = {}
239
+ config[section][prop] = value
240
+ else:
241
+ return False, f"Configuration key depth '{key}' not supported"
242
+
243
+ save_config(config)
244
+ return True, ""
245
+
246
+
247
+
248
+ def generate_default_config(answers: dict) -> dict:
249
+ """Create initial config from prompted values during init.
250
+
251
+ ``answers`` should contain keys like 'groq_api_key',
252
+ 'composio_api_key', 'neo4j_password', 'composio_user_id'.
253
+ """
254
+ import copy
255
+ config = copy.deepcopy(DEFAULTS)
256
+
257
+ if answers.get("groq_api_key"):
258
+ config["groq"]["api_key"] = answers["groq_api_key"]
259
+ if answers.get("composio_api_key"):
260
+ config["composio"]["api_key"] = answers["composio_api_key"]
261
+ if answers.get("composio_user_id"):
262
+ config["composio"]["user_id"] = answers["composio_user_id"]
263
+ if answers.get("neo4j_password"):
264
+ config["neo4j"]["password"] = answers["neo4j_password"]
265
+
266
+ return config
267
+
268
+
269
+ def _toml_value(value) -> str:
270
+ """Format a Python value as a TOML literal."""
271
+ if isinstance(value, str):
272
+ return f'"{value}"'
273
+ elif isinstance(value, bool):
274
+ return "true" if value else "false"
275
+ elif isinstance(value, int):
276
+ return str(value)
277
+ elif isinstance(value, float):
278
+ return str(value)
279
+ else:
280
+ return f'"{value}"'
@@ -0,0 +1,61 @@
1
+ """
2
+ Infrastructure: Docker availability checks.
3
+
4
+ Verifies that Docker and Docker Compose are installed and running.
5
+ """
6
+
7
+ import subprocess
8
+ import logging
9
+
10
+ logger = logging.getLogger("infrastructure.docker")
11
+
12
+
13
+ def check_docker_installed() -> tuple[bool, str]:
14
+ """Check if Docker is installed. Returns (available, version_string)."""
15
+ try:
16
+ result = subprocess.run(
17
+ ["docker", "--version"],
18
+ capture_output=True, text=True, timeout=10
19
+ )
20
+ if result.returncode == 0:
21
+ version = result.stdout.strip()
22
+ return True, version
23
+ return False, ""
24
+ except FileNotFoundError:
25
+ return False, ""
26
+ except Exception as e:
27
+ logger.error(f"Docker check failed: {e}")
28
+ return False, ""
29
+
30
+
31
+ def check_docker_compose_installed() -> tuple[bool, str]:
32
+ """Check if Docker Compose is installed. Returns (available, version_string)."""
33
+ try:
34
+ result = subprocess.run(
35
+ ["docker", "compose", "version"],
36
+ capture_output=True, text=True, timeout=10
37
+ )
38
+ if result.returncode == 0:
39
+ version = result.stdout.strip()
40
+ return True, version
41
+ return False, ""
42
+ except FileNotFoundError:
43
+ return False, ""
44
+ except Exception as e:
45
+ logger.error(f"Docker Compose check failed: {e}")
46
+ return False, ""
47
+
48
+
49
+ def check_docker_running() -> bool:
50
+ """Verify that the Docker daemon is active."""
51
+ try:
52
+ result = subprocess.run(
53
+ ["docker", "info"],
54
+ capture_output=True, text=True, timeout=10
55
+ )
56
+ return result.returncode == 0
57
+ except FileNotFoundError:
58
+ return False
59
+ except Exception as e:
60
+ logger.error(f"Docker daemon check failed: {e}")
61
+ return False