longmem 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.
- longmem/__init__.py +3 -0
- longmem/cli.py +544 -0
- longmem/config.py +89 -0
- longmem/embedder.py +83 -0
- longmem/server.py +724 -0
- longmem/store.py +896 -0
- longmem/templates/CLAUDE.md +28 -0
- longmem/templates/longmem.mdc +32 -0
- longmem-0.1.0.dist-info/METADATA +243 -0
- longmem-0.1.0.dist-info/RECORD +13 -0
- longmem-0.1.0.dist-info/WHEEL +4 -0
- longmem-0.1.0.dist-info/entry_points.txt +2 -0
- longmem-0.1.0.dist-info/licenses/LICENSE +21 -0
longmem/__init__.py
ADDED
longmem/cli.py
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""CLI entry point for longmem.
|
|
2
|
+
|
|
3
|
+
Sub-commands:
|
|
4
|
+
(none) — Start the MCP server over stdio (used by Cursor / Claude Code)
|
|
5
|
+
init — Interactive setup wizard
|
|
6
|
+
install — Copy rules files into the current project
|
|
7
|
+
status — Show config, Ollama reachability, and DB stats
|
|
8
|
+
export [file] — Export all entries to a JSON file
|
|
9
|
+
import <file> — Import entries from a JSON export
|
|
10
|
+
review — Manually save a solution when the AI forgot
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
longmem # start MCP server
|
|
14
|
+
longmem init # one-time machine setup
|
|
15
|
+
longmem install # per-project rules setup
|
|
16
|
+
longmem status # health check
|
|
17
|
+
longmem export # backup / share
|
|
18
|
+
longmem import <f> # restore / onboard
|
|
19
|
+
longmem review # manual save fallback
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import asyncio
|
|
26
|
+
import json
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
# ── constants ──────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
_SERVER_NAME = "longmem"
|
|
36
|
+
_MCP_ENTRY = {"command": "longmem", "args": []}
|
|
37
|
+
|
|
38
|
+
_DEFAULT_CONFIG_TOML = """\
|
|
39
|
+
# ~/.longmem/config.toml
|
|
40
|
+
# All values are optional — defaults work with a local Ollama instance.
|
|
41
|
+
|
|
42
|
+
# embedder = "ollama" # "ollama" (default, free, local) or "openai"
|
|
43
|
+
# ollama_url = "http://localhost:11434"
|
|
44
|
+
# ollama_model = "nomic-embed-text"
|
|
45
|
+
# openai_model = "text-embedding-3-small"
|
|
46
|
+
# openai_api_key = "sk-..." # or export OPENAI_API_KEY
|
|
47
|
+
# similarity_threshold = 0.85 # minimum score to surface a cached solution
|
|
48
|
+
# duplicate_threshold = 0.95 # minimum score to block a save as a near-duplicate
|
|
49
|
+
|
|
50
|
+
# ── storage ────────────────────────────────────────────────────────────────────
|
|
51
|
+
# Local path (default):
|
|
52
|
+
# db_path = "/custom/path/db" # default: ~/.longmem/db
|
|
53
|
+
|
|
54
|
+
# Remote / cloud storage (overrides db_path when set):
|
|
55
|
+
# db_uri = "s3://my-bucket/longmem" # S3 — uses AWS env vars for auth
|
|
56
|
+
# db_uri = "gs://my-bucket/longmem" # Google Cloud Storage
|
|
57
|
+
# db_uri = "az://my-container/longmem" # Azure Blob Storage
|
|
58
|
+
# db_uri = "db://my-org/my-db" # LanceDB Cloud
|
|
59
|
+
# lancedb_api_key = "ldb_..." # LanceDB Cloud only (or export LANCEDB_API_KEY)
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# ── helpers ────────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def _ok(msg: str) -> None:
|
|
65
|
+
print(f" [ok] {msg}")
|
|
66
|
+
|
|
67
|
+
def _warn(msg: str) -> None:
|
|
68
|
+
print(f" [!] {msg}")
|
|
69
|
+
|
|
70
|
+
def _info(msg: str) -> None:
|
|
71
|
+
print(f" {msg}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write_mcp_config(path: Path, server_name: str, entry: dict) -> None:
|
|
75
|
+
"""Merge our MCP server entry into an existing config JSON, or create it."""
|
|
76
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
existing: dict = {}
|
|
78
|
+
if path.exists():
|
|
79
|
+
try:
|
|
80
|
+
existing = json.loads(path.read_text(encoding="utf-8"))
|
|
81
|
+
except (json.JSONDecodeError, OSError):
|
|
82
|
+
pass
|
|
83
|
+
existing.setdefault("mcpServers", {})
|
|
84
|
+
existing["mcpServers"][server_name] = entry
|
|
85
|
+
path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _templates_dir() -> Path:
|
|
89
|
+
return Path(__file__).parent / "templates"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _dir_size_mb(path: Path) -> float:
|
|
93
|
+
if not path.exists():
|
|
94
|
+
return 0.0
|
|
95
|
+
total = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
|
96
|
+
return round(total / (1024 * 1024), 2)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── init ───────────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
def cmd_init() -> int:
|
|
102
|
+
"""Interactive one-time setup wizard."""
|
|
103
|
+
print()
|
|
104
|
+
print("longmem setup")
|
|
105
|
+
print("=" * 50)
|
|
106
|
+
print()
|
|
107
|
+
|
|
108
|
+
# 1. Check Ollama
|
|
109
|
+
ollama_ok = _init_check_ollama()
|
|
110
|
+
|
|
111
|
+
# 2. Pull embedding model if needed
|
|
112
|
+
if ollama_ok:
|
|
113
|
+
_init_ensure_model()
|
|
114
|
+
|
|
115
|
+
# 3. Choose IDE(s) to configure
|
|
116
|
+
print()
|
|
117
|
+
print("Which IDE(s) would you like to configure?")
|
|
118
|
+
print(" 1) Cursor")
|
|
119
|
+
print(" 2) Claude Code")
|
|
120
|
+
print(" 3) Both")
|
|
121
|
+
choice = input("Choice [1/2/3, default 1]: ").strip() or "1"
|
|
122
|
+
print()
|
|
123
|
+
|
|
124
|
+
cfg_cursor = choice in ("1", "3")
|
|
125
|
+
cfg_claude = choice in ("2", "3")
|
|
126
|
+
|
|
127
|
+
if cfg_cursor:
|
|
128
|
+
path = Path.home() / ".cursor" / "mcp.json"
|
|
129
|
+
_write_mcp_config(path, _SERVER_NAME, _MCP_ENTRY)
|
|
130
|
+
_ok(f"Cursor config → {path}")
|
|
131
|
+
|
|
132
|
+
if cfg_claude:
|
|
133
|
+
path = Path.home() / ".claude" / "mcp.json"
|
|
134
|
+
_write_mcp_config(path, _SERVER_NAME, _MCP_ENTRY)
|
|
135
|
+
_ok(f"Claude Code → {path}")
|
|
136
|
+
|
|
137
|
+
if not cfg_cursor and not cfg_claude:
|
|
138
|
+
_warn("Invalid choice — re-run init to configure.")
|
|
139
|
+
|
|
140
|
+
# 4. Default config.toml
|
|
141
|
+
config_file = Path.home() / ".longmem" / "config.toml"
|
|
142
|
+
if config_file.exists():
|
|
143
|
+
_info(f"Config already exists at {config_file} — not overwritten")
|
|
144
|
+
else:
|
|
145
|
+
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
config_file.write_text(_DEFAULT_CONFIG_TOML, encoding="utf-8")
|
|
147
|
+
_ok(f"Config written → {config_file}")
|
|
148
|
+
|
|
149
|
+
print()
|
|
150
|
+
print("Setup complete.")
|
|
151
|
+
print()
|
|
152
|
+
print("Next — run this in each project to activate the memory rules:")
|
|
153
|
+
print(" longmem install")
|
|
154
|
+
print()
|
|
155
|
+
print("Then restart Cursor / Claude Code to load the MCP server.")
|
|
156
|
+
print()
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _init_check_ollama() -> bool:
|
|
161
|
+
try:
|
|
162
|
+
import httpx # already a dependency
|
|
163
|
+
r = httpx.get("http://localhost:11434/api/tags", timeout=5)
|
|
164
|
+
r.raise_for_status()
|
|
165
|
+
_ok("Ollama is running at http://localhost:11434")
|
|
166
|
+
return True
|
|
167
|
+
except Exception:
|
|
168
|
+
_warn("Ollama not found at http://localhost:11434")
|
|
169
|
+
_info("Install from https://ollama.com, then run: ollama pull nomic-embed-text")
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _init_ensure_model() -> None:
|
|
174
|
+
try:
|
|
175
|
+
import httpx
|
|
176
|
+
r = httpx.get("http://localhost:11434/api/tags", timeout=5)
|
|
177
|
+
models = [m.get("name", "") for m in r.json().get("models", [])]
|
|
178
|
+
if any("nomic-embed-text" in m for m in models):
|
|
179
|
+
_ok("nomic-embed-text is already installed")
|
|
180
|
+
return
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
print()
|
|
185
|
+
_info("Pulling nomic-embed-text (required for local embeddings, ~270 MB) ...")
|
|
186
|
+
if shutil.which("ollama"):
|
|
187
|
+
result = subprocess.run(["ollama", "pull", "nomic-embed-text"])
|
|
188
|
+
if result.returncode == 0:
|
|
189
|
+
_ok("nomic-embed-text pulled successfully")
|
|
190
|
+
else:
|
|
191
|
+
_warn("Pull failed — run manually: ollama pull nomic-embed-text")
|
|
192
|
+
else:
|
|
193
|
+
_warn("'ollama' not found in PATH — run manually: ollama pull nomic-embed-text")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── install ────────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
def cmd_install() -> int:
|
|
199
|
+
"""Copy rules files into the current project directory."""
|
|
200
|
+
cwd = Path.cwd()
|
|
201
|
+
templates = _templates_dir()
|
|
202
|
+
print()
|
|
203
|
+
|
|
204
|
+
# Cursor rules
|
|
205
|
+
dest_mdc = cwd / ".cursor" / "rules" / "longmem.mdc"
|
|
206
|
+
dest_mdc.parent.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
shutil.copy2(templates / "longmem.mdc", dest_mdc)
|
|
208
|
+
_ok(f"Cursor rules → {dest_mdc}")
|
|
209
|
+
|
|
210
|
+
# Claude Code rules
|
|
211
|
+
dest_claude = cwd / "CLAUDE.md"
|
|
212
|
+
claude_template = (templates / "CLAUDE.md").read_text(encoding="utf-8")
|
|
213
|
+
if dest_claude.exists():
|
|
214
|
+
print()
|
|
215
|
+
_warn(f"CLAUDE.md already exists at {dest_claude}")
|
|
216
|
+
_info("Append the following block to it for Claude Code memory support:")
|
|
217
|
+
print()
|
|
218
|
+
for line in claude_template.splitlines():
|
|
219
|
+
print(f" {line}")
|
|
220
|
+
print()
|
|
221
|
+
else:
|
|
222
|
+
dest_claude.write_text(claude_template, encoding="utf-8")
|
|
223
|
+
_ok(f"Claude Code → {dest_claude}")
|
|
224
|
+
|
|
225
|
+
print()
|
|
226
|
+
print("Done. Restart Cursor / Claude Code to activate.")
|
|
227
|
+
print()
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ── status ─────────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
def cmd_status() -> int:
|
|
234
|
+
"""Show config, Ollama reachability, and DB statistics."""
|
|
235
|
+
from .config import CONFIG_FILE, load_config
|
|
236
|
+
|
|
237
|
+
print()
|
|
238
|
+
print("longmem status")
|
|
239
|
+
print("=" * 50)
|
|
240
|
+
print()
|
|
241
|
+
|
|
242
|
+
# Config
|
|
243
|
+
if CONFIG_FILE.exists():
|
|
244
|
+
_ok(f"Config {CONFIG_FILE}")
|
|
245
|
+
else:
|
|
246
|
+
_info(f"No config file at {CONFIG_FILE} — using defaults")
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
cfg = load_config()
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
_warn(f"Config error: {exc}")
|
|
252
|
+
return 1
|
|
253
|
+
|
|
254
|
+
print(f" Embedder : {cfg.embedder}")
|
|
255
|
+
if cfg.embedder == "ollama":
|
|
256
|
+
print(f" Model : {cfg.ollama_model}")
|
|
257
|
+
print(f" Ollama : {cfg.ollama_url}")
|
|
258
|
+
else:
|
|
259
|
+
print(f" Model : {cfg.openai_model}")
|
|
260
|
+
print(f" Threshold: {cfg.similarity_threshold}")
|
|
261
|
+
if cfg.is_remote:
|
|
262
|
+
print(f" DB URI : {cfg.db_uri} (remote)")
|
|
263
|
+
else:
|
|
264
|
+
print(f" DB path : {cfg.db_path}")
|
|
265
|
+
print()
|
|
266
|
+
|
|
267
|
+
# Ollama
|
|
268
|
+
_check_ollama_status(cfg)
|
|
269
|
+
print()
|
|
270
|
+
|
|
271
|
+
# DB stats
|
|
272
|
+
asyncio.run(_status_db(cfg))
|
|
273
|
+
print()
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _check_ollama_status(cfg) -> None:
|
|
278
|
+
if cfg.embedder != "ollama":
|
|
279
|
+
return
|
|
280
|
+
try:
|
|
281
|
+
import httpx
|
|
282
|
+
r = httpx.get(f"{cfg.ollama_url}/api/tags", timeout=5)
|
|
283
|
+
r.raise_for_status()
|
|
284
|
+
models = [m.get("name", "") for m in r.json().get("models", [])]
|
|
285
|
+
has_model = any(cfg.ollama_model in m for m in models)
|
|
286
|
+
_ok(f"Ollama running — {'model found' if has_model else 'WARNING: ' + cfg.ollama_model + ' not installed'}")
|
|
287
|
+
except Exception:
|
|
288
|
+
_warn(f"Ollama not reachable at {cfg.ollama_url}")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def _status_db(cfg) -> None:
|
|
292
|
+
from .store import SolutionStore
|
|
293
|
+
try:
|
|
294
|
+
store = await SolutionStore.open(cfg)
|
|
295
|
+
stats_data = await store.get_stats()
|
|
296
|
+
|
|
297
|
+
print(f" Total entries : {stats_data['total']}")
|
|
298
|
+
if not cfg.is_remote:
|
|
299
|
+
size_mb = _dir_size_mb(cfg.db_path)
|
|
300
|
+
print(f" DB size : {size_mb} MB")
|
|
301
|
+
if stats_data["oldest_entry"]:
|
|
302
|
+
print(f" Oldest entry : {stats_data['oldest_entry'][:10]}")
|
|
303
|
+
if stats_data["newest_entry"]:
|
|
304
|
+
print(f" Newest entry : {stats_data['newest_entry'][:10]}")
|
|
305
|
+
if stats_data["total"] > 0:
|
|
306
|
+
print()
|
|
307
|
+
print(" By category:")
|
|
308
|
+
for cat, count in list(stats_data["by_category"].items())[:10]:
|
|
309
|
+
print(f" {cat:<22} {count}")
|
|
310
|
+
if stats_data["total"] >= 256:
|
|
311
|
+
print()
|
|
312
|
+
_info("256+ entries — consider running: longmem rebuild-index")
|
|
313
|
+
except Exception as exc:
|
|
314
|
+
_warn(f"Could not read DB: {exc}")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ── export ─────────────────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
def cmd_export(output_path: str | None) -> int:
|
|
320
|
+
"""Export all entries to a JSON file."""
|
|
321
|
+
from .config import load_config
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
cfg = load_config()
|
|
325
|
+
except Exception as exc:
|
|
326
|
+
print(f"Config error: {exc}")
|
|
327
|
+
return 1
|
|
328
|
+
|
|
329
|
+
entries = asyncio.run(_export_entries(cfg))
|
|
330
|
+
|
|
331
|
+
if output_path is None:
|
|
332
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
333
|
+
output_path = f"longmem_export_{ts}.json"
|
|
334
|
+
|
|
335
|
+
out = Path(output_path)
|
|
336
|
+
payload = {
|
|
337
|
+
"version": "1",
|
|
338
|
+
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
339
|
+
"entry_count": len(entries),
|
|
340
|
+
"entries": entries,
|
|
341
|
+
}
|
|
342
|
+
out.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
343
|
+
print(f"Exported {len(entries)} entries to {out}")
|
|
344
|
+
return 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def _export_entries(cfg) -> list[dict]:
|
|
348
|
+
from .store import SolutionStore
|
|
349
|
+
store = await SolutionStore.open(cfg)
|
|
350
|
+
return await store.export_all()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ── import ─────────────────────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
def cmd_import(input_path: str) -> int:
|
|
356
|
+
"""Import entries from a JSON export file."""
|
|
357
|
+
from .config import load_config
|
|
358
|
+
|
|
359
|
+
src = Path(input_path)
|
|
360
|
+
if not src.exists():
|
|
361
|
+
print(f"File not found: {src}")
|
|
362
|
+
return 1
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
payload = json.loads(src.read_text(encoding="utf-8"))
|
|
366
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
367
|
+
print(f"Could not read {src}: {exc}")
|
|
368
|
+
return 1
|
|
369
|
+
|
|
370
|
+
entries = payload.get("entries", [])
|
|
371
|
+
if not entries:
|
|
372
|
+
print("No entries found in export file.")
|
|
373
|
+
return 0
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
cfg = load_config()
|
|
377
|
+
except Exception as exc:
|
|
378
|
+
print(f"Config error: {exc}")
|
|
379
|
+
return 1
|
|
380
|
+
|
|
381
|
+
added, skipped = asyncio.run(_import_entries(cfg, entries))
|
|
382
|
+
print(f"Import complete: {added} added, {skipped} skipped (already existed or invalid vector).")
|
|
383
|
+
return 0
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
async def _import_entries(cfg, entries: list[dict]) -> tuple[int, int]:
|
|
387
|
+
from .store import SolutionStore
|
|
388
|
+
store = await SolutionStore.open(cfg)
|
|
389
|
+
return await store.import_entries(entries)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ── review ─────────────────────────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
def cmd_review() -> int:
|
|
395
|
+
"""Manually save a solution — use this when the AI forgot to call confirm_solution."""
|
|
396
|
+
from .store import CATEGORIES
|
|
397
|
+
|
|
398
|
+
print()
|
|
399
|
+
print("longmem review — save a solution manually")
|
|
400
|
+
print("=" * 50)
|
|
401
|
+
print("Use this when the AI forgot to save after solving a problem.")
|
|
402
|
+
print()
|
|
403
|
+
|
|
404
|
+
problem = input("Problem (what was broken or needed): ").strip()
|
|
405
|
+
if not problem:
|
|
406
|
+
_warn("Nothing entered — cancelled.")
|
|
407
|
+
return 0
|
|
408
|
+
|
|
409
|
+
# Category
|
|
410
|
+
print()
|
|
411
|
+
_info("Categories: " + " ".join(CATEGORIES))
|
|
412
|
+
category = input("Category [other]: ").strip() or "other"
|
|
413
|
+
if category not in CATEGORIES:
|
|
414
|
+
_warn(f"Unknown category {category!r} — using 'other'")
|
|
415
|
+
category = "other"
|
|
416
|
+
|
|
417
|
+
# Solution — multiline, terminated by END or Ctrl+D
|
|
418
|
+
print()
|
|
419
|
+
_info("Paste your solution. Type END on a new line when done (or Ctrl+D).")
|
|
420
|
+
lines: list[str] = []
|
|
421
|
+
try:
|
|
422
|
+
while True:
|
|
423
|
+
line = input()
|
|
424
|
+
if line.strip() == "END":
|
|
425
|
+
break
|
|
426
|
+
lines.append(line)
|
|
427
|
+
except EOFError:
|
|
428
|
+
pass
|
|
429
|
+
solution = "\n".join(lines).strip()
|
|
430
|
+
if not solution:
|
|
431
|
+
_warn("No solution entered — cancelled.")
|
|
432
|
+
return 0
|
|
433
|
+
|
|
434
|
+
# Optional metadata
|
|
435
|
+
print()
|
|
436
|
+
tags_raw = input("Tags (comma-separated, optional): ").strip()
|
|
437
|
+
tags = [t.strip() for t in tags_raw.split(",") if t.strip()]
|
|
438
|
+
language = input("Language (optional): ").strip()
|
|
439
|
+
project = input("Project/repo name (optional): ").strip()
|
|
440
|
+
|
|
441
|
+
from .config import load_config
|
|
442
|
+
try:
|
|
443
|
+
cfg = load_config()
|
|
444
|
+
except Exception as exc:
|
|
445
|
+
_warn(f"Config error: {exc}")
|
|
446
|
+
return 1
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
entry_id = asyncio.run(_review_save(cfg, problem, solution, category, project, tags, language))
|
|
450
|
+
print()
|
|
451
|
+
_ok(f"Saved (id={entry_id})")
|
|
452
|
+
print()
|
|
453
|
+
return 0
|
|
454
|
+
except Exception as exc:
|
|
455
|
+
_warn(f"Save failed: {exc}")
|
|
456
|
+
return 1
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
async def _review_save(
|
|
460
|
+
cfg,
|
|
461
|
+
problem: str,
|
|
462
|
+
solution: str,
|
|
463
|
+
category: str,
|
|
464
|
+
project: str,
|
|
465
|
+
tags: list[str],
|
|
466
|
+
language: str,
|
|
467
|
+
) -> str:
|
|
468
|
+
from .embedder import get_embedder
|
|
469
|
+
from .store import SolutionStore
|
|
470
|
+
embedder = get_embedder(cfg)
|
|
471
|
+
store = await SolutionStore.open(cfg)
|
|
472
|
+
vector = await embedder.embed(f"{category}: {problem}")
|
|
473
|
+
return await store.save(
|
|
474
|
+
problem=problem,
|
|
475
|
+
solution=solution,
|
|
476
|
+
vector=vector,
|
|
477
|
+
project=project,
|
|
478
|
+
category=category, # type: ignore[arg-type]
|
|
479
|
+
tags=tags,
|
|
480
|
+
language=language,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# ── main ───────────────────────────────────────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
_SUBCOMMANDS = {"init", "install", "status", "export", "import", "review"}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _get_version() -> str:
|
|
490
|
+
try:
|
|
491
|
+
from importlib.metadata import version
|
|
492
|
+
return version("longmem")
|
|
493
|
+
except Exception:
|
|
494
|
+
return "unknown"
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def main() -> None:
|
|
498
|
+
"""
|
|
499
|
+
No sub-command → start MCP server (called by the IDE).
|
|
500
|
+
Sub-command → run that CLI command.
|
|
501
|
+
"""
|
|
502
|
+
first = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
503
|
+
|
|
504
|
+
if first == "--version":
|
|
505
|
+
print(_get_version())
|
|
506
|
+
return
|
|
507
|
+
|
|
508
|
+
if first not in _SUBCOMMANDS:
|
|
509
|
+
# MCP server mode — start over stdio
|
|
510
|
+
from .server import main as server_main
|
|
511
|
+
server_main()
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
parser = argparse.ArgumentParser(
|
|
515
|
+
prog="longmem",
|
|
516
|
+
description="Persistent cross-project memory for Cursor and Claude Code.",
|
|
517
|
+
)
|
|
518
|
+
parser.add_argument("--version", action="version", version=_get_version())
|
|
519
|
+
sub = parser.add_subparsers(dest="cmd", metavar="<command>")
|
|
520
|
+
|
|
521
|
+
sub.add_parser("init", help="One-time machine setup wizard")
|
|
522
|
+
sub.add_parser("install", help="Copy rules into current project")
|
|
523
|
+
sub.add_parser("status", help="Show config, Ollama status, and DB stats")
|
|
524
|
+
sub.add_parser("review", help="Manually save a solution when the AI forgot")
|
|
525
|
+
|
|
526
|
+
exp = sub.add_parser("export", help="Export all entries to a JSON file")
|
|
527
|
+
exp.add_argument("output", nargs="?", default=None,
|
|
528
|
+
metavar="FILE",
|
|
529
|
+
help="Output file path (default: longmem_export_<timestamp>.json)")
|
|
530
|
+
|
|
531
|
+
imp = sub.add_parser("import", help="Import entries from a JSON export file")
|
|
532
|
+
imp.add_argument("input", metavar="FILE", help="JSON file produced by longmem export")
|
|
533
|
+
|
|
534
|
+
args = parser.parse_args()
|
|
535
|
+
|
|
536
|
+
dispatch = {
|
|
537
|
+
"init": lambda: cmd_init(),
|
|
538
|
+
"install": lambda: cmd_install(),
|
|
539
|
+
"status": lambda: cmd_status(),
|
|
540
|
+
"export": lambda: cmd_export(getattr(args, "output", None)),
|
|
541
|
+
"import": lambda: cmd_import(args.input),
|
|
542
|
+
"review": lambda: cmd_review(),
|
|
543
|
+
}
|
|
544
|
+
sys.exit(dispatch[args.cmd]())
|
longmem/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Configuration loading from ~/.longmem/config.toml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR = Path.home() / ".longmem"
|
|
11
|
+
DB_PATH = CONFIG_DIR / "db"
|
|
12
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
13
|
+
|
|
14
|
+
# Both Ollama nomic-embed-text and OpenAI text-embedding-3-small
|
|
15
|
+
# can produce 768-dim vectors — keep one fixed dimension for the DB.
|
|
16
|
+
VECTOR_DIM = 768
|
|
17
|
+
|
|
18
|
+
# URI prefixes that indicate a remote LanceDB connection.
|
|
19
|
+
# These are passed to lancedb.connect_async() as-is (no Path conversion, no mkdir).
|
|
20
|
+
_REMOTE_PREFIXES = ("db://", "s3://", "gs://", "az://")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Config:
|
|
25
|
+
db_path: Path = DB_PATH
|
|
26
|
+
# Remote URI — overrides db_path when set.
|
|
27
|
+
# Supported: LanceDB Cloud (db://org/db), S3 (s3://bucket/prefix),
|
|
28
|
+
# GCS (gs://bucket/prefix), Azure (az://container/prefix).
|
|
29
|
+
db_uri: str = ""
|
|
30
|
+
lancedb_api_key: str = "" # LanceDB Cloud only (db:// URIs)
|
|
31
|
+
# "ollama" (default, local, no API key) or "openai"
|
|
32
|
+
embedder: str = "ollama"
|
|
33
|
+
ollama_url: str = "http://localhost:11434"
|
|
34
|
+
ollama_model: str = "nomic-embed-text"
|
|
35
|
+
openai_api_key: str = ""
|
|
36
|
+
openai_model: str = "text-embedding-3-small"
|
|
37
|
+
vector_dim: int = VECTOR_DIM
|
|
38
|
+
similarity_threshold: float = 0.85 # minimum score to surface a cached solution
|
|
39
|
+
duplicate_threshold: float = 0.95 # minimum score to treat a new save as a duplicate
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_remote(self) -> bool:
|
|
43
|
+
"""True when db_uri points to a cloud/object-store backend."""
|
|
44
|
+
return bool(self.db_uri and self.db_uri.startswith(_REMOTE_PREFIXES))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_config() -> Config:
|
|
48
|
+
cfg = Config()
|
|
49
|
+
|
|
50
|
+
if CONFIG_FILE.exists():
|
|
51
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
52
|
+
data = tomllib.load(f)
|
|
53
|
+
|
|
54
|
+
if "db_path" in data:
|
|
55
|
+
cfg.db_path = Path(data["db_path"])
|
|
56
|
+
cfg.db_uri = data.get("db_uri", cfg.db_uri)
|
|
57
|
+
cfg.lancedb_api_key = data.get("lancedb_api_key", "")
|
|
58
|
+
cfg.embedder = data.get("embedder", cfg.embedder)
|
|
59
|
+
cfg.ollama_url = data.get("ollama_url", cfg.ollama_url)
|
|
60
|
+
cfg.ollama_model = data.get("ollama_model", cfg.ollama_model)
|
|
61
|
+
cfg.openai_api_key = data.get("openai_api_key", "")
|
|
62
|
+
cfg.openai_model = data.get("openai_model", cfg.openai_model)
|
|
63
|
+
raw_threshold = data.get("similarity_threshold", cfg.similarity_threshold)
|
|
64
|
+
try:
|
|
65
|
+
cfg.similarity_threshold = float(raw_threshold)
|
|
66
|
+
except (ValueError, TypeError):
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"Invalid similarity_threshold {raw_threshold!r} in config.toml — "
|
|
69
|
+
"expected a number between 0.0 and 1.0 (e.g. similarity_threshold = 0.85)"
|
|
70
|
+
) from None
|
|
71
|
+
|
|
72
|
+
raw_dup = data.get("duplicate_threshold", cfg.duplicate_threshold)
|
|
73
|
+
try:
|
|
74
|
+
cfg.duplicate_threshold = float(raw_dup)
|
|
75
|
+
except (ValueError, TypeError):
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"Invalid duplicate_threshold {raw_dup!r} in config.toml — "
|
|
78
|
+
"expected a number between 0.0 and 1.0 (e.g. duplicate_threshold = 0.95)"
|
|
79
|
+
) from None
|
|
80
|
+
|
|
81
|
+
# Env vars take priority
|
|
82
|
+
cfg.openai_api_key = os.environ.get("OPENAI_API_KEY", cfg.openai_api_key)
|
|
83
|
+
cfg.lancedb_api_key = os.environ.get("LANCEDB_API_KEY", cfg.lancedb_api_key)
|
|
84
|
+
|
|
85
|
+
# Only create the local directory when using local storage
|
|
86
|
+
if not cfg.is_remote:
|
|
87
|
+
cfg.db_path.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
|
|
89
|
+
return cfg
|