loop-memory 0.4.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 (84) hide show
  1. loop_memory/__init__.py +62 -0
  2. loop_memory/backends/__init__.py +13 -0
  3. loop_memory/backends/embedding.py +82 -0
  4. loop_memory/backends/sentence_embedder.py +30 -0
  5. loop_memory/backends/vector_store.py +139 -0
  6. loop_memory/cli/__init__.py +0 -0
  7. loop_memory/cli/_common.py +68 -0
  8. loop_memory/cli/commands/__init__.py +13 -0
  9. loop_memory/cli/commands/cognitive.py +205 -0
  10. loop_memory/cli/commands/diag.py +346 -0
  11. loop_memory/cli/commands/graph.py +21 -0
  12. loop_memory/cli/commands/hooks.py +212 -0
  13. loop_memory/cli/commands/read.py +362 -0
  14. loop_memory/cli/commands/serve.py +147 -0
  15. loop_memory/cli/commands/write.py +138 -0
  16. loop_memory/cli/main.py +115 -0
  17. loop_memory/engine/__init__.py +0 -0
  18. loop_memory/engine/loop.py +247 -0
  19. loop_memory/engine/reflect.py +89 -0
  20. loop_memory/examples/__init__.py +0 -0
  21. loop_memory/examples/demo.py +39 -0
  22. loop_memory/export/__init__.py +39 -0
  23. loop_memory/export/memory_md.py +629 -0
  24. loop_memory/graph/__init__.py +0 -0
  25. loop_memory/graph/build.py +259 -0
  26. loop_memory/graph/extract.py +197 -0
  27. loop_memory/ingest/__init__.py +0 -0
  28. loop_memory/ingest/loader.py +782 -0
  29. loop_memory/ingest/pipeline.py +458 -0
  30. loop_memory/jobs/__init__.py +0 -0
  31. loop_memory/jobs/cognitive.py +353 -0
  32. loop_memory/jobs/compact.py +371 -0
  33. loop_memory/jobs/consolidate.py +95 -0
  34. loop_memory/jobs/contradiction.py +281 -0
  35. loop_memory/jobs/evolution.py +2021 -0
  36. loop_memory/jobs/graph.py +395 -0
  37. loop_memory/jobs/llm_compact_pass.py +24 -0
  38. loop_memory/jobs/llm_consolidate.py +980 -0
  39. loop_memory/jobs/scheduler.py +495 -0
  40. loop_memory/llm/__init__.py +0 -0
  41. loop_memory/llm/base.py +80 -0
  42. loop_memory/llm/openai_adapter.py +31 -0
  43. loop_memory/llm/providers.py +517 -0
  44. loop_memory/mcp/__init__.py +804 -0
  45. loop_memory/memory/__init__.py +0 -0
  46. loop_memory/memory/types.py +199 -0
  47. loop_memory/privacy/__init__.py +22 -0
  48. loop_memory/privacy/private.py +46 -0
  49. loop_memory/privacy/redact.py +188 -0
  50. loop_memory/py.typed +0 -0
  51. loop_memory/sdk.py +875 -0
  52. loop_memory/sdk_extensions.py +384 -0
  53. loop_memory/security/__init__.py +20 -0
  54. loop_memory/security/secrets.py +464 -0
  55. loop_memory/serve/__init__.py +0 -0
  56. loop_memory/serve/app.py +506 -0
  57. loop_memory/serve/handlers.py +316 -0
  58. loop_memory/serve/routes/_shared.py +59 -0
  59. loop_memory/serve/routes/admin.py +970 -0
  60. loop_memory/serve/routes/cognitive.py +64 -0
  61. loop_memory/serve/routes/export.py +65 -0
  62. loop_memory/serve/routes/graph.py +101 -0
  63. loop_memory/serve/routes/insights.py +702 -0
  64. loop_memory/serve/routes/memories.py +435 -0
  65. loop_memory/serve/routes/sessions.py +75 -0
  66. loop_memory/serve/routes/system.py +493 -0
  67. loop_memory/serve/routes/wiki.py +812 -0
  68. loop_memory/serve/static/__init__.py +0 -0
  69. loop_memory/serve/static/index.html +15 -0
  70. loop_memory/serve/watcher.py +451 -0
  71. loop_memory/storage/__init__.py +5 -0
  72. loop_memory/storage/retrieval.py +365 -0
  73. loop_memory/storage/sqlite_store.py +3627 -0
  74. loop_memory/wiki/__init__.py +41 -0
  75. loop_memory/wiki/backfill.py +143 -0
  76. loop_memory/wiki/classifier.py +238 -0
  77. loop_memory/wiki/prompts.py +295 -0
  78. loop_memory/wiki/scope.py +227 -0
  79. loop_memory-0.4.0.dist-info/METADATA +627 -0
  80. loop_memory-0.4.0.dist-info/RECORD +84 -0
  81. loop_memory-0.4.0.dist-info/WHEEL +5 -0
  82. loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
  83. loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
  84. loop_memory-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,346 @@
