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,62 @@
1
+ """Loop Engineering Memory System for Large Language Models.
2
+
3
+ A zero-dependency framework that gives any LLM a persistent
4
+ ``Retrieve → Generate → Reflect → Store`` loop plus a tiered memory
5
+ (short / long / episodic / procedural) and a SQLite-backed local store
6
+ for cross-session recall.
7
+
8
+ Optional extras:
9
+
10
+ pip install loop-memory[openai] # OpenAIClient
11
+ pip install loop-memory[chroma] # ChromaVectorStore
12
+ pip install loop-memory[sentence] # SentenceTransformerEmbedder
13
+ pip install loop-memory[serve] # FastAPI + uvicorn for the local UI
14
+ pip install loop-memory[all] # everything
15
+ """
16
+
17
+ from .backends.embedding import BaseEmbedder, HashingEmbedder, IdentityEmbedder
18
+ from .backends.vector_store import (
19
+ ChromaVectorStore,
20
+ InMemoryVectorStore,
21
+ VectorStore,
22
+ )
23
+ from .engine.loop import LoopEngine, LoopResult
24
+ from .llm.base import ChatHistory, EchoLLM, LLMClient, Message
25
+ from .memory.types import (
26
+ EpisodicMemory,
27
+ LongTermMemory,
28
+ MemoryItem,
29
+ ProceduralMemory,
30
+ ShortTermMemory,
31
+ )
32
+ from .storage.sqlite_store import MemoryStore, StoredMemory, StoredSession
33
+
34
+ __version__ = "0.2.0"
35
+
36
+ __all__ = [
37
+ # engine
38
+ "LoopEngine",
39
+ "LoopResult",
40
+ # memory
41
+ "MemoryItem",
42
+ "ShortTermMemory",
43
+ "LongTermMemory",
44
+ "EpisodicMemory",
45
+ "ProceduralMemory",
46
+ # llm
47
+ "LLMClient",
48
+ "EchoLLM",
49
+ "ChatHistory",
50
+ "Message",
51
+ # backends
52
+ "BaseEmbedder",
53
+ "HashingEmbedder",
54
+ "IdentityEmbedder",
55
+ "VectorStore",
56
+ "InMemoryVectorStore",
57
+ "ChromaVectorStore",
58
+ # storage
59
+ "MemoryStore",
60
+ "StoredMemory",
61
+ "StoredSession",
62
+ ]
@@ -0,0 +1,13 @@
1
+ """Embedder + vector-store backends."""
2
+
3
+ from .embedding import BaseEmbedder, HashingEmbedder, IdentityEmbedder
4
+ from .vector_store import ChromaVectorStore, InMemoryVectorStore, VectorStore
5
+
6
+ __all__ = [
7
+ "BaseEmbedder",
8
+ "HashingEmbedder",
9
+ "IdentityEmbedder",
10
+ "VectorStore",
11
+ "InMemoryVectorStore",
12
+ "ChromaVectorStore",
13
+ ]
@@ -0,0 +1,82 @@
1
+ """Embedding backend abstractions.
2
+
3
+ The library is zero-dependency by default. This module ships a
4
+ ``HashingEmbedder`` that produces deterministic bag-of-words style
5
+ vectors — good enough for demos and tests. Real users plug in OpenAI,
6
+ sentence-transformers, BGE, etc. via ``BaseEmbedder``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import math
13
+ import re
14
+
15
+ from ..memory.types import MemoryItem
16
+
17
+ _TOKEN_RE = re.compile(r"[\w]+", re.UNICODE)
18
+
19
+
20
+ def _tokenize(text: str) -> list[str]:
21
+ return [t.lower() for t in _TOKEN_RE.findall(text)]
22
+
23
+
24
+ class BaseEmbedder:
25
+ """Subclass and implement ``embed`` and ``embed_query``."""
26
+
27
+ dim: int = 0
28
+
29
+ def embed(self, items: list[MemoryItem]) -> list[list[float]]:
30
+ raise NotImplementedError
31
+
32
+ def embed_query(self, text: str) -> list[float]:
33
+ raise NotImplementedError
34
+
35
+
36
+ class HashingEmbedder(BaseEmbedder):
37
+ """Deterministic hashed bag-of-words embedder.
38
+
39
+ Not semantically meaningful, but stable, dependency-free, and good
40
+ for tests. Useful when no real embedding model is available.
41
+ """
42
+
43
+ def __init__(self, dim: int = 256, ngram_range: tuple = (1, 2)) -> None:
44
+ self.dim = dim
45
+ self.ngram_range = ngram_range
46
+
47
+ def _vec(self, tokens: list[str]) -> list[float]:
48
+ vec = [0.0] * self.dim
49
+ for n in range(self.ngram_range[0], self.ngram_range[1] + 1):
50
+ for i in range(len(tokens) - n + 1):
51
+ gram = " ".join(tokens[i : i + n])
52
+ h = int(hashlib.md5(gram.encode("utf-8"), usedforsecurity=False).hexdigest(), 16)
53
+ idx = h % self.dim
54
+ sign = 1.0 if (h >> 8) & 1 else -1.0
55
+ vec[idx] += sign
56
+ norm = math.sqrt(sum(v * v for v in vec)) or 1e-12
57
+ return [v / norm for v in vec]
58
+
59
+ def embed(self, items: list[MemoryItem]) -> list[list[float]]:
60
+ return [self._vec(_tokenize(it.text)) for it in items]
61
+
62
+ def embed_query(self, text: str) -> list[float]:
63
+ return self._vec(_tokenize(text))
64
+
65
+ def embed_text(self, text: str) -> list[float]:
66
+ return self._vec(_tokenize(text))
67
+
68
+
69
+ class IdentityEmbedder(BaseEmbedder):
70
+ """Pass-through embedder — store None; retrieval becomes score-only.
71
+
72
+ Useful when you don't want vector search at all and prefer ranking
73
+ by importance/recency alone.
74
+ """
75
+
76
+ dim = 0
77
+
78
+ def embed(self, items: list[MemoryItem]) -> list[list[float]]:
79
+ return [None] # type: ignore[return-value]
80
+
81
+ def embed_query(self, text: str) -> None: # type: ignore[override]
82
+ return None
@@ -0,0 +1,30 @@
1
+ """Sentence-transformers backed embedder (optional dependency)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..memory.types import MemoryItem
6
+ from .embedding import BaseEmbedder
7
+
8
+
9
+ class SentenceTransformerEmbedder(BaseEmbedder):
10
+ def __init__(self, model_name: str = "all-MiniLM-L6-v2") -> None:
11
+ try:
12
+ from sentence_transformers import SentenceTransformer # type: ignore
13
+ except ImportError as e:
14
+ raise RuntimeError(
15
+ "sentence-transformers is not installed; "
16
+ "run `pip install loop-memory[sentence]`"
17
+ ) from e
18
+ self._model = SentenceTransformer(model_name)
19
+ self.dim = int(self._model.get_sentence_embedding_dimension())
20
+
21
+ def embed(self, items: list[MemoryItem]) -> list[list[float]]:
22
+ if not items:
23
+ return []
24
+ return [list(v) for v in self._model.encode([it.text for it in items])]
25
+
26
+ def embed_query(self, text: str) -> list[float]:
27
+ return list(self._model.encode([text])[0])
28
+
29
+ def embed_text(self, text: str) -> list[float]:
30
+ return self.embed_query(text)
@@ -0,0 +1,139 @@
1
+ """Pluggable vector-store abstraction.
2
+
3
+ By default the loop engine keeps everything in-process via
4
+ ``HashingEmbedder``. For production workloads you can swap in any
5
+ backend that implements ``VectorStore``: ChromaDB, FAISS, LanceDB,
6
+ pgvector, etc.
7
+
8
+ The store contract is intentionally minimal — add / search / delete —
9
+ so wiring a new backend is a small subclass.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Iterable
15
+ from dataclasses import dataclass, field
16
+ from typing import Protocol
17
+
18
+ from ..memory.types import MemoryItem
19
+
20
+
21
+ class VectorStore(Protocol):
22
+ def add(self, items: list[MemoryItem]) -> None: ...
23
+ def search(self, query_embedding: list[float], top_k: int = 5) -> list[MemoryItem]: ...
24
+ def delete(self, ids: Iterable[str]) -> int: ...
25
+ def count(self) -> int: ...
26
+
27
+
28
+ @dataclass
29
+ class InMemoryVectorStore:
30
+ """Reference implementation backed by a Python list.
31
+
32
+ Useful for unit tests and tiny personal agents. Not suitable for
33
+ corpora larger than a few thousand items.
34
+ """
35
+
36
+ _items: list[MemoryItem] = field(default_factory=list)
37
+
38
+ def add(self, items: list[MemoryItem]) -> None:
39
+ for it in items:
40
+ if it.embedding is None:
41
+ raise ValueError(f"MemoryItem {it.id!r} has no embedding")
42
+ self._items.extend(items)
43
+
44
+ def search(self, query_embedding: list[float], top_k: int = 5) -> list[MemoryItem]:
45
+ from ..memory.types import cosine_similarity
46
+
47
+ scored = [
48
+ (cosine_similarity(query_embedding, it.embedding or []), it)
49
+ for it in self._items
50
+ if it.embedding is not None
51
+ ]
52
+ scored.sort(key=lambda x: x[0], reverse=True)
53
+ return [it for _, it in scored[:top_k]]
54
+
55
+ def delete(self, ids: Iterable[str]) -> int:
56
+ ids_set = set(ids)
57
+ before = len(self._items)
58
+ self._items = [it for it in self._items if it.id not in ids_set]
59
+ return before - len(self._items)
60
+
61
+ def count(self) -> int:
62
+ return len(self._items)
63
+
64
+ def __iter__(self):
65
+ return iter(self._items)
66
+
67
+
68
+ @dataclass
69
+ class ChromaVectorStore:
70
+ """Optional ChromaDB backend. Imported lazily.
71
+
72
+ Install with::
73
+
74
+ pip install loop-memory[chroma]
75
+
76
+ Example::
77
+
78
+ store = ChromaVectorStore(collection="user_42")
79
+ engine = LoopEngine(llm=..., embedder=my_embedder, vector_store=store)
80
+ """
81
+
82
+ collection: str = "loop_memory"
83
+ persist_directory: str | None = None
84
+
85
+ def __post_init__(self) -> None:
86
+ try:
87
+ import chromadb # type: ignore
88
+ except ImportError as e:
89
+ raise RuntimeError(
90
+ "chromadb is not installed; run `pip install loop-memory[chroma]`"
91
+ ) from e
92
+ client = (
93
+ chromadb.PersistentClient(path=self.persist_directory)
94
+ if self.persist_directory
95
+ else chromadb.Client()
96
+ )
97
+ self._collection = client.get_or_create_collection(name=self.collection)
98
+
99
+ def add(self, items: list[MemoryItem]) -> None:
100
+ for it in items:
101
+ if it.embedding is None:
102
+ raise ValueError(f"MemoryItem {it.id!r} has no embedding")
103
+ self._collection.add(
104
+ ids=[it.id],
105
+ embeddings=[it.embedding],
106
+ documents=[it.text],
107
+ metadatas=[{"kind": it.kind, "importance": it.importance}],
108
+ )
109
+
110
+ def search(self, query_embedding: list[float], top_k: int = 5) -> list[MemoryItem]:
111
+ res = self._collection.query(
112
+ query_embeddings=[query_embedding],
113
+ n_results=top_k,
114
+ )
115
+ out: list[MemoryItem] = []
116
+ ids = (res.get("ids") or [[]])[0]
117
+ docs = (res.get("documents") or [[]])[0]
118
+ metas = (res.get("metadatas") or [[]])[0]
119
+ for i, _id in enumerate(ids):
120
+ meta = metas[i] if i < len(metas) else {}
121
+ out.append(
122
+ MemoryItem(
123
+ id=_id,
124
+ text=docs[i] if i < len(docs) else "",
125
+ importance=float(meta.get("importance", 0.5)),
126
+ kind=str(meta.get("kind", "fact")),
127
+ )
128
+ )
129
+ return out
130
+
131
+ def delete(self, ids: Iterable[str]) -> int:
132
+ ids_list = list(ids)
133
+ if not ids_list:
134
+ return 0
135
+ self._collection.delete(ids=ids_list)
136
+ return len(ids_list)
137
+
138
+ def count(self) -> int:
139
+ return int(self._collection.count())
File without changes
@@ -0,0 +1,68 @@
1
+ """Shared helpers for CLI commands.
2
+
3
+ Kept in a single tiny module so the per-command files can stay
4
+ focused on dispatch logic instead of repeating flag parsing and
5
+ default paths.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from ..backends.embedding import HashingEmbedder
15
+ from ..engine.loop import LoopEngine
16
+ from ..llm.base import EchoLLM
17
+
18
+ DEFAULT_DB = os.environ.get(
19
+ "LOOP_MEMORY_DB",
20
+ str(Path.home() / ".loop_memory" / "loop_memory.db"),
21
+ )
22
+
23
+
24
+ def default_db_path() -> str:
25
+ """Resolve the DB path at call time (not import time).
26
+
27
+ Use this instead of the module-level ``DEFAULT_DB`` whenever the
28
+ caller may run after the test suite (or any embedded host) has
29
+ changed ``$LOOP_MEMORY_DB``.
30
+ """
31
+ return os.environ.get(
32
+ "LOOP_MEMORY_DB",
33
+ str(Path.home() / ".loop_memory" / "loop_memory.db"),
34
+ )
35
+
36
+
37
+
38
+ def make_engine(db_path: str | None = None) -> LoopEngine:
39
+ """Build a LoopEngine with the default echo LLM + hashing embedder.
40
+
41
+ Used by ``chat`` and any other command that drives the engine
42
+ end-to-end without an external LLM.
43
+ """
44
+ return LoopEngine(llm=EchoLLM(), embedder=HashingEmbedder(dim=128))
45
+
46
+
47
+ def parse_int_flag(args: list, name: str, default: int) -> tuple[int, list]:
48
+ """Pop ``--name <int>`` from ``args`` if present.
49
+
50
+ Returns the parsed integer (or ``default``) plus the remaining
51
+ args. Non-integer values fall back to ``default`` so we never
52
+ crash the whole command on a typo.
53
+ """
54
+ if name in args:
55
+ i = args.index(name)
56
+ if i + 1 < len(args):
57
+ try:
58
+ v = int(args[i + 1])
59
+ return v, args[:i] + args[i + 2:]
60
+ except ValueError:
61
+ pass
62
+ return default, list(args)
63
+
64
+
65
+ def die(msg: str, code: int = 2) -> int:
66
+ """Print ``msg`` to stderr and return ``code``."""
67
+ print(msg, file=sys.stderr)
68
+ return code
@@ -0,0 +1,13 @@
1
+ """CLI command implementations.
2
+
3
+ Each module in this package exports a small ``run(args) -> int``
4
+ function. ``cli.main`` is responsible only for dispatch.
5
+
6
+ Modules are grouped by concern:
7
+
8
+ * ``read`` — read-only commands (chat, stats, recall, ask, export, inject)
9
+ * ``write`` — mutation commands (ingest, flush, consolidate, rescore)
10
+ * ``serve`` — server / hook / mcp commands
11
+ * ``hooks`` — install-hooks auto-config
12
+ * ``graph`` — knowledge graph commands
13
+ """
@@ -0,0 +1,205 @@
1
+ """CLI commands for the Universal Agent Memory v7 surface.
2
+
3
+ * ``loop-memory cognitive-sleep`` — run a sweep, optionally apply.
4
+ * ``loop-memory audit`` — read the audit trail.
5
+ * ``loop-memory export`` — write a MEMORY.md bundle.
6
+ * ``loop-memory import`` — re-hydrate a bundle.
7
+ * ``loop-memory fork`` — snapshot the wiki.
8
+ * ``loop-memory graph-edge`` — push a semantic relation.
9
+ * ``loop-memory subgraph`` — print a small subgraph.
10
+
11
+ All commands accept ``--db PATH`` to point at a non-default store
12
+ (handy in tests). They print JSON so a shell pipeline can consume
13
+ the output.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ from ...storage.sqlite_store import MemoryStore
25
+ from .._common import DEFAULT_DB, default_db_path
26
+
27
+
28
+ def _build_parser() -> argparse.ArgumentParser:
29
+ p = argparse.ArgumentParser(prog="loop-memory (v7)", add_help=True)
30
+ p.add_argument("--db", default=DEFAULT_DB,
31
+ help=f"Path to the SQLite store (default {DEFAULT_DB})")
32
+ sub = p.add_subparsers(dest="cmd", required=True)
33
+
34
+ cs = sub.add_parser("cognitive-sleep", help="Run a cognitive sweep")
35
+ cs.add_argument("--apply", action="store_true",
36
+ help="Actually delete the suggested memories")
37
+ cs.add_argument("--stale-days", type=int, default=90)
38
+ cs.add_argument("--min-score", type=float, default=0.2)
39
+ cs.add_argument("--min-importance", type=float, default=0.3)
40
+ cs.add_argument("--low-value", type=float, default=0.3)
41
+ cs.add_argument("--merge-threshold", type=float, default=0.92)
42
+ cs.add_argument("--limit", type=int, default=1000)
43
+
44
+ au = sub.add_parser("audit", help="Read the cognitive audit trail")
45
+ au.add_argument("--kind", default=None)
46
+ au.add_argument("--action", default=None)
47
+ au.add_argument("--limit", type=int, default=200)
48
+
49
+ ex = sub.add_parser("export", help="Write a MEMORY.md bundle")
50
+ ex.add_argument("out_dir")
51
+ ex.add_argument("--agent-id", default=None)
52
+ ex.add_argument("--user-id", default=None)
53
+ ex.add_argument("--scope", default="global")
54
+ ex.add_argument("--min-importance", type=float, default=0.0)
55
+
56
+ im = sub.add_parser("import", help="Re-hydrate a MEMORY.md bundle")
57
+ im.add_argument("in_dir")
58
+ im.add_argument("--agent-id", default=None)
59
+ im.add_argument("--user-id", default=None)
60
+ im.add_argument("--dry-run", action="store_true")
61
+
62
+ fk = sub.add_parser("fork", help="Snapshot every wiki page")
63
+ fk.add_argument("--branch-tag", default=None)
64
+
65
+ ge = sub.add_parser("graph-edge", help="Push a semantic relation")
66
+ ge.add_argument("src")
67
+ ge.add_argument("dst")
68
+ ge.add_argument("--kind", default="relates_to")
69
+ ge.add_argument("--weight", type=float, default=0.5)
70
+ ge.add_argument("--evidence-id", default=None)
71
+
72
+ sg = sub.add_parser("subgraph", help="Print a small subgraph")
73
+ sg.add_argument("query")
74
+ sg.add_argument("--max-nodes", type=int, default=32)
75
+ sg.add_argument("--max-edges", type=int, default=64)
76
+
77
+ sub.add_parser("graph-rebuild", help="Rebuild the entity graph")
78
+
79
+ rc = sub.add_parser(
80
+ "wiki-reclassify-legacy",
81
+ help=("Re-classify legacy wiki pages whose auto_classification is NULL; "
82
+ "downgrade non-universal-security pages to per-source scope."))
83
+ rc.add_argument("--batch", type=int, default=500,
84
+ help="Maximum rows to scan (default 500)")
85
+ rc.add_argument("--dry-run", action="store_true",
86
+ help="Report what would change without persisting")
87
+
88
+ return p
89
+
90
+
91
+ def _emit(d) -> int:
92
+ print(json.dumps(d, ensure_ascii=False, indent=2, default=str))
93
+ return 0
94
+
95
+
96
+ def run_cognitive_sleep(args: list) -> int:
97
+ p = _build_parser()
98
+ ns = p.parse_args(["--db", os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB),
99
+ "cognitive-sleep", *args])
100
+ s = MemoryStore(ns.db)
101
+ from ...jobs.cognitive import cognitive_sleep
102
+ rpt = cognitive_sleep(
103
+ s, apply=ns.apply,
104
+ stale_days=ns.stale_days, min_score=ns.min_score,
105
+ min_importance=ns.min_importance, low_value=ns.low_value,
106
+ merge_threshold=ns.merge_threshold, limit=ns.limit,
107
+ )
108
+ return _emit(rpt.to_dict())
109
+
110
+
111
+ def run_audit(args: list) -> int:
112
+ p = _build_parser()
113
+ db = os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB)
114
+ ns = p.parse_args(["--db", db, "audit", *args])
115
+ s = MemoryStore(ns.db)
116
+ return _emit({"rows": s.list_audit(kind=ns.kind, action=ns.action, limit=ns.limit)})
117
+
118
+
119
+ def run_export(args: list) -> int:
120
+ p = _build_parser()
121
+ db = os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB)
122
+ ns = p.parse_args(["--db", db, "export", *args])
123
+ s = MemoryStore(ns.db)
124
+ from ...export import export_bundle
125
+ r = export_bundle(s, ns.out_dir, agent_id=ns.agent_id, user_id=ns.user_id,
126
+ scope=ns.scope, min_importance=ns.min_importance)
127
+ return _emit(r.to_dict())
128
+
129
+
130
+ def run_import(args: list) -> int:
131
+ p = _build_parser()
132
+ db = os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB)
133
+ ns = p.parse_args(["--db", db, "import", *args])
134
+ s = MemoryStore(ns.db)
135
+ from ...export import import_bundle
136
+ r = import_bundle(s, ns.in_dir, agent_id=ns.agent_id, user_id=ns.user_id,
137
+ dry_run=ns.dry_run)
138
+ return _emit(r.to_dict())
139
+
140
+
141
+ def run_fork(args: list) -> int:
142
+ p = _build_parser()
143
+ db = os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB)
144
+ ns = p.parse_args(["--db", db, "fork", *args])
145
+ s = MemoryStore(ns.db)
146
+ from ...export import fork_snapshot
147
+ return _emit(fork_snapshot(s, branch_tag=ns.branch_tag))
148
+
149
+
150
+ def run_graph_edge(args: list) -> int:
151
+ p = _build_parser()
152
+ db = os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB)
153
+ ns = p.parse_args(["--db", db, "graph-edge", *args])
154
+ s = MemoryStore(ns.db)
155
+ from ...jobs.graph import upsert_semantic_edge
156
+ info = upsert_semantic_edge(
157
+ s, ns.src, ns.dst, kind=ns.kind, weight=ns.weight,
158
+ evidence_id=ns.evidence_id,
159
+ )
160
+ return _emit(info)
161
+
162
+
163
+ def run_subgraph(args: list) -> int:
164
+ p = _build_parser()
165
+ db = os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB)
166
+ ns = p.parse_args(["--db", db, "subgraph", *args])
167
+ s = MemoryStore(ns.db)
168
+ from ...jobs.graph import subgraph_for
169
+ sg = subgraph_for(s, ns.query, max_nodes=ns.max_nodes, max_edges=ns.max_edges)
170
+ return _emit(sg.to_dict())
171
+
172
+
173
+ def run_graph_rebuild(args: list) -> int:
174
+ p = _build_parser()
175
+ db = os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB)
176
+ ns = p.parse_args(["--db", db, "graph-rebuild", *args])
177
+ s = MemoryStore(ns.db)
178
+ from ...graph.build import KnowledgeGraph
179
+ KnowledgeGraph(s).rebuild(clear=True)
180
+ n = s.rebuild_entity_mentions()
181
+ return _emit({"entity_mentions": n})
182
+
183
+
184
+ def run_wiki_reclassify_legacy(args: list) -> int:
185
+ p = _build_parser()
186
+ db = os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB)
187
+ ns = p.parse_args(["--db", db, "wiki-reclassify-legacy", *args])
188
+ s = MemoryStore(ns.db)
189
+ from ...wiki import reclassify_legacy_pages
190
+ if ns.dry_run:
191
+ pages = s.list_wiki_pages(limit=ns.batch)
192
+ legacy = [
193
+ p for p in pages
194
+ if (p.get("scope") or "global") == "global"
195
+ and p.get("auto_classification") is None
196
+ ]
197
+ return _emit({
198
+ "dry_run": True,
199
+ "scanned": len(pages),
200
+ "legacy_global": len(legacy),
201
+ "items": [{"slug": p["slug"], "title": p.get("title")}
202
+ for p in legacy],
203
+ })
204
+ summary = reclassify_legacy_pages(s, batch=ns.batch)
205
+ return _emit(summary)