lorien-memory 0.2.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.
- lorien/__init__.py +17 -0
- lorien/cli.py +203 -0
- lorien/contradiction.py +179 -0
- lorien/ingest.py +533 -0
- lorien/memory.py +353 -0
- lorien/models.py +72 -0
- lorien/query.py +184 -0
- lorien/schema.py +216 -0
- lorien/serve.py +247 -0
- lorien/vectors.py +178 -0
- lorien_memory-0.2.0.dist-info/METADATA +234 -0
- lorien_memory-0.2.0.dist-info/RECORD +15 -0
- lorien_memory-0.2.0.dist-info/WHEEL +4 -0
- lorien_memory-0.2.0.dist-info/entry_points.txt +2 -0
- lorien_memory-0.2.0.dist-info/licenses/LICENSE +21 -0
lorien/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .contradiction import ContradictionDetector
|
|
2
|
+
from .ingest import LorienIngester
|
|
3
|
+
from .memory import LorienMemory
|
|
4
|
+
from .models import Entity, Fact, Rule
|
|
5
|
+
from .query import KnowledgeGraph
|
|
6
|
+
from .schema import GraphStore
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ContradictionDetector",
|
|
10
|
+
"Entity",
|
|
11
|
+
"Fact",
|
|
12
|
+
"Rule",
|
|
13
|
+
"GraphStore",
|
|
14
|
+
"KnowledgeGraph",
|
|
15
|
+
"LorienIngester",
|
|
16
|
+
"LorienMemory",
|
|
17
|
+
]
|
lorien/cli.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from .query import KnowledgeGraph
|
|
9
|
+
from .schema import GraphStore
|
|
10
|
+
|
|
11
|
+
DEFAULT_DB = "~/.lorien/db"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group()
|
|
15
|
+
def main() -> None:
|
|
16
|
+
"""lorien — local-first personal knowledge graph for AI agents."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@main.command()
|
|
20
|
+
@click.option("--db", default=DEFAULT_DB, show_default=True)
|
|
21
|
+
def init(db: str) -> None:
|
|
22
|
+
"""Initialize a new lorien graph store."""
|
|
23
|
+
store = GraphStore(db_path=db)
|
|
24
|
+
counts = store.count_nodes()
|
|
25
|
+
click.echo(f"✓ lorien initialized at {Path(db).expanduser()}")
|
|
26
|
+
click.echo(f" {counts}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@main.command()
|
|
30
|
+
@click.option("--db", default=DEFAULT_DB, show_default=True)
|
|
31
|
+
def status(db: str) -> None:
|
|
32
|
+
"""Show node counts."""
|
|
33
|
+
store = GraphStore(db_path=db)
|
|
34
|
+
for name, count in store.count_nodes().items():
|
|
35
|
+
click.echo(f" {name}: {count}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@main.command()
|
|
39
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
40
|
+
@click.option("--db", default=DEFAULT_DB, show_default=True)
|
|
41
|
+
@click.option("--model", default=None, help="LLM model e.g. claude-haiku-3-5 (enables LLM extraction)")
|
|
42
|
+
@click.option("--api-key", default=None, envvar=["ANTHROPIC_API_KEY", "LORIEN_API_KEY"],
|
|
43
|
+
help="API key (reads ANTHROPIC_API_KEY or LORIEN_API_KEY from env)")
|
|
44
|
+
@click.option("--base-url", default=None, envvar="LORIEN_LLM_BASE_URL")
|
|
45
|
+
@click.option("--verbose", "-v", is_flag=True, default=False)
|
|
46
|
+
@click.option("--batch", default=1, show_default=True,
|
|
47
|
+
help="Sections per LLM call (>1 reduces API calls, use 3-5)")
|
|
48
|
+
def ingest(
|
|
49
|
+
file: str, db: str, model: str | None, api_key: str | None,
|
|
50
|
+
base_url: str | None, verbose: bool, batch: int
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Ingest a text or MEMORY.md file.
|
|
53
|
+
|
|
54
|
+
With --model: uses LLM for rich entity extraction.
|
|
55
|
+
Without --model: keyword fallback (rules only).
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
lorien ingest MEMORY.md --model haiku --batch 4
|
|
59
|
+
"""
|
|
60
|
+
from .ingest import LorienIngester
|
|
61
|
+
|
|
62
|
+
# Let LorienIngester auto-detect OpenClaw gateway; only fail if explicitly needed
|
|
63
|
+
if model and not api_key:
|
|
64
|
+
from .ingest import _read_openclaw_gateway
|
|
65
|
+
if not _read_openclaw_gateway():
|
|
66
|
+
click.echo("⚠ --model set but no API key found (set ANTHROPIC_API_KEY or configure OpenClaw gateway)", err=True)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
if verbose:
|
|
69
|
+
click.echo("→ Using OpenClaw gateway")
|
|
70
|
+
|
|
71
|
+
store = GraphStore(db_path=db)
|
|
72
|
+
ingester = LorienIngester(store, llm_model=model, api_key=api_key, base_url=base_url)
|
|
73
|
+
|
|
74
|
+
if verbose and model:
|
|
75
|
+
click.echo(f"→ LLM mode: {model}")
|
|
76
|
+
|
|
77
|
+
filename = Path(file).name
|
|
78
|
+
if filename.upper().startswith("MEMORY") and file.endswith(".md"):
|
|
79
|
+
result = ingester.ingest_memory_md(file, verbose=verbose, batch_size=batch)
|
|
80
|
+
else:
|
|
81
|
+
text = Path(file).read_text(encoding="utf-8")
|
|
82
|
+
result = ingester.ingest_text(text, source=file)
|
|
83
|
+
|
|
84
|
+
click.echo(
|
|
85
|
+
f"✓ {file}: +{result.entities_added} entities, +{result.facts_added} facts, +{result.rules_added} rules"
|
|
86
|
+
)
|
|
87
|
+
if result.errors:
|
|
88
|
+
for error in result.errors[:5]:
|
|
89
|
+
click.echo(f" ⚠ {error}", err=True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@main.command()
|
|
93
|
+
@click.argument("cypher")
|
|
94
|
+
@click.option("--db", default=DEFAULT_DB, show_default=True)
|
|
95
|
+
def query(cypher: str, db: str) -> None:
|
|
96
|
+
"""Run raw Cypher query."""
|
|
97
|
+
store = GraphStore(db_path=db)
|
|
98
|
+
for row in store.query(cypher):
|
|
99
|
+
click.echo(row)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@main.command()
|
|
103
|
+
@click.argument("entity_name")
|
|
104
|
+
@click.option("--db", default=DEFAULT_DB, show_default=True)
|
|
105
|
+
def show(entity_name: str, db: str) -> None:
|
|
106
|
+
"""Show all context for an entity."""
|
|
107
|
+
store = GraphStore(db_path=db)
|
|
108
|
+
graph = KnowledgeGraph(store)
|
|
109
|
+
entity = graph.get_entity(entity_name)
|
|
110
|
+
if not entity:
|
|
111
|
+
click.echo(f"Not found: {entity_name}", err=True)
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
context = graph.get_entity_context(entity["id"])
|
|
114
|
+
click.echo(f"\n{entity['name']} ({entity['entity_type']})")
|
|
115
|
+
click.echo("─" * 40)
|
|
116
|
+
for fact in context["facts"]:
|
|
117
|
+
click.echo(f" • {fact['text']} [{fact['confidence']:.2f}]")
|
|
118
|
+
for rule in context["rules"]:
|
|
119
|
+
click.echo(f" ★ [{rule['rule_type']}] {rule['text']}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@main.command()
|
|
123
|
+
@click.option("--to-md", required=True, type=click.Path())
|
|
124
|
+
@click.option("--entity", default=None)
|
|
125
|
+
@click.option("--db", default=DEFAULT_DB, show_default=True)
|
|
126
|
+
def sync(to_md: str, entity: str | None, db: str) -> None:
|
|
127
|
+
"""Export graph to MEMORY.md-style file."""
|
|
128
|
+
store = GraphStore(db_path=db)
|
|
129
|
+
graph = KnowledgeGraph(store)
|
|
130
|
+
markdown = graph.export_to_memory_md(entity_name=entity)
|
|
131
|
+
Path(to_md).write_text(markdown, encoding="utf-8")
|
|
132
|
+
click.echo(f"✓ Exported to {to_md}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@main.command()
|
|
136
|
+
@click.argument("user_id")
|
|
137
|
+
@click.option("--db", default=DEFAULT_DB, show_default=True)
|
|
138
|
+
@click.option("--model", default=None, help="LLM model for extraction")
|
|
139
|
+
@click.option("--api-key", default=None, envvar=["ANTHROPIC_API_KEY", "LORIEN_API_KEY"])
|
|
140
|
+
@click.option("--limit", default=20, show_default=True)
|
|
141
|
+
def memory(user_id: str, db: str, model: str | None, api_key: str | None, limit: int) -> None:
|
|
142
|
+
"""Show all memories for USER_ID, or pipe a conversation for real-time ingestion.
|
|
143
|
+
|
|
144
|
+
Show memories:
|
|
145
|
+
lorien memory 아부지
|
|
146
|
+
|
|
147
|
+
Add from stdin (JSON messages):
|
|
148
|
+
echo '[{"role":"user","content":"나는 커피를 싫어해"}]' | lorien memory 아부지 --model haiku
|
|
149
|
+
"""
|
|
150
|
+
import select
|
|
151
|
+
|
|
152
|
+
from .memory import LorienMemory
|
|
153
|
+
|
|
154
|
+
mem = LorienMemory(db_path=db, model=model, api_key=api_key)
|
|
155
|
+
|
|
156
|
+
# Check if stdin has data (piped input)
|
|
157
|
+
if select.select([sys.stdin], [], [], 0.0)[0]:
|
|
158
|
+
import json as _json
|
|
159
|
+
raw = sys.stdin.read().strip()
|
|
160
|
+
try:
|
|
161
|
+
messages = _json.loads(raw)
|
|
162
|
+
except Exception:
|
|
163
|
+
click.echo("⚠ stdin must be JSON array of {role, content} objects", err=True)
|
|
164
|
+
sys.exit(1)
|
|
165
|
+
result = mem.add(messages, user_id=user_id)
|
|
166
|
+
click.echo(f"✓ +{result['entities']} entities, +{result['facts']} facts, +{result['rules']} rules")
|
|
167
|
+
else:
|
|
168
|
+
# Show all memories
|
|
169
|
+
memories = mem.get_all(user_id=user_id, limit=limit)
|
|
170
|
+
if not memories:
|
|
171
|
+
click.echo(f"No memories for {user_id}")
|
|
172
|
+
return
|
|
173
|
+
click.echo(f"\n{user_id} — {len(memories)} memories")
|
|
174
|
+
click.echo("─" * 40)
|
|
175
|
+
for m in memories:
|
|
176
|
+
prefix = "★" if m["type"] == "rule" else "•"
|
|
177
|
+
extra = f" [p{m.get('priority', '')}]" if m["type"] == "rule" else f" [{m['score']:.2f}]"
|
|
178
|
+
click.echo(f" {prefix} {m['memory']}{extra}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@main.command()
|
|
182
|
+
@click.option("--db", default=DEFAULT_DB, show_default=True)
|
|
183
|
+
@click.option("--port", default=7331, show_default=True)
|
|
184
|
+
def serve(db: str, port: int) -> None:
|
|
185
|
+
"""Launch local web graph viewer at http://127.0.0.1:PORT."""
|
|
186
|
+
from .serve import serve as _serve
|
|
187
|
+
_serve(db_path=db, port=port)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@main.command()
|
|
191
|
+
@click.option("--db", default=DEFAULT_DB, show_default=True)
|
|
192
|
+
def contradictions(db: str) -> None:
|
|
193
|
+
"""List all detected contradictions."""
|
|
194
|
+
store = GraphStore(db_path=db)
|
|
195
|
+
graph = KnowledgeGraph(store)
|
|
196
|
+
items = graph.find_contradictions()
|
|
197
|
+
if not items:
|
|
198
|
+
click.echo("✓ No contradictions.")
|
|
199
|
+
return
|
|
200
|
+
click.echo(f"⚠️ {len(items)} contradiction(s):")
|
|
201
|
+
for item in items:
|
|
202
|
+
click.echo(f"\n A: {item['fact_a']['text']}")
|
|
203
|
+
click.echo(f" B: {item['fact_b']['text']}")
|
lorien/contradiction.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""ContradictionDetector — automatic contradiction detection using vector similarity + LLM."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from .schema import GraphStore
|
|
10
|
+
|
|
11
|
+
CONTRADICTION_PROMPT = """Do these two statements DIRECTLY CONTRADICT each other?
|
|
12
|
+
Answer ONLY 'yes' or 'no'.
|
|
13
|
+
|
|
14
|
+
Statement A: {a}
|
|
15
|
+
Statement B: {b}"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ContradictionDetector:
|
|
19
|
+
"""Detects contradictions between new facts/rules and existing ones.
|
|
20
|
+
|
|
21
|
+
Uses vector similarity to find candidates, then LLM to confirm.
|
|
22
|
+
Falls back to heuristic-only (negation patterns) if no LLM configured.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# Negation pairs — offline heuristic
|
|
26
|
+
NEGATION_PAIRS = [
|
|
27
|
+
("좋아", "싫어"),
|
|
28
|
+
("좋아해", "싫어해"),
|
|
29
|
+
("좋다", "싫다"),
|
|
30
|
+
("허용", "금지"),
|
|
31
|
+
("허용한다", "금지한다"),
|
|
32
|
+
("가능", "불가능"),
|
|
33
|
+
("해야", "하지 말"),
|
|
34
|
+
("반드시", "절대"),
|
|
35
|
+
("always", "never"),
|
|
36
|
+
("must", "must not"),
|
|
37
|
+
("allow", "prohibit"),
|
|
38
|
+
("enable", "disable"),
|
|
39
|
+
("할 수 있다", "할 수 없다"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
store: GraphStore,
|
|
45
|
+
vector_index=None, # VectorIndex | None
|
|
46
|
+
llm_model: str | None = None,
|
|
47
|
+
api_key: str | None = None,
|
|
48
|
+
base_url: str | None = None,
|
|
49
|
+
use_openclaw: bool = False,
|
|
50
|
+
similarity_threshold: float = 0.55,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.store = store
|
|
53
|
+
self.vectors = vector_index
|
|
54
|
+
self.llm_model = llm_model
|
|
55
|
+
self.api_key = api_key
|
|
56
|
+
self.base_url = base_url or "https://api.openai.com/v1"
|
|
57
|
+
self._use_openclaw = use_openclaw
|
|
58
|
+
self.threshold = similarity_threshold
|
|
59
|
+
|
|
60
|
+
def check_and_record(self, new_node_id: str, new_text: str, node_type: str = "Fact") -> int:
|
|
61
|
+
"""Check if new_text contradicts existing facts/rules. Returns number of contradictions found.
|
|
62
|
+
|
|
63
|
+
Creates CONTRADICTS edges for confirmed contradictions.
|
|
64
|
+
"""
|
|
65
|
+
if not new_text or not new_text.strip():
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
# Find candidates via vector similarity
|
|
69
|
+
candidates = []
|
|
70
|
+
if self.vectors:
|
|
71
|
+
similar = self.vectors.search(
|
|
72
|
+
new_text,
|
|
73
|
+
top_k=8,
|
|
74
|
+
node_type=node_type,
|
|
75
|
+
threshold=self.threshold,
|
|
76
|
+
exclude_ids={new_node_id},
|
|
77
|
+
)
|
|
78
|
+
candidates = similar
|
|
79
|
+
else:
|
|
80
|
+
# No vector index: heuristic only on recent facts
|
|
81
|
+
rows = self.store.query(
|
|
82
|
+
f"MATCH (n:{node_type}) WHERE n.status = 'active' AND n.id <> '{new_node_id}' "
|
|
83
|
+
f"RETURN n.id, n.text LIMIT 50"
|
|
84
|
+
)
|
|
85
|
+
for nid, text in rows:
|
|
86
|
+
if self._heuristic_contradiction(new_text, text):
|
|
87
|
+
candidates.append({"id": nid, "text": text, "score": 0.8})
|
|
88
|
+
|
|
89
|
+
found = 0
|
|
90
|
+
for candidate in candidates:
|
|
91
|
+
cid = candidate["id"]
|
|
92
|
+
ctext = candidate["text"]
|
|
93
|
+
if self._is_contradiction(new_text, ctext):
|
|
94
|
+
try:
|
|
95
|
+
if node_type == "Fact":
|
|
96
|
+
self.store.add_contradicts(new_node_id, cid)
|
|
97
|
+
# For Rules: store as Fact contradiction if both are Facts
|
|
98
|
+
found += 1
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
return found
|
|
103
|
+
|
|
104
|
+
def _is_contradiction(self, text_a: str, text_b: str) -> bool:
|
|
105
|
+
"""Check if two texts contradict — LLM if available, heuristic fallback."""
|
|
106
|
+
# Heuristic first (fast, offline)
|
|
107
|
+
if self._heuristic_contradiction(text_a, text_b):
|
|
108
|
+
return True
|
|
109
|
+
# LLM confirmation
|
|
110
|
+
if self.llm_model and self.api_key:
|
|
111
|
+
return self._llm_contradiction_check(text_a, text_b)
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def _heuristic_contradiction(self, a: str, b: str) -> bool:
|
|
115
|
+
"""Simple negation-pair heuristic."""
|
|
116
|
+
a_lower = a.lower()
|
|
117
|
+
b_lower = b.lower()
|
|
118
|
+
for pos, neg in self.NEGATION_PAIRS:
|
|
119
|
+
if pos in a_lower and neg in b_lower:
|
|
120
|
+
return True
|
|
121
|
+
if neg in a_lower and pos in b_lower:
|
|
122
|
+
return True
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
def _llm_contradiction_check(self, text_a: str, text_b: str) -> bool:
|
|
126
|
+
"""Ask LLM if two statements contradict each other."""
|
|
127
|
+
try:
|
|
128
|
+
prompt = CONTRADICTION_PROMPT.format(a=text_a, b=text_b)
|
|
129
|
+
if self._use_openclaw or not self.llm_model.startswith("claude"):
|
|
130
|
+
payload = json.dumps({
|
|
131
|
+
"model": self.llm_model,
|
|
132
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
133
|
+
"max_tokens": 5,
|
|
134
|
+
"temperature": 0.0,
|
|
135
|
+
}).encode()
|
|
136
|
+
req = urllib.request.Request(
|
|
137
|
+
f"{self.base_url}/chat/completions",
|
|
138
|
+
data=payload,
|
|
139
|
+
headers={
|
|
140
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
141
|
+
"Content-Type": "application/json",
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
payload = json.dumps({
|
|
146
|
+
"model": self.llm_model,
|
|
147
|
+
"max_tokens": 5,
|
|
148
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
149
|
+
}).encode()
|
|
150
|
+
req = urllib.request.Request(
|
|
151
|
+
"https://api.anthropic.com/v1/messages",
|
|
152
|
+
data=payload,
|
|
153
|
+
headers={
|
|
154
|
+
"x-api-key": self.api_key,
|
|
155
|
+
"anthropic-version": "2023-06-01",
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
160
|
+
raw = json.loads(resp.read())
|
|
161
|
+
if "choices" in raw:
|
|
162
|
+
answer = raw["choices"][0]["message"]["content"]
|
|
163
|
+
else:
|
|
164
|
+
answer = raw["content"][0]["text"]
|
|
165
|
+
return answer.strip().lower().startswith("yes")
|
|
166
|
+
except Exception:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def from_ingester(cls, ingester) -> "ContradictionDetector":
|
|
171
|
+
"""Create a ContradictionDetector from an existing LorienIngester."""
|
|
172
|
+
return cls(
|
|
173
|
+
store=ingester.store,
|
|
174
|
+
vector_index=ingester.vectors,
|
|
175
|
+
llm_model=ingester.llm_model,
|
|
176
|
+
api_key=ingester.api_key,
|
|
177
|
+
base_url=ingester.base_url,
|
|
178
|
+
use_openclaw=ingester._use_openclaw,
|
|
179
|
+
)
|