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
File without changes
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>循环记忆 · Loop Memory</title>
7
+ <meta name="description" content="Loop Memory — local-first memory loop for Codex, Claude, Hermes and OpenClaw. Capture, distill and recall your AI conversation knowledge." />
8
+ <link rel="stylesheet" href="static/css/app.css" />
9
+ <link rel="stylesheet" href="static/css/layout.css" />
10
+ </head>
11
+ <body>
12
+ <div id="app"></div>
13
+ <script type="module" src="static/js/main.js"></script>
14
+ </body>
15
+ </html>
@@ -0,0 +1,451 @@
1
+ """Filesystem watcher — auto-ingests **finished** transcripts.
2
+
3
+ Sister to ``loop-memory hook``. Watches a directory for transcripts
4
+ written by Codex CLI / Claude Code / Hermes and ingests **only when a
5
+ transcript is "done"**:
6
+
7
+ * its **byte size** has not grown for ``idle_seconds`` (default 60s).
8
+
9
+ We intentionally do NOT key on mtime alone: Codex desktop (and similar
10
+ agents) refresh the file mtime on background metadata flushes even
11
+ when no new content is being written. Treating those as "still being
12
+ written" would prevent an ingest from ever firing for long, active
13
+ sessions. Size-stable-for-N-seconds is the correct signal.
14
+
15
+ This means a 30-minute chat that just ended is picked up ~60 seconds
16
+ after the user (or the CLI's auto-save) finished writing. Active
17
+ typing that grows the file size every few seconds is **not** picked up,
18
+ but pure metadata flushes on an idle file **are**.
19
+
20
+ Already-ingested files are tracked in a small JSON ledger so re-runs
21
+ don't double-write.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ import os
29
+ import time
30
+ from pathlib import Path
31
+ from typing import Any, Optional
32
+
33
+ from ..ingest.loader import BaseLoader
34
+ from ..ingest.pipeline import MemoryPipeline
35
+
36
+ log = logging.getLogger("loop_memory.watcher")
37
+ # Make ``watching ...`` / ``watcher settings reloaded: ...`` lines
38
+ # visible in the hook process log without requiring every caller to
39
+ # configure logging first. ``basicConfig`` is a no-op once the root
40
+ # logger already has a handler, so importing this module from the
41
+ # serve app or from tests doesn't disturb their log formatting.
42
+ if not logging.getLogger().handlers:
43
+ _level_name = os.environ.get("LOOP_MEMORY_LOG_LEVEL", "INFO").upper()
44
+ logging.basicConfig(
45
+ level=getattr(logging, _level_name, logging.INFO),
46
+ format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
47
+ )
48
+
49
+
50
+ def _ledger_path(watch_dir: Path) -> Path:
51
+ return Path(watch_dir).expanduser() / ".loop_memory_seen.json"
52
+
53
+
54
+ def _load_ledger(path: Path) -> dict:
55
+ if not path.exists():
56
+ return {}
57
+ try:
58
+ return json.loads(path.read_text(encoding="utf-8"))
59
+ except Exception:
60
+ return {}
61
+
62
+
63
+ def _save_ledger(path: Path, ledger: dict) -> None:
64
+ try:
65
+ path.write_text(json.dumps(ledger, ensure_ascii=False), encoding="utf-8")
66
+ except Exception:
67
+ log.exception("failed to persist ingest ledger at %s", path)
68
+
69
+
70
+ # Default knobs when the user has no persisted ingest settings yet.
71
+ # We default to 5 minutes (300s) of size-stable idle before ingesting:
72
+ # shorter intervals fragment long conversations into multiple partial
73
+ # memories, longer intervals delay recall. The user can dial this up
74
+ # or down from Settings → 采集频率. ``poll_seconds`` defaults to 5
75
+ # (vs the previous 2.0) to cut down on stat() churn on the watched
76
+ # directory — stat is cheap on SSD but very cheap on the order of
77
+ # seconds; not the order of milliseconds. The user can always tune
78
+ # both via the Settings drawer.
79
+ DEFAULT_IDLE_SECONDS = 300.0
80
+ DEFAULT_POLL_SECONDS = 5.0
81
+
82
+
83
+ def _read_ingest_settings(store) -> tuple[float, float]:
84
+ """Pull ``ingest.idle_seconds`` / ``ingest.poll_seconds`` from the
85
+ settings store. Missing keys fall back to module defaults.
86
+
87
+ Returning floats (not e.g. ints) keeps the math inside the loop
88
+ predictable: ``time.sleep(poll_seconds)`` and the idle comparison
89
+ both treat the value as a wall-clock duration in seconds.
90
+
91
+ A failure here is logged and falls back to defaults rather than
92
+ crashing the watcher — the watcher is a long-lived background
93
+ process and a transient DB hiccup must not kill it.
94
+ """
95
+ try:
96
+ cfg = store.get_setting("ingest", {}) if store is not None else {}
97
+ except Exception:
98
+ cfg = {}
99
+ idle = float(cfg.get("idle_seconds", DEFAULT_IDLE_SECONDS))
100
+ poll = float(cfg.get("poll_seconds", DEFAULT_POLL_SECONDS))
101
+ return idle, poll
102
+
103
+
104
+ def run_watcher(
105
+ loader: BaseLoader,
106
+ watch_dir: Path,
107
+ pipeline: MemoryPipeline,
108
+ poll_seconds: float | None = None,
109
+ idle_seconds: float | None = None,
110
+ ledger: dict | None = None,
111
+ on_ingest: callable | None = None,
112
+ store: Any = None,
113
+ ) -> None:
114
+ """Watch a directory and ingest each transcript once it has been idle
115
+ for ``idle_seconds``.
116
+
117
+ ``ledger`` is a dict ``path → {mtime, size, ingested_at}`` used for
118
+ idempotency. Pass in to share state across processes, leave None
119
+ to use the default JSON file under ``watch_dir``.
120
+
121
+ ``on_ingest`` is an optional callable invoked with no arguments
122
+ after a successful ingest. The serve layer hooks this to a
123
+ consolidator scheduler so ``realtime`` mode can fire.
124
+
125
+ ``store`` is an optional :class:`MemoryStore`. When provided, the
126
+ watcher reads ``ingest.idle_seconds`` / ``ingest.poll_seconds``
127
+ from the settings table at every iteration so the user can dial
128
+ ingest frequency from the Settings drawer WITHOUT restarting the
129
+ launchd watcher process. Reads are throttled to once every
130
+ ``SETTINGS_RELOAD_EVERY`` ticks (cheap SQLite SELECT) so we
131
+ don't add noticeable overhead even at a 5-second poll cadence.
132
+ """
133
+ watch_dir = Path(watch_dir).expanduser()
134
+ watch_dir.mkdir(parents=True, exist_ok=True)
135
+ ledger_path = _ledger_path(watch_dir)
136
+ if ledger is None:
137
+ ledger = _load_ledger(ledger_path)
138
+
139
+ # Resolve initial values from the settings store if available,
140
+ # otherwise fall back to the kwarg / module defaults.
141
+ store_idle, store_poll = _read_ingest_settings(store)
142
+ if idle_seconds is None:
143
+ idle_seconds = store_idle
144
+ if poll_seconds is None:
145
+ poll_seconds = store_poll
146
+
147
+ # Re-read settings on a wall-clock cadence instead of per-tick,
148
+ # so reload latency doesn't grow with ``poll_seconds``. We default
149
+ # to 30s: short enough that a user dialing the slider sees the
150
+ # effect promptly, long enough that we don't hammer SQLite.
151
+ SETTINGS_RELOAD_SECONDS = 30.0
152
+
153
+ log.info(
154
+ "watching %s for %s transcripts (idle>=%.0fs, poll=%.1fs)",
155
+ watch_dir, loader.source, idle_seconds, poll_seconds,
156
+ )
157
+
158
+ def persist():
159
+ _save_ledger(ledger_path, ledger)
160
+
161
+ last_reload_at = 0.0
162
+ try:
163
+ while True:
164
+ # Throttled settings reload on a wall-clock cadence so
165
+ # the reload interval is stable regardless of poll_seconds.
166
+ if store is not None:
167
+ now_mono = time.monotonic()
168
+ if now_mono - last_reload_at >= SETTINGS_RELOAD_SECONDS:
169
+ last_reload_at = now_mono
170
+ try:
171
+ new_idle, new_poll = _read_ingest_settings(store)
172
+ if new_idle != idle_seconds or new_poll != poll_seconds:
173
+ log.info(
174
+ "watcher settings reloaded: idle=%.0fs poll=%.1fs",
175
+ new_idle, new_poll,
176
+ )
177
+ idle_seconds = new_idle
178
+ poll_seconds = new_poll
179
+ except Exception:
180
+ log.exception("settings reload failed (using current values)")
181
+
182
+ try:
183
+ files = list(loader.discover(watch_dir))
184
+ except FileNotFoundError:
185
+ files = []
186
+
187
+ now = time.time()
188
+ for path in files:
189
+ key = str(path)
190
+ try:
191
+ st = path.stat()
192
+ except FileNotFoundError:
193
+ continue
194
+
195
+ if path.name == ".loop_memory_seen.json":
196
+ continue
197
+
198
+ sig = (st.st_mtime, st.st_size)
199
+ prev = ledger.get(key)
200
+
201
+ # Already-ingested with same signature → skip.
202
+ if prev and prev.get("sig") == list(sig):
203
+ continue
204
+
205
+ # Already-ingested but file changed.
206
+ #
207
+ # v2 fix (size-stable idle, not mtime-stable idle):
208
+ # Previously any mtime refresh — including background
209
+ # metadata flushes from Codex desktop that do not add
210
+ # any new content — would reset the idle timer, which
211
+ # meant a long-running active session would never
212
+ # trigger an ingest: every keystroke flushed the file
213
+ # mtime and we kept waiting.
214
+ #
215
+ # The real signal of "still being written" is *content
216
+ # growth* (size increasing). mtime alone is unreliable.
217
+ # We now track ``last_size_change_at`` and only treat a
218
+ # file as active when its size is actually growing.
219
+ if prev and prev.get("ingested_at"):
220
+ prev_size = prev.get("size", -1)
221
+ if st.st_size > prev_size:
222
+ # Real content growth → bump idle timestamp.
223
+ ledger[key] = {
224
+ "sig": list(sig),
225
+ "first_seen": prev.get("first_seen", now),
226
+ "last_mtime": st.st_mtime,
227
+ "size": st.st_size,
228
+ "last_size_change_at": now,
229
+ "ingested_at": None,
230
+ }
231
+ else:
232
+ # Only mtime refreshed, no new bytes. Keep the
233
+ # idle clock running — do NOT reset it.
234
+ ledger[key] = {
235
+ "sig": list(sig),
236
+ "first_seen": prev.get("first_seen", now),
237
+ "last_mtime": st.st_mtime,
238
+ "size": st.st_size,
239
+ # Fall back to the previous bump time so
240
+ # legacy ledgers without the field keep
241
+ # working.
242
+ "last_size_change_at": prev.get(
243
+ "last_size_change_at",
244
+ prev.get("first_seen", now),
245
+ ),
246
+ "ingested_at": None,
247
+ }
248
+ continue
249
+
250
+ # First observation: stamp it.
251
+ if not prev:
252
+ ledger[key] = {
253
+ "sig": list(sig),
254
+ "first_seen": now,
255
+ "last_mtime": st.st_mtime,
256
+ "size": st.st_size,
257
+ "last_size_change_at": now,
258
+ "ingested_at": None,
259
+ }
260
+ persist()
261
+ continue
262
+
263
+ # Subsequent observation: only proceed if the file has
264
+ # been size-stable (not just mtime-stable) for
265
+ # ``idle_seconds``. Codex desktop touches mtime on
266
+ # every flush but the size only grows when new
267
+ # conversation content lands — that's the signal we
268
+ # care about.
269
+ last_change = ledger[key].get(
270
+ "last_size_change_at",
271
+ ledger[key].get("first_seen", now),
272
+ )
273
+ if (now - last_change) < idle_seconds:
274
+ continue
275
+
276
+ # Stable and idle → ingest once.
277
+ try:
278
+ session = loader.load_one(path)
279
+ except Exception:
280
+ log.exception("loader failed on %s", path)
281
+ session = None
282
+
283
+ if session is not None:
284
+ try:
285
+ result = pipeline.run(session)
286
+ log.info(
287
+ "ingested %s as %s (%d summary items)",
288
+ path.name, session.source, len(result.summary_items),
289
+ )
290
+ if on_ingest is not None:
291
+ try:
292
+ on_ingest()
293
+ except Exception:
294
+ log.exception("on_ingest callback failed")
295
+ except Exception:
296
+ log.exception("pipeline failed on %s", path)
297
+
298
+ ledger[key] = {
299
+ "sig": list(sig),
300
+ "first_seen": prev.get("first_seen", now),
301
+ "last_mtime": st.st_mtime,
302
+ "size": st.st_size,
303
+ "ingested_at": now,
304
+ }
305
+ persist()
306
+
307
+ time.sleep(poll_seconds)
308
+ except KeyboardInterrupt:
309
+ log.info("watcher exiting")
310
+ persist()
311
+
312
+
313
+
314
+ def run_once(
315
+ loader: BaseLoader,
316
+ watch_dir: Path,
317
+ pipeline: MemoryPipeline,
318
+ poll_seconds: float = 2.0,
319
+ idle_seconds: float = 0.0,
320
+ ledger: dict | None = None,
321
+ on_ingest: callable | None = None,
322
+ ) -> dict[str, Any]:
323
+ """Run a single ingest pass over ``watch_dir`` and return a summary.
324
+
325
+ Unlike :func:`run_watcher` this does NOT loop — it scans once,
326
+ ingests any file whose size has grown since the last successful
327
+ ingest (or that has been size-stable for ``idle_seconds``), and
328
+ returns. Used by the server-side force-ingest endpoint so a UI
329
+ button can trigger one batch without spawning a long-lived
330
+ watcher process.
331
+
332
+ Returns a dict with::
333
+
334
+ {
335
+ "scanned": int, # number of files seen
336
+ "ingested": int, # number of files successfully ingested
337
+ "skipped": int, # unchanged or already-ingested
338
+ "errors": int, # files that failed to load
339
+ "files": [ # per-file detail
340
+ {"path": str, "status": "ingested"|"skipped"|"error",
341
+ "summary_items": int, "error": str?}
342
+ ],
343
+ }
344
+ """
345
+ watch_dir = Path(watch_dir).expanduser()
346
+ watch_dir.mkdir(parents=True, exist_ok=True)
347
+ ledger_path = _ledger_path(watch_dir)
348
+ if ledger is None:
349
+ ledger = _load_ledger(ledger_path)
350
+
351
+ def _persist():
352
+ _save_ledger(ledger_path, ledger)
353
+
354
+ result: dict[str, Any] = {
355
+ "scanned": 0,
356
+ "ingested": 0,
357
+ "skipped": 0,
358
+ "errors": 0,
359
+ "files": [],
360
+ }
361
+ try:
362
+ files = list(loader.discover(watch_dir))
363
+ except FileNotFoundError:
364
+ files = []
365
+
366
+ now = time.time()
367
+ for path in files:
368
+ result["scanned"] += 1
369
+ key = str(path)
370
+ try:
371
+ st = path.stat()
372
+ except FileNotFoundError:
373
+ continue
374
+ if path.name == ".loop_memory_seen.json":
375
+ continue
376
+
377
+ prev = ledger.get(key)
378
+ prev_size = (prev or {}).get("size", -1)
379
+ prev_ingested = (prev or {}).get("ingested_at")
380
+
381
+ # If the file is identical to what we last ingested, skip.
382
+ if prev_ingested and st.st_size == prev_size:
383
+ result["skipped"] += 1
384
+ result["files"].append({"path": key, "status": "skipped"})
385
+ continue
386
+
387
+ # Optional idle gate: when idle_seconds > 0, only ingest if
388
+ # the file's size has been stable for at least that long.
389
+ # When idle_seconds == 0 (the default for run_once), ingest
390
+ # immediately as long as new content exists.
391
+ if idle_seconds > 0:
392
+ last_change = (prev or {}).get(
393
+ "last_size_change_at",
394
+ (prev or {}).get("first_seen", now),
395
+ )
396
+ if (now - last_change) < idle_seconds:
397
+ result["skipped"] += 1
398
+ result["files"].append({
399
+ "path": key, "status": "skipped",
400
+ "reason": "not_idle_long_enough",
401
+ })
402
+ continue
403
+
404
+ # Try to load + ingest.
405
+ try:
406
+ session = loader.load_one(path)
407
+ except Exception as e:
408
+ log.exception("loader failed on %s", path)
409
+ result["errors"] += 1
410
+ result["files"].append({
411
+ "path": key, "status": "error",
412
+ "error": f"{type(e).__name__}: {e}",
413
+ })
414
+ continue
415
+
416
+ if session is None:
417
+ result["skipped"] += 1
418
+ result["files"].append({"path": key, "status": "skipped"})
419
+ continue
420
+
421
+ try:
422
+ pipe_result = pipeline.run(session)
423
+ n_items = len(pipe_result.summary_items)
424
+ result["ingested"] += 1
425
+ result["files"].append({
426
+ "path": key, "status": "ingested",
427
+ "summary_items": n_items,
428
+ })
429
+ ledger[key] = {
430
+ "sig": [st.st_mtime, st.st_size],
431
+ "first_seen": (prev or {}).get("first_seen", now),
432
+ "last_mtime": st.st_mtime,
433
+ "size": st.st_size,
434
+ "last_size_change_at": now,
435
+ "ingested_at": now,
436
+ }
437
+ _persist()
438
+ if on_ingest is not None:
439
+ try:
440
+ on_ingest()
441
+ except Exception:
442
+ log.exception("on_ingest callback failed")
443
+ except Exception as e:
444
+ log.exception("pipeline failed on %s", path)
445
+ result["errors"] += 1
446
+ result["files"].append({
447
+ "path": key, "status": "error",
448
+ "error": f"{type(e).__name__}: {e}",
449
+ })
450
+
451
+ return result
@@ -0,0 +1,5 @@
1
+ """Persistent storage backends for Loop Memory."""
2
+
3
+ from .sqlite_store import MemoryStore, StoredMemory, StoredSession
4
+
5
+ __all__ = ["MemoryStore", "StoredMemory", "StoredSession"]