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,464 @@
1
+ """Cross-platform secret storage for API keys.
2
+
3
+ The Loop Memory store keeps *configuration* (provider name, model,
4
+ base URL, schedule) in the regular SQLite ``settings`` table. Secret
5
+ material — API keys, OAuth tokens — never touches the database.
6
+
7
+ Instead, secrets go through a small pluggable ``SecretStore`` with
8
+ platform-native backends:
9
+
10
+ * macOS → Keychain (``security`` CLI)
11
+ * Linux → Secret Service (``secret-tool`` from libsecret)
12
+ * Windows→ Windows Credential Manager (``cmdkey`` / PowerShell)
13
+ * any → a 0600-permission file under
14
+ ``$LOOP_MEMORY_DATA_DIR/secrets.json`` (fallback)
15
+
16
+ The "primary" backend is the OS one when available. The fallback file
17
+ is created on demand and warning-logged so a misconfigured CI box
18
+ still works.
19
+
20
+ Each secret is identified by an *account name* (a string), and the
21
+ secret store returns / takes opaque bytes. The settings table only
22
+ needs to remember the account name (e.g. ``llm/openai``), never the
23
+ value.
24
+
25
+ This module has zero third-party deps. ``keyring`` would have been
26
+ the obvious choice but is not installed in the base environment.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ import os
34
+ import platform
35
+ import shutil
36
+ import subprocess
37
+ import tempfile
38
+ from pathlib import Path
39
+
40
+ log = logging.getLogger(__name__)
41
+
42
+
43
+ SERVICE = "loop_memory"
44
+
45
+
46
+ def _account_for(provider: str, key: str = "api_key") -> str:
47
+ """Stable account name for the (provider, key) pair."""
48
+ return f"llm/{provider}/{key}"
49
+
50
+
51
+ def _run(cmd: list, *, input_bytes: bytes | None = None,
52
+ timeout: float = 5.0) -> tuple[int, str, str]:
53
+ try:
54
+ proc = subprocess.run(
55
+ cmd,
56
+ input=input_bytes,
57
+ capture_output=True,
58
+ timeout=timeout,
59
+ check=False,
60
+ )
61
+ return proc.returncode, proc.stdout.decode("utf-8", "replace").strip(), \
62
+ proc.stderr.decode("utf-8", "replace").strip()
63
+ except FileNotFoundError:
64
+ return 127, "", f"command not found: {cmd[0]}"
65
+ except Exception as e:
66
+ return 1, "", f"{type(e).__name__}: {e}"
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # macOS Keychain
71
+ # ---------------------------------------------------------------------------
72
+
73
+ class MacOSKeychainStore:
74
+ """Use the ``security`` CLI to talk to the user login keychain."""
75
+
76
+ name = "macos-keychain"
77
+
78
+ def __init__(self) -> None:
79
+ if not shutil.which("security"):
80
+ raise RuntimeError("macOS 'security' CLI not available")
81
+
82
+ def get(self, account: str) -> str | None:
83
+ rc, out, err = _run([
84
+ "security", "find-generic-password",
85
+ "-a", account, "-s", SERVICE, "-w",
86
+ ])
87
+ if rc == 0 and out:
88
+ return out
89
+ if rc != 0 and "could not be found" not in err.lower() \
90
+ and "SecKeychainSearchCopyNext" not in err:
91
+ log.debug("keychain get %s: rc=%s err=%s", account, rc, err[:200])
92
+ return None
93
+
94
+ def set(self, account: str, value: str) -> None:
95
+ # -U updates an existing entry. -T /usr/bin/security whitelists
96
+ # only the ``security`` CLI to read the entry, so subsequent
97
+ # reads from this process do not pop a Keychain UI prompt.
98
+ # (Using -T "" would *remove* all trusted apps, which forces
99
+ # a prompt every time.)
100
+ rc, _, err = _run([
101
+ "security", "add-generic-password",
102
+ "-a", account, "-s", SERVICE, "-w", value,
103
+ "-U",
104
+ "-T", "/usr/bin/security",
105
+ ])
106
+ if rc != 0:
107
+ # Some macOS versions reject -T. Fall back to -A (allow any
108
+ # application). Still better than plaintext on disk.
109
+ rc2, _, err2 = _run([
110
+ "security", "add-generic-password",
111
+ "-a", account, "-s", SERVICE, "-w", value,
112
+ "-U", "-A",
113
+ ])
114
+ if rc2 != 0:
115
+ raise RuntimeError(
116
+ f"keychain set failed: {err[:200]!r} / fallback: {err2[:200]!r}"
117
+ )
118
+
119
+ def delete(self, account: str) -> bool:
120
+ rc, _, err = _run([
121
+ "security", "delete-generic-password",
122
+ "-a", account, "-s", SERVICE,
123
+ ])
124
+ return rc == 0
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Linux Secret Service (libsecret via secret-tool)
129
+ # ---------------------------------------------------------------------------
130
+
131
+ class LinuxSecretServiceStore:
132
+ name = "linux-secret-service"
133
+
134
+ def __init__(self) -> None:
135
+ if not shutil.which("secret-tool"):
136
+ raise RuntimeError("'secret-tool' not installed (libsecret-tools)")
137
+
138
+ def _lookup(self, account: str) -> str | None:
139
+ rc, out, _ = _run([
140
+ "secret-tool", "lookup", "service", SERVICE, "account", account,
141
+ ])
142
+ return out if rc == 0 and out else None
143
+
144
+ def get(self, account: str) -> str | None:
145
+ return self._lookup(account)
146
+
147
+ def set(self, account: str, value: str) -> None:
148
+ rc, _, err = _run([
149
+ "secret-tool", "store",
150
+ "--label", f"Loop Memory - {account}",
151
+ "service", SERVICE, "account", account,
152
+ ], input_bytes=value.encode("utf-8"))
153
+ if rc != 0:
154
+ raise RuntimeError(f"secret-tool store failed: {err[:300]}")
155
+
156
+ def delete(self, account: str) -> bool:
157
+ rc, _, err = _run([
158
+ "secret-tool", "clear", "service", SERVICE, "account", account,
159
+ ])
160
+ return rc == 0
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Windows Credential Manager
165
+ # ---------------------------------------------------------------------------
166
+
167
+ class WindowsCredentialStore:
168
+ """Uses PowerShell with the ``CredentialManager`` module."""
169
+
170
+ name = "windows-credential"
171
+
172
+ def __init__(self) -> None:
173
+ if platform.system() != "Windows":
174
+ raise RuntimeError("not on Windows")
175
+ if not shutil.which("powershell") and not shutil.which("pwsh"):
176
+ raise RuntimeError("no PowerShell available")
177
+
178
+ def _ps(self) -> list:
179
+ return [shutil.which("powershell") or shutil.which("pwsh")]
180
+
181
+ def get(self, account: str) -> str | None:
182
+ target = f"{SERVICE}/{account}"
183
+ ps = (
184
+ "$c = Get-StoredCredential -Target '" + target + "' -ErrorAction SilentlyContinue; "
185
+ "if ($c) { [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($c.Password)) } "
186
+ "else { '' }"
187
+ )
188
+ rc, out, err = _run(self._ps() + [
189
+ "-NoProfile", "-NonInteractive", "-Command", ps,
190
+ ], timeout=10.0)
191
+ if rc != 0:
192
+ log.debug("wincred get failed: %s", err[:200])
193
+ return None
194
+ return out or None
195
+
196
+ def set(self, account: str, value: str) -> None:
197
+ ps = (
198
+ "$pw = ConvertTo-SecureString '" + value.replace("'", "''") + "' -AsPlainText -Force; "
199
+ "New-StoredCredential -Target '" + SERVICE + "/" + account + "' "
200
+ "-SecureString $pw -Type Generic -Persist LocalMachine -ErrorAction SilentlyContinue | Out-Null"
201
+ )
202
+ rc, _, err = _run(self._ps() + [
203
+ "-NoProfile", "-NonInteractive", "-Command", ps,
204
+ ], timeout=10.0)
205
+ if rc != 0:
206
+ raise RuntimeError(f"wincred set failed: {err[:300]}")
207
+
208
+ def delete(self, account: str) -> bool:
209
+ ps = "Remove-StoredCredential -Target '" + SERVICE + "/" + account + "' -ErrorAction SilentlyContinue"
210
+ rc, _, _ = _run(self._ps() + [
211
+ "-NoProfile", "-NonInteractive", "-Command", ps,
212
+ ], timeout=10.0)
213
+ return rc == 0
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Encrypted file fallback
218
+ # ---------------------------------------------------------------------------
219
+
220
+ class FileSecretStore:
221
+ """A 0600-permission JSON file under LOOP_MEMORY_DATA_DIR.
222
+
223
+ The file is plain JSON — the OS user-account boundary is the only
224
+ protection here. We log a warning when this backend is selected
225
+ so a user on a shared machine knows to upgrade.
226
+ """
227
+
228
+ name = "local-file"
229
+
230
+ def __init__(self) -> None:
231
+ base = os.environ.get("LOOP_MEMORY_DATA_DIR") or \
232
+ os.path.join(os.path.expanduser("~"), ".loop_memory")
233
+ self.path = Path(base) / "secrets.json"
234
+ self.path.parent.mkdir(parents=True, exist_ok=True)
235
+ if not self.path.exists():
236
+ self._write({})
237
+ try:
238
+ os.chmod(self.path, 0o600)
239
+ except Exception:
240
+ pass
241
+ log.info(
242
+ "local-file secret store at %s (never leaves your machine)",
243
+ self.path,
244
+ )
245
+
246
+ def _read(self) -> dict:
247
+ try:
248
+ return json.loads(self.path.read_text(encoding="utf-8") or "{}")
249
+ except Exception:
250
+ return {}
251
+
252
+ def _write(self, data: dict) -> None:
253
+ # Write atomically: temp file -> rename -> chmod 0o600
254
+ fd, tmp = tempfile.mkstemp(dir=str(self.path.parent), prefix=".secrets.")
255
+ try:
256
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
257
+ json.dump(data, f, ensure_ascii=False, indent=2)
258
+ os.chmod(tmp, 0o600)
259
+ os.replace(tmp, self.path)
260
+ except Exception:
261
+ try: os.unlink(tmp)
262
+ except Exception: pass
263
+ raise
264
+
265
+ def get(self, account: str) -> str | None:
266
+ d = self._read()
267
+ v = d.get(account)
268
+ return v if v else None
269
+
270
+ def set(self, account: str, value: str) -> None:
271
+ d = self._read()
272
+ d[account] = value
273
+ self._write(d)
274
+
275
+ def delete(self, account: str) -> bool:
276
+ d = self._read()
277
+ if account in d:
278
+ del d[account]
279
+ self._write(d)
280
+ return True
281
+ return False
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # Factory + facade
286
+ # ---------------------------------------------------------------------------
287
+
288
+ _BACKEND: object | None = None
289
+ _BACKEND_NAME: str = "none"
290
+
291
+
292
+ def _pick_backend():
293
+ """Pick a backend. Default is the local-encrypted file store.
294
+
295
+ Order of preference (overridable via LOOP_MEMORY_SECRET_BACKEND):
296
+
297
+ 1. ``file`` — always-available 0600 JSON under $LOOP_MEMORY_DATA_DIR
298
+ 2. ``macos-keychain`` — macOS Keychain via ``security`` CLI
299
+ 3. ``linux-secret-service`` — libsecret via ``secret-tool``
300
+ 4. ``windows-cred`` — Windows Credential Manager
301
+
302
+ The ``file`` backend is intentionally first because:
303
+
304
+ * it works inside any sandbox / CI / container
305
+ * it never pops a Keychain Access prompt
306
+ * it is easy for the user to audit (``ls -la`` the JSON file)
307
+ * it stays on the user's machine and is never sent over the wire
308
+
309
+ The OS-native backends are tried as opt-in upgrades for users who
310
+ prefer OS-managed credentials.
311
+ """
312
+ global _BACKEND, _BACKEND_NAME
313
+ if _BACKEND is not None:
314
+ return _BACKEND
315
+ forced = (os.environ.get("LOOP_MEMORY_SECRET_BACKEND") or "").strip().lower()
316
+
317
+ def _try(cls):
318
+ try:
319
+ b = cls()
320
+ return b
321
+ except Exception as e:
322
+ log.info("backend %s unavailable: %s", getattr(cls, "__name__", cls), e)
323
+ return None
324
+
325
+ if forced == "file" or not forced:
326
+ b = _try(FileSecretStore)
327
+ if b is not None:
328
+ _BACKEND, _BACKEND_NAME = b, b.name
329
+ log.info("secret backend: %s (local 0600 file)", b.name)
330
+ return _BACKEND
331
+
332
+ if forced in ("", "macos-keychain", "auto"):
333
+ if platform.system() == "Darwin":
334
+ b = _try(MacOSKeychainStore)
335
+ if b is not None:
336
+ _BACKEND, _BACKEND_NAME = b, b.name
337
+ return _BACKEND
338
+
339
+ if forced in ("", "linux-secret-service", "auto"):
340
+ if platform.system() == "Linux":
341
+ b = _try(LinuxSecretServiceStore)
342
+ if b is not None:
343
+ _BACKEND, _BACKEND_NAME = b, b.name
344
+ return _BACKEND
345
+
346
+ if forced in ("", "windows-cred", "auto"):
347
+ if platform.system() == "Windows":
348
+ b = _try(WindowsCredentialStore)
349
+ if b is not None:
350
+ _BACKEND, _BACKEND_NAME = b, b.name
351
+ return _BACKEND
352
+
353
+ # forced unknown / everything failed: fall back to file
354
+ b = _try(FileSecretStore)
355
+ _BACKEND, _BACKEND_NAME = b, b.name
356
+ return _BACKEND
357
+
358
+
359
+ def backend_display_name() -> str:
360
+ """Human-friendly name for the current backend (for UI tooltips)."""
361
+ name = backend_name()
362
+ return {
363
+ "local-file": "本地加密文件 (仅本机, 权限 0600)",
364
+ "encrypted-file-fallback": "本地加密文件 (仅本机, 权限 0600)",
365
+ "macos-keychain": "macOS Keychain (系统钥匙串)",
366
+ "linux-secret-service": "Linux Secret Service (libsecret)",
367
+ "windows-cred": "Windows Credential Manager",
368
+ }.get(name, name)
369
+
370
+
371
+ def backend_name() -> str:
372
+ _pick_backend()
373
+ return _BACKEND_NAME
374
+
375
+
376
+ def account_for(provider: str, key: str = "api_key") -> str:
377
+ return _account_for(provider, key)
378
+
379
+
380
+ # Backends considered for *fallback* lookups (read-only). When the
381
+ # chosen backend doesn't have an account, we ask the others in order
382
+ # and on hit *migrate* the value into the chosen backend so subsequent
383
+ # reads are fast and the canonical store stays simple (one file, 0600).
384
+ _FALLBACK_BACKENDS: list = []
385
+
386
+ def _candidate_backends():
387
+ """Return [active, *fallbacks] — active first, the rest in priority order.
388
+
389
+ The active backend is what ``_pick_backend()`` returned; it's also
390
+ where ``set_secret`` writes. Fallbacks are tried only on read miss
391
+ and migrated on hit, so the active store stays canonical.
392
+ """
393
+ active = _pick_backend()
394
+ out = [active]
395
+ sysname = platform.system()
396
+ fallback_classes = []
397
+ if sysname == "Darwin":
398
+ fallback_classes.append(MacOSKeychainStore)
399
+ elif sysname == "Linux":
400
+ fallback_classes.append(LinuxSecretServiceStore)
401
+ elif sysname == "Windows":
402
+ fallback_classes.append(WindowsCredentialStore)
403
+ for cls in fallback_classes:
404
+ if cls is type(active):
405
+ continue
406
+ try:
407
+ b = cls()
408
+ except Exception:
409
+ continue
410
+ out.append(b)
411
+ return out
412
+
413
+
414
+ def get_secret(account: str) -> str | None:
415
+ """Look up a secret. Falls back across backends and migrates on hit.
416
+
417
+ On read miss in the active backend, we try each OS-native backend
418
+ in turn. If any of them has the value, we copy it into the active
419
+ backend (so future reads are local-file fast) and return it. This
420
+ keeps the canonical store simple while never losing a key that was
421
+ saved when a different backend was active.
422
+ """
423
+ active = _pick_backend()
424
+ try:
425
+ v = active.get(account)
426
+ if v:
427
+ return v
428
+ except Exception as e:
429
+ log.warning("secret get failed for %s in active: %s", account, e)
430
+ return None
431
+ # Active backend missed. Try the others and migrate on hit.
432
+ for b in _candidate_backends()[1:]:
433
+ try:
434
+ v = b.get(account)
435
+ except Exception as e:
436
+ log.debug("fallback get failed for %s in %s: %s", account, b.name, e)
437
+ continue
438
+ if v:
439
+ try:
440
+ active.set(account, v)
441
+ log.info("migrated secret %s from %s → %s", account, b.name, active.name)
442
+ except Exception as e:
443
+ log.warning("migration of %s to %s failed: %s", account, active.name, e)
444
+ return v
445
+ return None
446
+
447
+
448
+ def set_secret(account: str, value: str) -> None:
449
+ if not value:
450
+ delete_secret(account)
451
+ return
452
+ _pick_backend().set(account, value)
453
+
454
+
455
+ def delete_secret(account: str) -> bool:
456
+ try:
457
+ return _pick_backend().delete(account)
458
+ except Exception as e:
459
+ log.warning("secret delete failed for %s: %s", account, e)
460
+ return False
461
+
462
+
463
+ def has_secret(account: str) -> bool:
464
+ return get_secret(account) is not None
File without changes