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.
- loop_memory/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- 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
|