1
+ """Diagnostic commands: ``loop-memory doctor`` and ``loop-memory status``.
2
+
3
+ Designed to be the first thing a new user runs after ``pip install``.
4
+ Prints a single screen with green/red dots for every subsystem so
5
+ the user can see at a glance what's wired and what's missing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from .._common import DEFAULT_DB
18
+
19
+
20
+ _GREEN = "\x1b[32m"
21
+ _RED = "\x1b[31m"
22
+ _YELLOW = "\x1b[33m"
23
+ _DIM = "\x1b[2m"
24
+ _RESET = "\x1b[0m"
25
+
26
+
27
+ def _dot(ok: bool | None, msg: str) -> str:
28
+ """Return a single coloured status line. ``None`` is a soft warning."""
29
+ if ok is True:
30
+ icon = f"{_GREEN}●{_RESET}"
31
+ elif ok is False:
32
+ icon = f"{_RED}●{_RESET}"
33
+ else:
34
+ icon = f"{_YELLOW}●{_RESET}"
35
+ return f" {icon} {msg}"
36
+
37
+
38
+ def _has_cmd(name: str) -> bool:
39
+ return shutil.which(name) is not None
40
+
41
+
42
+ def _detect_clients() -> dict[str, dict]:
43
+ """Discover local AI CLIs the user has installed."""
44
+ home = Path.home()
45
+ out: dict[str, dict] = {}
46
+ out["codex"] = {
47
+ "installed": (home / ".codex").exists(),
48
+ "config": str(home / ".codex" / "config.toml"),
49
+ "mcp_configured": False,
50
+ }
51
+ codex_cfg = home / ".codex" / "config.toml"
52
+ if codex_cfg.exists():
53
+ try:
54
+ text = codex_cfg.read_text(encoding="utf-8", errors="ignore")
55
+ out["codex"]["mcp_configured"] = "loop-memory" in text
56
+ except Exception:
57
+ pass
58
+
59
+ out["claude"] = {
60
+ "installed": (home / ".claude").exists(),
61
+ "config": str(home / ".claude" / "settings.json"),
62
+ "mcp_configured": False,
63
+ }
64
+ claude_mcp = home / ".claude" / "mcp.json"
65
+ if claude_mcp.exists():
66
+ try:
67
+ data = json.loads(claude_mcp.read_text(encoding="utf-8"))
68
+ out["claude"]["mcp_configured"] = "loop_memory" in (
69
+ data.get("mcpServers") or {}
70
+ )
71
+ except Exception:
72
+ pass
73
+
74
+ out["hermes"] = {
75
+ "installed": (home / ".hermes").exists(),
76
+ "config": str(home / ".hermes" / "mcp.json"),
77
+ "mcp_configured": False,
78
+ }
79
+ hermes_cfg = home / ".hermes" / "mcp.json"
80
+ if hermes_cfg.exists():
81
+ try:
82
+ data = json.loads(hermes_cfg.read_text(encoding="utf-8"))
83
+ out["hermes"]["mcp_configured"] = "loop_memory" in (
84
+ data.get("mcpServers") or {}
85
+ )
86
+ except Exception:
87
+ pass
88
+
89
+ openclaw_dir = home / ".openclaw"
90
+ candidates = [
91
+ openclaw_dir / "agents" / "main" / "sessions",
92
+ openclaw_dir / "sessions",
93
+ openclaw_dir / "workspace" / "memory",
94
+ ]
95
+ existing = [c for c in candidates if c.exists()]
96
+ out["openclaw"] = {
97
+ "installed": openclaw_dir.exists(),
98
+ "watch_paths": [str(c) for c in existing],
99
+ "watcher_running": False,
100
+ }
101
+ return out
102
+
103
+
104
+ def _detect_watcher() -> bool:
105
+ """Return True if any ``loop-memory hook`` watcher is running.
106
+
107
+ Probes both ``pgrep`` (foreground processes) and ``launchctl list``
108
+ (daemonised watchers installed by ``loop-memory openclaw-setup``)
109
+ so we don't miss a watcher that was started by launchd.
110
+ """
111
+ try:
112
+ out = subprocess.check_output(
113
+ ["pgrep", "-fl", "loop_memory.cli.main hook"],
114
+ text=True, timeout=2,
115
+ )
116
+ if "loop_memory.cli.main hook" in (out or ""):
117
+ return True
118
+ except Exception:
119
+ pass
120
+ try:
121
+ out = subprocess.check_output(
122
+ ["launchctl", "list"], text=True, timeout=2,
123
+ )
124
+ if "com.loopmemory.openclaw" in (out or ""):
125
+ for line in out.splitlines():
126
+ if "com.loopmemory.openclaw" in line:
127
+ parts = line.split()
128
+ if parts and parts[0].lstrip("-").isdigit():
129
+ return True
130
+ except Exception:
131
+ pass
132
+ return False
133
+
134
+
135
+ def run_doctor(_args) -> int:
136
+ """``loop-memory doctor`` — green/red diagnostic screen.
137
+
138
+ Tells the user what's installed, what's wired, what's broken, and
139
+ gives copy-pasteable fix commands for anything red.
140
+ """
141
+ print("loop-memory doctor\n")
142
+ # 1) Installation
143
+ cli_path = shutil.which("loop-memory")
144
+ print(_dot(cli_path is not None,
145
+ f"CLI on PATH: {'yes (' + cli_path + ')' if cli_path else 'no'}"))
146
+ # 2) Database
147
+ db_path = Path(DEFAULT_DB)
148
+ print(_dot(db_path.exists(),
149
+ f"database: {db_path}" + ("" if db_path.exists() else " (will be created on first run)")))
150
+ # 3) Server
151
+ server_running = False
152
+ try:
153
+ import urllib.request
154
+ with urllib.request.urlopen("http://127.0.0.1:7767/api/stats", timeout=1) as r:
155
+ server_running = r.status == 200
156
+ except Exception:
157
+ server_running = False
158
+ print(_dot(server_running,
159
+ "web UI server: http://127.0.0.1:7767" + (" (running)" if server_running else " (not running — start with `loop-memory serve &`)")))
160
+
161
+ # 4) Clients
162
+ print()
163
+ print("clients:")
164
+ clients = _detect_clients()
165
+ for name in ("codex", "claude", "hermes", "openclaw"):
166
+ info = clients[name]
167
+ if name == "openclaw":
168
+ print(_dot(info["installed"],
169
+ f" openclaw: {'installed' if info['installed'] else 'not installed'}"))
170
+ if info["installed"]:
171
+ paths = ", ".join(Path(p).name for p in info["watch_paths"]) or "no transcript paths found"
172
+ print(f" {paths}")
173
+ print(_dot(_detect_watcher(),
174
+ " watcher running" if _detect_watcher() else " watcher not running — start with `loop-memory openclaw-setup`"))
175
+ else:
176
+ cfg = info["config"]
177
+ installed = info["installed"]
178
+ cfg_str = f"@ {cfg}" if installed else ""
179
+ print(_dot(installed,
180
+ f" {name}: {'installed' if installed else 'not installed'} {cfg_str}"))
181
+ if installed:
182
+ print(_dot(info["mcp_configured"],
183
+ f" MCP server wired: {'yes' if info['mcp_configured'] else 'no — run `loop-memory install-hooks`'}"))
184
+
185
+ # 5) LLM provider
186
+ print()
187
+ print("LLM provider:")
188
+ try:
189
+ from ...storage.sqlite_store import MemoryStore
190
+ from ...llm.providers import default_config
191
+ from ...security import backend_display_name, has_secret
192
+ store = MemoryStore(DEFAULT_DB)
193
+ cfg = store.get_setting("llm_consolidator", default_config())
194
+ provider = cfg.get("provider") or "echo"
195
+ print(f" provider: {provider}")
196
+ print(f" model: {cfg.get('model', '—')}")
197
+ print(f" secret backend: {backend_display_name()}")
198
+ if provider != "echo":
199
+ from ...security import account_for
200
+ account = cfg.get("api_key_account") or account_for(provider)
201
+ has = has_secret(account)
202
+ print(_dot(has,
203
+ f" API key: {'configured' if has else 'MISSING — open the web UI → ⚙ Model → paste your key'}"))
204
+ except Exception as e:
205
+ print(_dot(False, f" could not read LLM config: {e}"))
206
+
207
+ # 6) Data summary
208
+ print()
209
+ print("data:")
210
+ try:
211
+ from ...storage.sqlite_store import MemoryStore
212
+ store = MemoryStore(DEFAULT_DB)
213
+ n_mem = store.count_memories()
214
+ n_sess = store.count_sessions()
215
+ n_wiki = store.count_wiki_pages()
216
+ n_ent = store.count_entities()
217
+ print(f" {n_mem} memories · {n_sess} sessions · {n_wiki} wiki pages · {n_ent} entities")
218
+ except Exception as e:
219
+ print(f" could not read counters: {e}")
220
+
221
+ print()
222
+ print(_DIM + "Tip: run `loop-memory status` for a one-screen summary, "
223
+ "or `loop-memory install-hooks` to auto-configure your CLIs." + _RESET)
224
+ return 0
225
+
226
+
227
+ def run_status(_args) -> int:
228
+ """``loop-memory status`` — concise one-screen summary."""
229
+ db_path = Path(DEFAULT_DB)
230
+ print("loop-memory v0.3.0")
231
+ print(f" db: {db_path}" + (f" ({db_path.stat().st_size//1024} KB)" if db_path.exists() else ""))
232
+ try:
233
+ import urllib.request
234
+ with urllib.request.urlopen("http://127.0.0.1:7767/api/stats", timeout=1) as r:
235
+ stats = json.loads(r.read().decode())
236
+ print(f" server: http://127.0.0.1:7767 ({stats['memories']} memories, "
237
+ f"{stats['sessions']} sessions, {stats['wiki_pages']} wiki)")
238
+ except Exception:
239
+ print(" server: not running (start with `loop-memory serve`)")
240
+
241
+ watcher = _detect_watcher()
242
+ print(f" watcher: {'running' if watcher else 'idle'}")
243
+ clients = _detect_clients()
244
+ installed = sum(1 for k in ("codex", "claude", "hermes", "openclaw") if clients[k]["installed"])
245
+ wired = sum(1 for k in ("codex", "claude", "hermes") if clients[k]["installed"] and clients[k]["mcp_configured"])
246
+ print(f" clients: {installed} installed, {wired} MCP-wired")
247
+ return 0
248
+
249
+
250
+ def run_openclaw_setup(_args) -> int:
251
+ """``loop-memory openclaw-setup`` — install a launchd watcher for openclaw.
252
+
253
+ Writes ``~/Library/LaunchAgents/com.loopmemory.openclaw.plist`` so
254
+ that openclaw sessions + workspace/memory/*.md daily logs are
255
+ ingested automatically after every conversation. Idempotent.
256
+ """
257
+ home = Path.home()
258
+ openclaw_dir = home / ".openclaw"
259
+ if not openclaw_dir.exists():
260
+ print(_RED + "✘ ~/.openclaw not found — openclaw not installed." + _RESET)
261
+ print(" Install clawx first, then re-run this command.")
262
+ return 1
263
+
264
+ candidates = [
265
+ openclaw_dir / "agents" / "main" / "sessions",
266
+ openclaw_dir / "sessions",
267
+ openclaw_dir / "workspace" / "memory",
268
+ ]
269
+ existing = [str(c) for c in candidates if c.exists()]
270
+ if not existing:
271
+ print(_YELLOW + "⚠ ~/.openclaw exists but no transcript paths were found." + _RESET)
272
+ print(" Expected at least one of:")
273
+ for c in candidates:
274
+ print(f" {c}")
275
+ return 1
276
+
277
+ cli_path = shutil.which("loop-memory")
278
+ label = "com.loopmemory.openclaw"
279
+ plist_path = home / "Library" / "LaunchAgents" / f"{label}.plist"
280
+
281
+ # Build ProgramArguments. If the CLI is on PATH we use it directly,
282
+ # otherwise fall back to invoking the module via the current python
283
+ # (user pip installs put loop-memory in ~/Library/Python/<v>/bin
284
+ # which isn't on launchd's PATH).
285
+ # Build a --watch <path> pair for every existing directory so the
286
+ # watcher covers clawx sessions AND workspace/memory daily logs.
287
+ watch_pairs = ""
288
+ for p in existing:
289
+ watch_pairs += f" <string>--watch</string><string>{p}</string>\n"
290
+ if cli_path:
291
+ prog_args = (
292
+ f" <string>{cli_path}</string>\n"
293
+ " <string>hook</string>\n"
294
+ " <string>--source</string>\n"
295
+ " <string>openclaw</string>\n"
296
+ + watch_pairs
297
+ )
298
+ else:
299
+ py = sys.executable or "/usr/bin/env python3"
300
+ prog_args = (
301
+ f" <string>{py}</string>\n"
302
+ " <string>-m</string>\n"
303
+ " <string>loop_memory.cli.main</string>\n"
304
+ " <string>hook</string>\n"
305
+ " <string>--source</string>\n"
306
+ " <string>openclaw</string>\n"
307
+ + watch_pairs
308
+ )
309
+
310
+ body = (
311
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
312
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
313
+ '<plist version="1.0">\n'
314
+ '<dict>\n'
315
+ f' <key>Label</key><string>{label}</string>\n'
316
+ ' <key>ProgramArguments</key>\n'
317
+ ' <array>\n'
318
+ + prog_args +
319
+ ' </array>\n'
320
+ f' <key>WorkingDirectory</key><string>{home}</string>\n'
321
+ ' <key>RunAtLoad</key><true/>\n'
322
+ ' <key>KeepAlive</key><true/>\n'
323
+ ' <key>StandardOutPath</key><string>/tmp/loop_openclaw.log</string>\n'
324
+ ' <key>StandardErrorPath</key><string>/tmp/loop_openclaw.log</string>\n'
325
+ '</dict>\n'
326
+ '</plist>\n'
327
+ )
328
+ plist_path.parent.mkdir(parents=True, exist_ok=True)
329
+ plist_path.write_text(body, encoding="utf-8")
330
+ print(_GREEN + "✓ wrote" + _RESET + f" {plist_path}")
331
+ print(f" watch paths: {', '.join(existing)}")
332
+
333
+ # Reload via launchctl
334
+ try:
335
+ subprocess.run(["launchctl", "unload", str(plist_path)],
336
+ check=False, capture_output=True)
337
+ subprocess.run(["launchctl", "load", "-w", str(plist_path)],
338
+ check=True, capture_output=True)
339
+ print(_GREEN + "✓ loaded" + _RESET + " into launchd")
340
+ print()
341
+ print("Done. New openclaw sessions + workspace/memory/*.md daily logs will")
342
+ print("be ingested automatically. Use `loop-memory doctor` to verify.")
343
+ except subprocess.CalledProcessError as e:
344
+ print(_YELLOW + "⚠ could not load via launchctl:" + _RESET, e)
345
+ print(f" Load it manually: launchctl load -w {plist_path}")
346
+ return 0
@@ -0,0 +1,21 @@
1
+ """Knowledge graph commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from .._common import DEFAULT_DB
8
+
9
+
10
+ def run_graph(args) -> int:
11
+ """loop-memory graph [--rebuild] [--limit N] [--clear]"""
12
+ from ...graph.build import KnowledgeGraph
13
+ from ...storage.sqlite_store import MemoryStore
14
+ if "--rebuild" in args or "--clear" in args:
15
+ store = MemoryStore(DEFAULT_DB)
16
+ clear = "--clear" in args
17
+ report = KnowledgeGraph(store).rebuild(clear=clear)
18
+ print(json.dumps(report.__dict__, indent=2))
19
+ return 0
20
+ print(json.dumps(MemoryStore(DEFAULT_DB).graph_stats(), indent=2))
21
+ return 0
@@ -0,0 +1,212 @@
1
+ """install-hooks: auto-configure local AI CLIs to use loop-memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def _upsert_block(text: str, block: str, marker: str) -> str:
11
+ """Replace an existing block starting with ``marker`` with ``block``.
12
+
13
+ See original docstring in v0.3.0 — this function is called from
14
+ the Codex config.toml updater to swap in the loop-memory section
15
+ without touching the user's other settings.
16
+ """
17
+ lines = text.splitlines(keepends=True)
18
+ start = None
19
+ for i, ln in enumerate(lines):
20
+ if marker in ln:
21
+ start = i
22
+ break
23
+ if start is None:
24
+ if text and not text.endswith("\n"):
25
+ text += "\n"
26
+ return text + block
27
+
28
+ block_sections = []
29
+ cur = None
30
+ for ln in block.splitlines():
31
+ stripped = ln.strip()
32
+ if stripped.startswith("[") and stripped.endswith("]"):
33
+ cur = stripped
34
+ block_sections.append(cur)
35
+ end = start + 1
36
+ while end < len(lines):
37
+ ln = lines[end]
38
+ stripped = ln.strip()
39
+ if stripped.startswith("[") and stripped.endswith("]"):
40
+ if stripped in block_sections:
41
+ end += 1
42
+ continue
43
+ break
44
+ end += 1
45
+
46
+ return "".join(lines[:start]) + block + "".join(lines[end:])
47
+
48
+
49
+ def _install_codex(home: Path, actions: list) -> None:
50
+ codex_dir = home / ".codex"
51
+ codex_cfg = codex_dir / "config.toml"
52
+ # Codex's TOML schema: `hooks` is a struct (HooksToml) keyed by event
53
+ # name (PreToolUse / SessionStart / UserPromptSubmit / ...), NOT an
54
+ # array of tables. Each event value is a matcher group with a `hooks`
55
+ # list of {type, command}. Trying to write `[[hooks]]` makes codex
56
+ # fail at startup with "invalid type: sequence, expected struct
57
+ # HooksToml in `hooks`".
58
+ codex_block = (
59
+ "\n# [loop-memory] auto-installed by `loop-memory install-hooks`.\n"
60
+ "# Re-run the same command to refresh; the block is updated in place.\n"
61
+ "[mcp_servers.loop_memory]\n"
62
+ 'command = "loop-memory"\n'
63
+ 'args = ["mcp"]\n'
64
+ "\n"
65
+ "[hooks.session_start]\n"
66
+ '[[hooks.session_start.hooks]]\n'
67
+ 'type = "command"\n'
68
+ 'command = "loop-memory inject"\n'
69
+ "\n"
70
+ "[hooks.user_prompt_submit]\n"
71
+ '[[hooks.user_prompt_submit.hooks]]\n'
72
+ 'type = "command"\n'
73
+ 'command = "loop-memory inject"\n'
74
+ )
75
+ if not codex_dir.exists():
76
+ actions.append("codex → not installed (skipped)")
77
+ return
78
+ try:
79
+ existing = codex_cfg.read_text(encoding="utf-8") if codex_cfg.exists() else ""
80
+ new = _upsert_block(existing, codex_block, marker="# [loop-memory]")
81
+ codex_cfg.parent.mkdir(parents=True, exist_ok=True)
82
+ codex_cfg.write_text(new, encoding="utf-8")
83
+ # Validate that the resulting TOML still parses with the codex CLI's
84
+ # own loader, so a schema regression surfaces immediately rather than
85
+ # at codex startup time. Falls back to a stdlib `tomllib` check
86
+ # (3.11+); if unavailable we just trust the write.
87
+ parse_ok = False
88
+ try:
89
+ import tomllib # py3.11+
90
+ with open(codex_cfg, "rb") as f:
91
+ tomllib.load(f)
92
+ parse_ok = True
93
+ except ImportError:
94
+ try:
95
+ import tomli as tomllib # type: ignore
96
+ with open(codex_cfg, "rb") as f:
97
+ tomllib.load(f)
98
+ parse_ok = True
99
+ except Exception:
100
+ parse_ok = True # can't validate, trust the write
101
+ except Exception as ve:
102
+ actions.append(f"codex → WRITE OK but TOML INVALID: {ve}; rolling back")
103
+ try: codex_cfg.write_text(existing, encoding="utf-8")
104
+ except Exception: pass
105
+ return
106
+ actions.append(f"codex → {codex_cfg}" + ("" if parse_ok else " (unverified)"))
107
+ except Exception as e:
108
+ actions.append(f"codex → SKIP ({e})")
109
+
110
+
111
+ def _install_claude(home: Path, actions: list) -> None:
112
+ claude_dir = home / ".claude"
113
+ claude_mcp = claude_dir / "mcp.json"
114
+ claude_settings = claude_dir / "settings.json"
115
+ if not claude_dir.exists():
116
+ actions.append("claude → not installed (skipped)")
117
+ return
118
+ try:
119
+ existing = json.loads(claude_mcp.read_text(encoding="utf-8")) if claude_mcp.exists() else {}
120
+ except Exception:
121
+ existing = {}
122
+ existing.setdefault("mcpServers", {})
123
+ existing["mcpServers"]["loop_memory"] = {
124
+ "command": "loop-memory",
125
+ "args": ["mcp"],
126
+ "env": {},
127
+ "description": "Distilled long-term memory of the user.",
128
+ }
129
+ claude_mcp.parent.mkdir(parents=True, exist_ok=True)
130
+ claude_mcp.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8")
131
+ actions.append(f"claude (mcp) → {claude_mcp}")
132
+
133
+ try:
134
+ existing_s = json.loads(claude_settings.read_text(encoding="utf-8")) if claude_settings.exists() else {}
135
+ except Exception:
136
+ existing_s = {}
137
+ hooks = existing_s.setdefault("hooks", {})
138
+ sess = hooks.setdefault("SessionStart", [])
139
+ if not any(
140
+ isinstance(h, dict) and any(
141
+ isinstance(c, dict) and "loop-memory inject" in c.get("command", "")
142
+ for c in h.get("hooks", [])
143
+ )
144
+ for h in sess
145
+ ):
146
+ sess.append({
147
+ "hooks": [{"type": "command", "command": "loop-memory inject"}],
148
+ })
149
+ claude_settings.write_text(json.dumps(existing_s, ensure_ascii=False, indent=2), encoding="utf-8")
150
+ actions.append(f"claude (SessionStart hook) → {claude_settings}")
151
+
152
+
153
+ def _install_hermes(home: Path, actions: list) -> None:
154
+ hermes_dir = home / ".hermes"
155
+ hermes_cfg = hermes_dir / "mcp.json"
156
+ if not hermes_dir.exists():
157
+ actions.append("hermes → not installed (skipped)")
158
+ return
159
+ try:
160
+ existing = json.loads(hermes_cfg.read_text(encoding="utf-8")) if hermes_cfg.exists() else {}
161
+ except Exception:
162
+ existing = {}
163
+ existing.setdefault("mcpServers", {})
164
+ existing["mcpServers"]["loop_memory"] = {
165
+ "command": "loop-memory",
166
+ "args": ["mcp"],
167
+ }
168
+ hermes_cfg.parent.mkdir(parents=True, exist_ok=True)
169
+ hermes_cfg.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8")
170
+ actions.append(f"hermes → {hermes_cfg}")
171
+
172
+
173
+ def _openclaw_hint(home: Path, actions: list) -> None:
174
+ openclaw_dir = home / ".openclaw"
175
+ if not openclaw_dir.exists():
176
+ actions.append("openclaw → not installed (skipped)")
177
+ return
178
+ candidates = [
179
+ openclaw_dir / "agents" / "main" / "sessions",
180
+ openclaw_dir / "sessions",
181
+ openclaw_dir / "workspace" / "memory",
182
+ ]
183
+ existing = [str(c) for c in candidates if c.exists()]
184
+ watch_root = existing[0] if existing else str(openclaw_dir)
185
+ actions.append(
186
+ f"openclaw detected at {openclaw_dir}. To ingest new sessions "
187
+ f"automatically: `loop-memory hook --source openclaw --watch "
188
+ f"{watch_root} &` (also picks up workspace/memory/*.md daily logs)"
189
+ )
190
+
191
+
192
+ def run_install_hooks(_args) -> int:
193
+ """Auto-detect local AI CLI installs and write MCP + inject hook configs.
194
+
195
+ Supported: Codex CLI (~/.codex), Claude Code (~/.claude),
196
+ Hermes (~/.hermes). Each gets an MCP entry pointing at
197
+ ``loop-memory mcp`` and a SessionStart hook running
198
+ ``loop-memory inject``. Existing configs are *merged*, not
199
+ overwritten.
200
+ """
201
+ home = Path.home()
202
+ actions: list = []
203
+ _install_codex(home, actions)
204
+ _install_claude(home, actions)
205
+ _install_hermes(home, actions)
206
+ _openclaw_hint(home, actions)
207
+ print("[loop-memory] install-hooks results:")
208
+ for a in actions:
209
+ print(f" · {a}")
210
+ print()
211
+ print("Restart your CLI to pick up the new MCP server + hooks.")
212
+ return 0