loom-learn 0.3.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.
- loom/__init__.py +15 -0
- loom/cli/__init__.py +1 -0
- loom/cli/main.py +569 -0
- loom/coaching/__init__.py +5 -0
- loom/coaching/amplifier.py +478 -0
- loom/config.py +23 -0
- loom/engine/__init__.py +23 -0
- loom/engine/auto_observer.py +785 -0
- loom/engine/context_loader.py +602 -0
- loom/engine/decay_manager.py +75 -0
- loom/engine/domain_extractor.py +159 -0
- loom/engine/llm_extractor.py +103 -0
- loom/engine/org_store.py +812 -0
- loom/engine/retention.py +460 -0
- loom/engine/rule_store.py +251 -0
- loom/engine/timeline.py +432 -0
- loom/llm/__init__.py +15 -0
- loom/llm/anthropic.py +110 -0
- loom/llm/base.py +101 -0
- loom/llm/deepseek.py +108 -0
- loom/llm/factory.py +60 -0
- loom/llm/gemini.py +82 -0
- loom/mcp/__init__.py +24 -0
- loom/mcp/__main__.py +16 -0
- loom/mcp/proxy.py +338 -0
- loom/mcp/server.py +2357 -0
- loom/onboarding/__init__.py +9 -0
- loom/onboarding/packs.py +312 -0
- loom/onboarding/succession.py +498 -0
- loom/security/__init__.py +20 -0
- loom/security/access.py +74 -0
- loom/security/audit.py +97 -0
- loom/security/integrity.py +61 -0
- loom/security/private_mode.py +51 -0
- loom/security/rbac.py +445 -0
- loom/security/redactor.py +43 -0
- loom/security/tests/test_security.py +197 -0
- loom/storage/__init__.py +6 -0
- loom/storage/adapters.py +552 -0
- loom/storage/backend.py +78 -0
- loom/storage/migrations/001_initial.sql +129 -0
- loom/storage/postgres_store.py +761 -0
- loom_learn-0.3.0.dist-info/METADATA +17 -0
- loom_learn-0.3.0.dist-info/RECORD +47 -0
- loom_learn-0.3.0.dist-info/WHEEL +5 -0
- loom_learn-0.3.0.dist-info/entry_points.txt +2 -0
- loom_learn-0.3.0.dist-info/top_level.txt +1 -0
loom/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Loom — The memory layer for AI agents.
|
|
2
|
+
|
|
3
|
+
Glen-level features:
|
|
4
|
+
- Auto-capture (passive observation)
|
|
5
|
+
- Auto-recall (pre-loaded context every session)
|
|
6
|
+
- Org-wide shared memory (one repository for the whole org)
|
|
7
|
+
- Per-observation RBAC (agents see only what their user is cleared to see)
|
|
8
|
+
- Tiered retention (permanent org knowledge + decaying conventions)
|
|
9
|
+
- Auditable timeline (one queryable history of the organization)
|
|
10
|
+
- Instant onboarding (new hire's agent already knows the org)
|
|
11
|
+
- Succession capture (knowledge survives turnover)
|
|
12
|
+
- Coaching amplification (top performer patterns scale across the team)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "0.3.0"
|
loom/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Loom CLI — setup, doctor, and admin commands."""
|
loom/cli/main.py
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
"""Loom CLI — one-shot setup and health checks."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _python_path() -> str:
|
|
11
|
+
"""Return the full path to the current Python interpreter."""
|
|
12
|
+
return sys.executable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _is_windows() -> bool:
|
|
16
|
+
return sys.platform == "win32"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cmd_setup(args=None):
|
|
20
|
+
"""Generate a ready-to-paste MCP config for Claude Desktop."""
|
|
21
|
+
project_root = os.environ.get(
|
|
22
|
+
"LOOM_PROJECT_ROOT",
|
|
23
|
+
str(Path.home() / "loom-memory"),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Auto-create the directory
|
|
27
|
+
Path(project_root).mkdir(parents=True, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
python = _python_path()
|
|
30
|
+
is_mac = sys.platform == "darwin"
|
|
31
|
+
is_win = _is_windows()
|
|
32
|
+
|
|
33
|
+
if is_mac:
|
|
34
|
+
config_path = "~/Library/Application Support/Claude/claude_desktop_config.json"
|
|
35
|
+
elif is_win:
|
|
36
|
+
config_path = "%APPDATA%\\Claude\\claude_desktop_config.json"
|
|
37
|
+
else:
|
|
38
|
+
config_path = "~/.config/Claude/claude_desktop_config.json"
|
|
39
|
+
|
|
40
|
+
print("=" * 60)
|
|
41
|
+
print(" Loom MCP Server — One-Shot Setup")
|
|
42
|
+
print("=" * 60)
|
|
43
|
+
print()
|
|
44
|
+
print(f" Project root : {project_root}")
|
|
45
|
+
print(f" Python : {python}")
|
|
46
|
+
print(f" Config file : {config_path}")
|
|
47
|
+
print()
|
|
48
|
+
|
|
49
|
+
# Base config (no API key)
|
|
50
|
+
base_config = {
|
|
51
|
+
"mcpServers": {
|
|
52
|
+
"loom": {
|
|
53
|
+
"command": python,
|
|
54
|
+
"args": ["-m", "loom.mcp"],
|
|
55
|
+
"env": {
|
|
56
|
+
"LOOM_PROJECT_ROOT": project_root,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
print("── Paste this into your Claude config file: ──")
|
|
63
|
+
print()
|
|
64
|
+
print(json.dumps(base_config, indent=2))
|
|
65
|
+
print()
|
|
66
|
+
|
|
67
|
+
# API key options
|
|
68
|
+
has_anthropic = bool(os.environ.get("ANTHROPIC_API_KEY"))
|
|
69
|
+
has_deepseek = bool(os.environ.get("LOOM_DEEPSEEK_API_KEY"))
|
|
70
|
+
has_gemini = bool(os.environ.get("GEMINI_API_KEY"))
|
|
71
|
+
|
|
72
|
+
if has_anthropic or has_deepseek or has_gemini:
|
|
73
|
+
print("── Detected API keys in your environment ──")
|
|
74
|
+
print()
|
|
75
|
+
for name, key, env_var in [
|
|
76
|
+
("Anthropic", has_anthropic, "ANTHROPIC_API_KEY"),
|
|
77
|
+
("DeepSeek", has_deepseek, "LOOM_DEEPSEEK_API_KEY"),
|
|
78
|
+
("Gemini", has_gemini, "GEMINI_API_KEY"),
|
|
79
|
+
]:
|
|
80
|
+
if key:
|
|
81
|
+
masked = os.environ[env_var][:7] + "..." if os.environ[env_var] else ""
|
|
82
|
+
print(f" {name}: {masked} (from ${env_var})")
|
|
83
|
+
print()
|
|
84
|
+
print(" These keys were auto-detected and will be used if you")
|
|
85
|
+
print(" paste the config above. No extra config needed.")
|
|
86
|
+
else:
|
|
87
|
+
print("── Optional: Add an LLM for smarter extraction ──")
|
|
88
|
+
print()
|
|
89
|
+
print(" Copy one of these into the \"env\" block above:")
|
|
90
|
+
print()
|
|
91
|
+
print(' "ANTHROPIC_API_KEY": "sk-ant-..."')
|
|
92
|
+
print(' or')
|
|
93
|
+
print(' "LOOM_LLM_PROVIDER": "deepseek",')
|
|
94
|
+
print(' "LOOM_DEEPSEEK_API_KEY": "sk-..."')
|
|
95
|
+
print(' or')
|
|
96
|
+
print(' "LOOM_LLM_PROVIDER": "gemini",')
|
|
97
|
+
print(' "GEMINI_API_KEY": "..."')
|
|
98
|
+
print()
|
|
99
|
+
print(" Without a key, Loom uses free keyword extraction.")
|
|
100
|
+
|
|
101
|
+
print()
|
|
102
|
+
print("── Next steps ──")
|
|
103
|
+
print()
|
|
104
|
+
print(f" 1. Paste the JSON above into {config_path}")
|
|
105
|
+
print(" 2. Restart Claude Desktop")
|
|
106
|
+
print(" 3. Run: loom doctor")
|
|
107
|
+
print()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cmd_doctor(args=None):
|
|
111
|
+
"""Check everything is working."""
|
|
112
|
+
print("=" * 60)
|
|
113
|
+
print(" Loom Doctor — System Check")
|
|
114
|
+
print("=" * 60)
|
|
115
|
+
print()
|
|
116
|
+
|
|
117
|
+
checks = []
|
|
118
|
+
|
|
119
|
+
# 1. Python version
|
|
120
|
+
py_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
121
|
+
py_ok = sys.version_info >= (3, 11)
|
|
122
|
+
checks.append(("Python 3.11+", py_ok, f"Python {py_version}"))
|
|
123
|
+
|
|
124
|
+
# 2. Loom importable
|
|
125
|
+
try:
|
|
126
|
+
import loom
|
|
127
|
+
loom_ok = True
|
|
128
|
+
loom_msg = f"Loom v{loom.__version__}"
|
|
129
|
+
except ImportError:
|
|
130
|
+
loom_ok = False
|
|
131
|
+
loom_msg = "Loom not installed — run: pip install -e ."
|
|
132
|
+
checks.append(("Loom installed", loom_ok, loom_msg))
|
|
133
|
+
|
|
134
|
+
# 3. Project root exists and is writable
|
|
135
|
+
project_root = Path(os.environ.get("LOOM_PROJECT_ROOT", os.getcwd()))
|
|
136
|
+
root_exists = project_root.exists()
|
|
137
|
+
root_writable = os.access(project_root, os.W_OK) if root_exists else False
|
|
138
|
+
checks.append(("Project directory", root_exists, str(project_root)))
|
|
139
|
+
checks.append(("Directory writable", root_writable,
|
|
140
|
+
"writable" if root_writable else f"not writable: {project_root}"))
|
|
141
|
+
|
|
142
|
+
# 4. .loom/ directory
|
|
143
|
+
loom_dir = project_root / ".loom"
|
|
144
|
+
loom_dir_ok = loom_dir.exists()
|
|
145
|
+
checks.append((".loom/ exists", loom_dir_ok,
|
|
146
|
+
str(loom_dir) if loom_dir_ok else "will be created on first use"))
|
|
147
|
+
|
|
148
|
+
# 5. Rules and timeline
|
|
149
|
+
if loom_dir_ok:
|
|
150
|
+
rules_file = loom_dir / "rules.json"
|
|
151
|
+
if rules_file.exists():
|
|
152
|
+
try:
|
|
153
|
+
data = json.loads(rules_file.read_text())
|
|
154
|
+
rule_count = len(data.get("rules", []))
|
|
155
|
+
checks.append(("Rules stored", True, f"{rule_count} rules"))
|
|
156
|
+
except Exception:
|
|
157
|
+
checks.append(("Rules stored", False, "rules.json corrupted"))
|
|
158
|
+
else:
|
|
159
|
+
checks.append(("Rules stored", True, "no rules yet (fresh install)"))
|
|
160
|
+
|
|
161
|
+
timeline = loom_dir / "timeline.jsonl"
|
|
162
|
+
if timeline.exists():
|
|
163
|
+
entries = timeline.read_text().strip().splitlines()
|
|
164
|
+
checks.append(("Timeline", True, f"{len(entries)} entries"))
|
|
165
|
+
else:
|
|
166
|
+
checks.append(("Timeline", True, "no entries yet"))
|
|
167
|
+
|
|
168
|
+
# 6. Domain configs
|
|
169
|
+
domains_dir = loom_dir / "domains" if loom_dir_ok else None
|
|
170
|
+
if domains_dir and domains_dir.exists():
|
|
171
|
+
configs = list(domains_dir.glob("*.yml"))
|
|
172
|
+
checks.append(("Domain configs", True, f"{len(configs)} domains"))
|
|
173
|
+
else:
|
|
174
|
+
checks.append(("Domain configs", True, "created on first use"))
|
|
175
|
+
|
|
176
|
+
# 7. LLM Provider
|
|
177
|
+
from loom.llm.factory import get_provider
|
|
178
|
+
provider = get_provider()
|
|
179
|
+
if provider:
|
|
180
|
+
sdk_ok = False
|
|
181
|
+
if provider.provider_name == "anthropic":
|
|
182
|
+
try:
|
|
183
|
+
import anthropic
|
|
184
|
+
sdk_ok = True
|
|
185
|
+
except ImportError:
|
|
186
|
+
pass
|
|
187
|
+
elif provider.provider_name == "deepseek":
|
|
188
|
+
try:
|
|
189
|
+
import openai
|
|
190
|
+
sdk_ok = True
|
|
191
|
+
except ImportError:
|
|
192
|
+
pass
|
|
193
|
+
elif provider.provider_name == "gemini":
|
|
194
|
+
try:
|
|
195
|
+
import google.generativeai
|
|
196
|
+
sdk_ok = True
|
|
197
|
+
except ImportError:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
sdk_msg = f"{provider.provider_name} (SDK: {'installed' if sdk_ok else 'MISSING — pip install loom-agent[{provider.provider_name}]'})"
|
|
201
|
+
checks.append(("LLM extraction", sdk_ok, sdk_msg))
|
|
202
|
+
else:
|
|
203
|
+
checks.append(("LLM extraction", True, "keyword only (free, no API key)"))
|
|
204
|
+
|
|
205
|
+
# 8. MCP protocol check
|
|
206
|
+
try:
|
|
207
|
+
from mcp.server.fastmcp import FastMCP
|
|
208
|
+
mcp_ok = True
|
|
209
|
+
mcp_msg = "FastMCP available"
|
|
210
|
+
except ImportError:
|
|
211
|
+
mcp_ok = False
|
|
212
|
+
mcp_msg = "mcp package not installed"
|
|
213
|
+
checks.append(("MCP protocol", mcp_ok, mcp_msg))
|
|
214
|
+
|
|
215
|
+
# Print results
|
|
216
|
+
all_ok = True
|
|
217
|
+
for name, ok, detail in checks:
|
|
218
|
+
status = "PASS" if ok else "FAIL"
|
|
219
|
+
if not ok:
|
|
220
|
+
all_ok = False
|
|
221
|
+
print(f" [{status}] {name}")
|
|
222
|
+
if detail:
|
|
223
|
+
print(f" {detail}")
|
|
224
|
+
|
|
225
|
+
print()
|
|
226
|
+
if all_ok:
|
|
227
|
+
print(" All checks passed. Loom is ready.")
|
|
228
|
+
else:
|
|
229
|
+
print(" Some checks failed. Fix the FAIL items above.")
|
|
230
|
+
print()
|
|
231
|
+
print(" Quick fixes:")
|
|
232
|
+
print(" pip install -e . # install Loom")
|
|
233
|
+
print(" pip install openai # for DeepSeek")
|
|
234
|
+
print(" pip install anthropic # for Anthropic")
|
|
235
|
+
print(" pip install google-generativeai # for Gemini")
|
|
236
|
+
print(" export LOOM_PROJECT_ROOT=/path/to/your/project")
|
|
237
|
+
|
|
238
|
+
print()
|
|
239
|
+
return 0 if all_ok else 1
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _claude_config_path() -> Path | None:
|
|
243
|
+
"""Return the OS-specific Claude Desktop config path, or None."""
|
|
244
|
+
home = Path.home()
|
|
245
|
+
if sys.platform == "darwin":
|
|
246
|
+
return home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
|
247
|
+
elif sys.platform == "win32":
|
|
248
|
+
return Path(os.environ.get("APPDATA", "")) / "Claude" / "claude_desktop_config.json"
|
|
249
|
+
else:
|
|
250
|
+
return home / ".config" / "Claude" / "claude_desktop_config.json"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def cmd_preflight(config_path: str | None = None):
|
|
254
|
+
"""Validate that Loom will work when Claude Desktop launches it.
|
|
255
|
+
|
|
256
|
+
Parses the Claude Desktop config, checks the Python path, verifies
|
|
257
|
+
Loom is importable, and validates the MCP transport chain.
|
|
258
|
+
"""
|
|
259
|
+
import subprocess
|
|
260
|
+
|
|
261
|
+
print("=" * 60)
|
|
262
|
+
print(" Loom Preflight — MCP Chain Validation")
|
|
263
|
+
print("=" * 60)
|
|
264
|
+
print()
|
|
265
|
+
|
|
266
|
+
# Resolve config path
|
|
267
|
+
if config_path:
|
|
268
|
+
cfg = Path(config_path).expanduser()
|
|
269
|
+
else:
|
|
270
|
+
cfg = _claude_config_path()
|
|
271
|
+
|
|
272
|
+
print(f" Config: {cfg}")
|
|
273
|
+
print()
|
|
274
|
+
|
|
275
|
+
checks = []
|
|
276
|
+
all_ok = True
|
|
277
|
+
|
|
278
|
+
# 1. Config file exists and is valid JSON
|
|
279
|
+
if not cfg.exists():
|
|
280
|
+
print(f" [FAIL] Config file not found: {cfg}")
|
|
281
|
+
print(f" Run 'loom setup' first, or use --config-path to specify")
|
|
282
|
+
print()
|
|
283
|
+
return 1
|
|
284
|
+
else:
|
|
285
|
+
try:
|
|
286
|
+
config_data = json.loads(cfg.read_text())
|
|
287
|
+
checks.append(("Config file", True, "found and valid JSON"))
|
|
288
|
+
except json.JSONDecodeError as e:
|
|
289
|
+
print(f" [FAIL] Config file is not valid JSON: {e}")
|
|
290
|
+
print()
|
|
291
|
+
return 1
|
|
292
|
+
|
|
293
|
+
# 2. Find Loom in the mcpServers block
|
|
294
|
+
mcp_servers = config_data.get("mcpServers", {})
|
|
295
|
+
loom_config = mcp_servers.get("loom")
|
|
296
|
+
if not loom_config:
|
|
297
|
+
print(f" [FAIL] No 'loom' entry found in mcpServers")
|
|
298
|
+
print(f" Run 'loom setup' and paste its output into the config.")
|
|
299
|
+
print()
|
|
300
|
+
return 1
|
|
301
|
+
|
|
302
|
+
command = loom_config.get("command", "")
|
|
303
|
+
args_list = loom_config.get("args", [])
|
|
304
|
+
env_vars = loom_config.get("env", {})
|
|
305
|
+
|
|
306
|
+
# 3. Python executable exists and is executable
|
|
307
|
+
python_exe = command
|
|
308
|
+
if not python_exe:
|
|
309
|
+
python_exe = shutil.which("python3") or shutil.which("python") or ""
|
|
310
|
+
if not python_exe:
|
|
311
|
+
print(f" [FAIL] No Python command found in config")
|
|
312
|
+
print(f" Set 'command' to your Python path (e.g., which python3)")
|
|
313
|
+
print()
|
|
314
|
+
return 1
|
|
315
|
+
|
|
316
|
+
exe_path = Path(python_exe)
|
|
317
|
+
if not exe_path.is_file() and not shutil.which(python_exe):
|
|
318
|
+
print(f" [FAIL] Python not found: {python_exe}")
|
|
319
|
+
print(f" Full path or install Python 3.10+ and retry.")
|
|
320
|
+
print()
|
|
321
|
+
return 1
|
|
322
|
+
checks.append(("Python path", True, str(python_exe)))
|
|
323
|
+
|
|
324
|
+
# 4. Loom is importable
|
|
325
|
+
try:
|
|
326
|
+
result = subprocess.run(
|
|
327
|
+
[python_exe, "-c", "import loom"],
|
|
328
|
+
capture_output=True, text=True, timeout=10,
|
|
329
|
+
)
|
|
330
|
+
if result.returncode == 0:
|
|
331
|
+
checks.append(("Loom import", True, "loom package found"))
|
|
332
|
+
else:
|
|
333
|
+
print(f" [FAIL] Loom package not importable from {python_exe}")
|
|
334
|
+
print(f" {result.stderr.strip()}")
|
|
335
|
+
print(f" Run: {python_exe} -m pip install loom-agent")
|
|
336
|
+
print()
|
|
337
|
+
return 1
|
|
338
|
+
except subprocess.TimeoutExpired:
|
|
339
|
+
print(f" [FAIL] Python import check timed out after 10s")
|
|
340
|
+
print()
|
|
341
|
+
return 1
|
|
342
|
+
except Exception as e:
|
|
343
|
+
print(f" [FAIL] Cannot run Python: {e}")
|
|
344
|
+
print()
|
|
345
|
+
return 1
|
|
346
|
+
|
|
347
|
+
# 5. Storage path is writable (if configured)
|
|
348
|
+
project_root = env_vars.get("LOOM_PROJECT_ROOT", "")
|
|
349
|
+
if project_root:
|
|
350
|
+
pr = Path(project_root).expanduser()
|
|
351
|
+
if pr.exists():
|
|
352
|
+
if os.access(pr, os.W_OK):
|
|
353
|
+
checks.append(("Storage path", True, f"{pr} (writable)"))
|
|
354
|
+
else:
|
|
355
|
+
checks.append(("Storage path", False, f"{pr} (NOT writable — check permissions)"))
|
|
356
|
+
all_ok = False
|
|
357
|
+
else:
|
|
358
|
+
try:
|
|
359
|
+
pr.mkdir(parents=True, exist_ok=True)
|
|
360
|
+
checks.append(("Storage path", True, f"{pr} (created)"))
|
|
361
|
+
except Exception as e:
|
|
362
|
+
checks.append(("Storage path", False, f"{pr} (cannot create: {e})"))
|
|
363
|
+
all_ok = False
|
|
364
|
+
else:
|
|
365
|
+
checks.append(("Storage path", True, "not set (defaults to $PWD at runtime)"))
|
|
366
|
+
|
|
367
|
+
# 6. MCP module loads
|
|
368
|
+
try:
|
|
369
|
+
result = subprocess.run(
|
|
370
|
+
[python_exe, "-c", "from loom.mcp.server import LoomMCPServer"],
|
|
371
|
+
capture_output=True, text=True, timeout=10,
|
|
372
|
+
)
|
|
373
|
+
if result.returncode == 0:
|
|
374
|
+
checks.append(("MCP module", True, "loom.mcp.server loads"))
|
|
375
|
+
else:
|
|
376
|
+
print(f" [FAIL] loom.mcp.server failed to load")
|
|
377
|
+
print(f" {result.stderr.strip()}")
|
|
378
|
+
print()
|
|
379
|
+
return 1
|
|
380
|
+
except subprocess.TimeoutExpired:
|
|
381
|
+
print(f" [FAIL] MCP module load check timed out after 10s")
|
|
382
|
+
print()
|
|
383
|
+
return 1
|
|
384
|
+
|
|
385
|
+
# Print results
|
|
386
|
+
print()
|
|
387
|
+
for name, ok, detail in checks:
|
|
388
|
+
status = "PASS" if ok else "FAIL"
|
|
389
|
+
if not ok:
|
|
390
|
+
all_ok = False
|
|
391
|
+
print(f" [{status}] {name}")
|
|
392
|
+
if detail:
|
|
393
|
+
print(f" {detail}")
|
|
394
|
+
|
|
395
|
+
print()
|
|
396
|
+
if all_ok:
|
|
397
|
+
print(" All preflight checks passed. Ready to restart Claude Desktop.")
|
|
398
|
+
else:
|
|
399
|
+
print(" Some checks failed. Fix the FAIL items above.")
|
|
400
|
+
print()
|
|
401
|
+
return 0 if all_ok else 1
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def cmd_cloud_setup(args=None):
|
|
405
|
+
"""Create a Supabase-backed shared Loom database and print config."""
|
|
406
|
+
import urllib.request
|
|
407
|
+
import urllib.error
|
|
408
|
+
|
|
409
|
+
print("=" * 60)
|
|
410
|
+
print(" Loom Cloud Setup — Shared Team Memory")
|
|
411
|
+
print("=" * 60)
|
|
412
|
+
print()
|
|
413
|
+
print(" This creates a shared database so your entire team")
|
|
414
|
+
print(" shares the same conventions in real-time.")
|
|
415
|
+
print()
|
|
416
|
+
|
|
417
|
+
# Get Supabase credentials
|
|
418
|
+
supabase_url = input(" Supabase URL (e.g. https://xyz.supabase.co): ").strip()
|
|
419
|
+
if not supabase_url:
|
|
420
|
+
print(" URL is required.")
|
|
421
|
+
return
|
|
422
|
+
|
|
423
|
+
supabase_key = input(" Supabase service_role key (sbp_...): ").strip()
|
|
424
|
+
if not supabase_key:
|
|
425
|
+
print(" Key is required.")
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
project_name = input(" Project name [default: loom-shared]: ").strip()
|
|
429
|
+
if not project_name:
|
|
430
|
+
project_name = "loom-shared"
|
|
431
|
+
|
|
432
|
+
print()
|
|
433
|
+
print(" Creating database...")
|
|
434
|
+
|
|
435
|
+
# Build the Postgres connection URL from Supabase params
|
|
436
|
+
# Supabase URL: https://[ref].supabase.co
|
|
437
|
+
# DB URL: postgresql://postgres:[key]@db.[ref].supabase.co:5432/postgres
|
|
438
|
+
try:
|
|
439
|
+
ref = supabase_url.replace("https://", "").replace(".supabase.co", "").strip("/")
|
|
440
|
+
db_url = f"postgresql://postgres:{supabase_key}@db.{ref}.supabase.co:5432/postgres"
|
|
441
|
+
except Exception:
|
|
442
|
+
print(" Invalid Supabase URL format. Expected: https://[ref].supabase.co")
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
# Run migrations
|
|
446
|
+
try:
|
|
447
|
+
from loom.storage.postgres_store import PostgresStore
|
|
448
|
+
from loom.config import StorageConfig
|
|
449
|
+
|
|
450
|
+
config = StorageConfig(
|
|
451
|
+
backend="postgres",
|
|
452
|
+
database_url=db_url,
|
|
453
|
+
)
|
|
454
|
+
store = PostgresStore(config)
|
|
455
|
+
store.initialize()
|
|
456
|
+
|
|
457
|
+
if store.health_check():
|
|
458
|
+
print(" Database: connected")
|
|
459
|
+
else:
|
|
460
|
+
print(" Database: connection failed — check your URL and key")
|
|
461
|
+
return
|
|
462
|
+
except ImportError:
|
|
463
|
+
print(" psycopg2 not installed. Run: pip install loom-agent[cloud]")
|
|
464
|
+
return
|
|
465
|
+
except Exception as e:
|
|
466
|
+
print(f" Error: {e}")
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
# Generate API key
|
|
470
|
+
import secrets
|
|
471
|
+
import hashlib
|
|
472
|
+
api_key = "loom_sk_" + secrets.token_hex(24)
|
|
473
|
+
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
|
|
474
|
+
|
|
475
|
+
try:
|
|
476
|
+
with store._conn() as conn:
|
|
477
|
+
with conn.cursor() as cur:
|
|
478
|
+
cur.execute(
|
|
479
|
+
"INSERT INTO api_keys (key_hash, key_prefix, project_id, role) "
|
|
480
|
+
"VALUES (%s, %s, %s, %s)",
|
|
481
|
+
(key_hash, api_key[:10] + "...", project_name, "admin"),
|
|
482
|
+
)
|
|
483
|
+
conn.commit()
|
|
484
|
+
except Exception:
|
|
485
|
+
pass # key storage is best-effort
|
|
486
|
+
|
|
487
|
+
# Generate config
|
|
488
|
+
python_path = sys.executable
|
|
489
|
+
config = {
|
|
490
|
+
"mcpServers": {
|
|
491
|
+
"loom": {
|
|
492
|
+
"command": python_path,
|
|
493
|
+
"args": ["-m", "loom.mcp"],
|
|
494
|
+
"env": {
|
|
495
|
+
"LOOM_STORAGE_BACKEND": "postgres",
|
|
496
|
+
"LOOM_DATABASE_URL": db_url,
|
|
497
|
+
},
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
print()
|
|
503
|
+
print("=" * 60)
|
|
504
|
+
print(" Paste this into your Claude Desktop config:")
|
|
505
|
+
print("=" * 60)
|
|
506
|
+
print()
|
|
507
|
+
print(json.dumps(config, indent=2))
|
|
508
|
+
print()
|
|
509
|
+
print(" Share this config with your team.")
|
|
510
|
+
print(" Everyone connects to the same memory.")
|
|
511
|
+
print()
|
|
512
|
+
print(" ⚠️ SECURITY: This config contains database credentials.")
|
|
513
|
+
print(" Restrict file permissions: chmod 600 ~/Library/Application\\\\")
|
|
514
|
+
print(" Support/Claude/claude_desktop_config.json")
|
|
515
|
+
print(" Do not commit this file to git — it contains your Supabase")
|
|
516
|
+
print(" service_role key which has full database access.")
|
|
517
|
+
print()
|
|
518
|
+
print(" API key (for SaaS later): " + api_key)
|
|
519
|
+
print()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def main():
|
|
523
|
+
if len(sys.argv) < 2:
|
|
524
|
+
print("Usage: loom <command>")
|
|
525
|
+
print()
|
|
526
|
+
print("Commands:")
|
|
527
|
+
print(" setup Generate local Claude Desktop config")
|
|
528
|
+
print(" init Same as setup — initialize Loom in this project")
|
|
529
|
+
print(" cloud setup Create a shared Supabase database for your team")
|
|
530
|
+
print(" doctor Check everything is working")
|
|
531
|
+
print(" doctor --preflight Validate MCP config before restart")
|
|
532
|
+
print()
|
|
533
|
+
print("Quick start (local):")
|
|
534
|
+
print(" 1. loom setup — paste into Claude config")
|
|
535
|
+
print(" 2. restart Claude Desktop")
|
|
536
|
+
print(" 3. loom doctor — verify everything is green")
|
|
537
|
+
print()
|
|
538
|
+
print("Quick start (team):")
|
|
539
|
+
print(" 1. loom cloud setup — paste Supabase URL + key")
|
|
540
|
+
print(" 2. share the config with your team")
|
|
541
|
+
sys.exit(0)
|
|
542
|
+
|
|
543
|
+
cmd = sys.argv[1]
|
|
544
|
+
if cmd == "setup":
|
|
545
|
+
cmd_setup()
|
|
546
|
+
elif cmd == "cloud" and len(sys.argv) > 2 and sys.argv[2] == "setup":
|
|
547
|
+
cmd_cloud_setup()
|
|
548
|
+
elif cmd == "doctor":
|
|
549
|
+
if "--preflight" in sys.argv:
|
|
550
|
+
# Extract optional --config-path argument
|
|
551
|
+
cp_idx = None
|
|
552
|
+
try:
|
|
553
|
+
cp_idx = sys.argv.index("--config-path")
|
|
554
|
+
except ValueError:
|
|
555
|
+
pass
|
|
556
|
+
config_path = sys.argv[cp_idx + 1] if cp_idx and cp_idx + 1 < len(sys.argv) else None
|
|
557
|
+
sys.exit(cmd_preflight(config_path))
|
|
558
|
+
else:
|
|
559
|
+
sys.exit(cmd_doctor())
|
|
560
|
+
elif cmd == "init":
|
|
561
|
+
cmd_setup()
|
|
562
|
+
else:
|
|
563
|
+
print(f"Unknown command: {cmd}")
|
|
564
|
+
print("Run 'loom' without arguments to see available commands.")
|
|
565
|
+
sys.exit(1)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
if __name__ == "__main__":
|
|
569
|
+
main()
|