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
loop_memory/__init__.py
ADDED
|
@@ -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)
|