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 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']}")
@@ -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
+ )