memorytrace 0.1.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 (54) hide show
  1. engram/__init__.py +8 -0
  2. engram/__main__.py +6 -0
  3. engram/cli/__init__.py +1 -0
  4. engram/cli/app.py +291 -0
  5. engram/cli/formatters.py +90 -0
  6. engram/cli/simple.py +267 -0
  7. engram/config.py +72 -0
  8. engram/engine.py +612 -0
  9. engram/exceptions.py +41 -0
  10. engram/extraction/__init__.py +6 -0
  11. engram/extraction/base.py +20 -0
  12. engram/extraction/llm_extractor.py +197 -0
  13. engram/extraction/ner/__init__.py +7 -0
  14. engram/extraction/ner/cjk.py +63 -0
  15. engram/extraction/ner/english.py +109 -0
  16. engram/extraction/ner/korean.py +106 -0
  17. engram/extraction/regex_extractor.py +188 -0
  18. engram/integrations/__init__.py +1 -0
  19. engram/integrations/mcp_server.py +213 -0
  20. engram/integrations/sdk.py +194 -0
  21. engram/models/__init__.py +19 -0
  22. engram/models/entity.py +72 -0
  23. engram/models/fact.py +58 -0
  24. engram/models/quality.py +61 -0
  25. engram/models/relation.py +26 -0
  26. engram/models/search.py +96 -0
  27. engram/models/session.py +53 -0
  28. engram/models/source.py +73 -0
  29. engram/quality/__init__.py +8 -0
  30. engram/quality/confidence.py +38 -0
  31. engram/quality/conflict.py +79 -0
  32. engram/quality/decay.py +28 -0
  33. engram/quality/gate.py +120 -0
  34. engram/quality/pii.py +80 -0
  35. engram/search/__init__.py +13 -0
  36. engram/search/base.py +20 -0
  37. engram/search/fts5_search.py +210 -0
  38. engram/search/hybrid.py +99 -0
  39. engram/search/semantic.py +186 -0
  40. engram/search/tokenizer.py +85 -0
  41. engram/session/__init__.py +6 -0
  42. engram/session/context.py +87 -0
  43. engram/session/manager.py +152 -0
  44. engram/session/working_memory.py +57 -0
  45. engram/storage/__init__.py +6 -0
  46. engram/storage/base.py +63 -0
  47. engram/storage/markdown_export.py +144 -0
  48. engram/storage/migrations.py +30 -0
  49. engram/storage/sqlite_store.py +615 -0
  50. memorytrace-0.1.0.dist-info/METADATA +138 -0
  51. memorytrace-0.1.0.dist-info/RECORD +54 -0
  52. memorytrace-0.1.0.dist-info/WHEEL +4 -0
  53. memorytrace-0.1.0.dist-info/entry_points.txt +3 -0
  54. memorytrace-0.1.0.dist-info/licenses/LICENSE +21 -0
engram/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Engram — AI agent memory system with persistent, structured, trustworthy memory."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from engram.engine import MemoryEngine, StoreResult
6
+ from engram.config import EngramConfig
7
+
8
+ __all__ = ["MemoryEngine", "StoreResult", "EngramConfig", "__version__"]
engram/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running as `python -m engram`."""
2
+
3
+ from engram.cli.app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
engram/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CLI interface for Engram."""
engram/cli/app.py ADDED
@@ -0,0 +1,291 @@
1
+ """CLI entry point for Engram."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from engram.config import EngramConfig
12
+ from engram.engine import MemoryEngine
13
+ from engram.models.entity import Tier
14
+ from engram.models.search import SearchOptions
15
+ from engram.models.source import Source, SourceType
16
+
17
+
18
+ def _build_parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(
20
+ prog="engram",
21
+ description="Engram — AI agent memory system",
22
+ )
23
+ parser.add_argument("--db", default=None, help="Database path")
24
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
25
+ parser.add_argument("--quiet", action="store_true", help="Suppress non-essential output")
26
+ parser.add_argument("--verbose", action="store_true", help="Show detailed error info")
27
+
28
+ sub = parser.add_subparsers(dest="command")
29
+
30
+ # init
31
+ sub.add_parser("init", help="Initialize memory database")
32
+
33
+ _source_choices = ["user_input", "direct_speech", "document", "api", "web", "agent_inference"]
34
+
35
+ # store
36
+ p = sub.add_parser("store", help="Extract and store information from text")
37
+ p.add_argument("text", nargs="?", help="Text to process (or read from stdin)")
38
+ p.add_argument("--source-type", default="user_input", choices=_source_choices, help="Source type")
39
+ p.add_argument("--confidence", type=float, default=1.0, help="Source confidence")
40
+ p.add_argument("--author", default="", help="Source author")
41
+
42
+ # search
43
+ p = sub.add_parser("search", help="Search memory")
44
+ p.add_argument("query", help="Search query")
45
+ p.add_argument("--max-results", type=int, default=10)
46
+ p.add_argument("--max-tokens", type=int, default=500)
47
+ p.add_argument("--min-confidence", type=float, default=0.0)
48
+ p.add_argument("--type", dest="entity_type", default="")
49
+ p.add_argument("--tier", default="")
50
+
51
+ # get
52
+ p = sub.add_parser("get", help="Get entity details")
53
+ p.add_argument("name", help="Entity name")
54
+
55
+ # list
56
+ p = sub.add_parser("list", help="List entities")
57
+ p.add_argument("--type", dest="entity_type", default="")
58
+ p.add_argument("--tier", default="")
59
+ p.add_argument("--limit", type=int, default=50)
60
+
61
+ # add-fact
62
+ p = sub.add_parser("add-fact", help="Add a fact to an entity")
63
+ p.add_argument("entity", help="Entity name")
64
+ p.add_argument("fact", help="Fact text")
65
+ p.add_argument("--predicate", default="attribute")
66
+ p.add_argument("--source-type", default="user_input", choices=_source_choices)
67
+ p.add_argument("--confidence", type=float, default=1.0)
68
+
69
+ # create
70
+ p = sub.add_parser("create", help="Create a new entity")
71
+ p.add_argument("name", help="Entity name")
72
+ p.add_argument("--type", dest="entity_type", default="person")
73
+ p.add_argument("--tier", default="recall")
74
+ p.add_argument("--summary", default="")
75
+
76
+ # conflicts
77
+ sub.add_parser("conflicts", help="Show pending conflicts")
78
+
79
+ # resolve
80
+ p = sub.add_parser("resolve", help="Resolve a conflict")
81
+ p.add_argument("conflict_id", help="Conflict ID")
82
+ p.add_argument("resolution", choices=["accept_new", "keep_old", "merge"])
83
+
84
+ # health
85
+ sub.add_parser("health", help="Run health checks")
86
+
87
+ # export
88
+ p = sub.add_parser("export", help="Export to Markdown")
89
+ p.add_argument("--dir", default=None, help="Export directory")
90
+
91
+ # reindex
92
+ sub.add_parser("reindex", help="Rebuild search index")
93
+
94
+ # serve
95
+ p = sub.add_parser("serve", help="Start MCP server")
96
+ p.add_argument("--transport", default="stdio", choices=["stdio", "sse"])
97
+ p.add_argument("--port", type=int, default=8080)
98
+
99
+ return parser
100
+
101
+
102
+ def _make_engine(args) -> MemoryEngine:
103
+ """Create engine from CLI args."""
104
+ config = EngramConfig()
105
+ if args.db:
106
+ db_path = Path(args.db).expanduser().resolve()
107
+ config.base_dir = db_path.parent
108
+ config.db_name = db_path.name
109
+ return MemoryEngine(config)
110
+
111
+
112
+ def main(argv: Optional[list[str]] = None) -> int:
113
+ """Main CLI entry point. Returns exit code."""
114
+ parser = _build_parser()
115
+ args = parser.parse_args(argv)
116
+
117
+ if not args.command:
118
+ parser.print_help()
119
+ return 0
120
+
121
+ from engram.cli.formatters import (
122
+ format_entity,
123
+ format_entity_list,
124
+ format_search_result,
125
+ format_store_result,
126
+ format_health,
127
+ )
128
+
129
+ engine = None
130
+ try:
131
+ engine = _make_engine(args)
132
+
133
+ if args.command == "init":
134
+ if not args.quiet:
135
+ print(f"Memory initialized at {engine.config.db_path}")
136
+ return 0
137
+
138
+ elif args.command == "store":
139
+ max_input = 1024 * 1024 # 1MB limit for stdin
140
+ text = args.text or sys.stdin.read(max_input)
141
+ source = Source(
142
+ type=SourceType(args.source_type),
143
+ confidence=args.confidence,
144
+ author=args.author,
145
+ channel="cli",
146
+ )
147
+ result = engine.store(text, source=source)
148
+ if args.json:
149
+ print(result.to_json())
150
+ else:
151
+ print(format_store_result(result))
152
+
153
+ elif args.command == "search":
154
+ options = SearchOptions(
155
+ query=args.query,
156
+ max_results=args.max_results,
157
+ max_tokens=args.max_tokens,
158
+ min_confidence=args.min_confidence,
159
+ )
160
+ if args.entity_type:
161
+ options.entity_types = [args.entity_type]
162
+ if args.tier:
163
+ options.tiers = [args.tier]
164
+ result = engine.search(args.query, options)
165
+ if args.json:
166
+ print(result.to_json())
167
+ else:
168
+ print(format_search_result(result))
169
+
170
+ elif args.command == "get":
171
+ entity = engine.get_entity(args.name)
172
+ if entity:
173
+ facts = engine.get_facts(args.name)
174
+ if args.json:
175
+ print(json.dumps({
176
+ "name": entity.name,
177
+ "type": entity.entity_type,
178
+ "tier": entity.tier.value,
179
+ "summary": entity.summary,
180
+ "facts": [{"text": f.raw_text, "confidence": f.confidence} for f in facts],
181
+ }, ensure_ascii=False, indent=2))
182
+ else:
183
+ print(format_entity(entity, facts))
184
+ else:
185
+ print(f" Entity '{args.name}' not found.", file=sys.stderr)
186
+ engine.close()
187
+ return 1
188
+
189
+ elif args.command == "list":
190
+ tier = Tier(args.tier) if args.tier else None
191
+ entities = engine.list_entities(
192
+ tier=tier,
193
+ entity_type=args.entity_type or None,
194
+ limit=args.limit,
195
+ )
196
+ if args.json:
197
+ print(json.dumps([
198
+ {"name": e.name, "type": e.entity_type, "tier": e.tier.value}
199
+ for e in entities
200
+ ], ensure_ascii=False, indent=2))
201
+ else:
202
+ print(format_entity_list(entities))
203
+
204
+ elif args.command == "add-fact":
205
+ source = Source(
206
+ type=SourceType(args.source_type),
207
+ confidence=args.confidence,
208
+ channel="cli",
209
+ )
210
+ result = engine.add_fact(args.entity, args.fact, predicate=args.predicate, source=source)
211
+ if args.json:
212
+ print(json.dumps({"action": result.action.value, "confidence": result.confidence}, indent=2))
213
+ else:
214
+ print(f" Result: {result.action.value} (confidence: {result.confidence:.2f})")
215
+
216
+ elif args.command == "create":
217
+ tier = Tier(args.tier)
218
+ entity = engine.create_entity(args.name, entity_type=args.entity_type, tier=tier, summary=args.summary)
219
+ if args.json:
220
+ print(json.dumps({"id": entity.id, "name": entity.name}, ensure_ascii=False, indent=2))
221
+ else:
222
+ print(f" Created: {entity.name} ({entity.entity_type}, {entity.tier.value})")
223
+
224
+ elif args.command == "conflicts":
225
+ conflicts = engine.storage.get_pending_conflicts()
226
+ if args.json:
227
+ print(json.dumps(conflicts, ensure_ascii=False, indent=2))
228
+ else:
229
+ if not conflicts:
230
+ print(" No pending conflicts.")
231
+ else:
232
+ for c in conflicts:
233
+ print(f" [{c['id'][:8]}] {c['conflict_type']} — {c.get('suggested_resolution', 'N/A')}")
234
+
235
+ elif args.command == "resolve":
236
+ engine.resolve_conflict(args.conflict_id, args.resolution)
237
+ print(f" Conflict {args.conflict_id[:8]} resolved: {args.resolution}")
238
+
239
+ elif args.command == "health":
240
+ health = engine.health_check()
241
+ if args.json:
242
+ print(json.dumps(health, indent=2))
243
+ else:
244
+ print(format_health(health))
245
+
246
+ elif args.command == "export":
247
+ if args.dir:
248
+ engine.config.export_dir_name = args.dir
249
+ count = engine.export_markdown()
250
+ print(f" Exported {count} entities to {engine.config.export_dir}")
251
+
252
+ elif args.command == "reindex":
253
+ count = engine.reindex()
254
+ print(f" Reindexed {count} entities.")
255
+
256
+ elif args.command == "serve":
257
+ _run_mcp_server(args)
258
+ return 0
259
+
260
+ return 0
261
+
262
+ except KeyboardInterrupt:
263
+ return 130
264
+ except Exception as e:
265
+ print(f"Error: {e}", file=sys.stderr)
266
+ if hasattr(args, "verbose") and args.verbose:
267
+ import traceback
268
+ traceback.print_exc()
269
+ return 1
270
+ finally:
271
+ if engine is not None:
272
+ engine.close()
273
+
274
+
275
+ def _run_mcp_server(args) -> None:
276
+ """Start MCP server (requires mcp package)."""
277
+ try:
278
+ from engram.integrations.mcp_server import run_server
279
+ run_server(transport=args.transport, port=args.port)
280
+ except ImportError:
281
+ print("MCP server requires 'mcp' package. Install with: pip install engram[mcp]", file=sys.stderr)
282
+ sys.exit(1)
283
+
284
+
285
+ def cli_entry() -> None:
286
+ """Entry point for setuptools console_scripts (expects no return value)."""
287
+ sys.exit(main())
288
+
289
+
290
+ if __name__ == "__main__":
291
+ cli_entry()
@@ -0,0 +1,90 @@
1
+ """Human-readable output formatters for CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from engram.models.entity import Entity
6
+ from engram.models.fact import Fact
7
+ from engram.models.search import SearchResult
8
+ from engram.engine import StoreResult
9
+
10
+
11
+ def format_entity(entity: Entity, facts: list[Fact] = None) -> str:
12
+ """Format an entity for CLI display."""
13
+ lines = [f" {entity.name} [{entity.entity_type}] (tier: {entity.tier.value})"]
14
+ if entity.state.role:
15
+ lines.append(f" Role: {entity.state.role}")
16
+ if entity.state.affiliation:
17
+ lines.append(f" Affiliation: {entity.state.affiliation}")
18
+ if entity.summary:
19
+ lines.append(f" Summary: {entity.summary}")
20
+ if facts:
21
+ lines.append(f" Facts: {len(facts)}")
22
+ for f in facts[:5]:
23
+ conf = f"[{f.confidence:.0%}]"
24
+ lines.append(f" - {f.raw_text} {conf}")
25
+ return "\n".join(lines)
26
+
27
+
28
+ def format_entity_list(entities: list[Entity]) -> str:
29
+ """Format a list of entities for CLI display."""
30
+ if not entities:
31
+ return " No entities found."
32
+ lines = []
33
+ for e in entities:
34
+ tier_tag = f"[{e.tier.value}]"
35
+ type_tag = f"({e.entity_type})"
36
+ lines.append(f" {e.name} {type_tag} {tier_tag}")
37
+ return "\n".join(lines)
38
+
39
+
40
+ def format_search_result(result: SearchResult) -> str:
41
+ """Format search results for CLI display."""
42
+ if not result.hits:
43
+ return f" No results for '{result.query}'."
44
+
45
+ lines = [f" Found {result.total_count} result(s) for '{result.query}' ({result.search_time_ms:.1f}ms)"]
46
+ lines.append("")
47
+ for i, hit in enumerate(result.hits, 1):
48
+ score = f"[{hit.relevance_score:.2f}]"
49
+ lines.append(f" {i}. {hit.entity.name} ({hit.entity.entity_type}) {score}")
50
+ if hit.snippet:
51
+ lines.append(f" {hit.snippet[:120]}")
52
+ if hit.facts:
53
+ for f in hit.facts[:3]:
54
+ lines.append(f" - {f.raw_text}")
55
+ lines.append("")
56
+ return "\n".join(lines)
57
+
58
+
59
+ def format_store_result(result: StoreResult) -> str:
60
+ """Format store operation result for CLI display."""
61
+ parts = []
62
+ if result.entities_created:
63
+ names = ", ".join(e.name for e in result.entities_created)
64
+ parts.append(f" Created {len(result.entities_created)} entity(s): {names}")
65
+ if result.entities_updated:
66
+ names = ", ".join(e.name for e in result.entities_updated)
67
+ parts.append(f" Updated {len(result.entities_updated)} entity(s): {names}")
68
+ if result.facts_added:
69
+ parts.append(f" Added {len(result.facts_added)} fact(s)")
70
+ if result.facts_quarantined:
71
+ parts.append(f" Quarantined {len(result.facts_quarantined)} fact(s) (low confidence)")
72
+ if result.facts_conflicted:
73
+ parts.append(f" Conflicted {len(result.facts_conflicted)} fact(s)")
74
+ if result.relations_added:
75
+ parts.append(f" Added {len(result.relations_added)} relation(s)")
76
+
77
+ if not parts:
78
+ return " No entities or facts extracted."
79
+ return "\n".join(parts)
80
+
81
+
82
+ def format_health(health: dict) -> str:
83
+ """Format health check result."""
84
+ lines = [
85
+ f" Entities: {health['entity_count']}",
86
+ f" Facts: {health['fact_count']}",
87
+ f" Pending conflicts: {health['pending_conflicts']}",
88
+ f" Status: {health['status']}",
89
+ ]
90
+ return "\n".join(lines)
engram/cli/simple.py ADDED
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env python3
2
+ """Engram — simple CLI interface.
3
+
4
+ Usage:
5
+ engram init
6
+ engram save "Minseong Jeong is the Space King of Galaxy Corp"
7
+ engram find "Space King"
8
+ engram who "Minseong Jeong"
9
+ engram remember "Minseong Jeong" "Loves AI and space"
10
+ engram all
11
+ engram status
12
+ engram forget "Old Entity"
13
+ engram export
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from engram.config import EngramConfig
23
+ from engram.engine import MemoryEngine
24
+
25
+ _DB_DIR = Path.home() / ".engram"
26
+ _engine = None
27
+
28
+
29
+ def _get_engine() -> MemoryEngine:
30
+ global _engine
31
+ if _engine is None:
32
+ _engine = MemoryEngine(EngramConfig(base_dir=_DB_DIR))
33
+ return _engine
34
+
35
+
36
+ def _print_help():
37
+ print("""
38
+ engram — AI agent memory system
39
+
40
+ Commands:
41
+ engram init Initialize memory
42
+ engram save "text" Extract & store information
43
+ engram find "query" Search memory
44
+ engram who "name" Look up entity
45
+ engram remember "name" "fact" Manually add a fact
46
+ engram all List all entities
47
+ engram status Health check
48
+ engram forget "name" Delete entity
49
+ engram export Export to Markdown
50
+
51
+ Examples:
52
+ engram save "Minseong Jeong is the Space King of Galaxy Corp"
53
+ engram find "Space King"
54
+ engram who "Minseong Jeong"
55
+ engram remember "정민성" "AI 전문가이다"
56
+
57
+ Install:
58
+ pip install git+https://github.com/aop60003/default.git
59
+ """)
60
+
61
+
62
+ def main() -> int:
63
+ args = sys.argv[1:]
64
+
65
+ if not args or args[0] in ("-h", "--help", "help"):
66
+ _print_help()
67
+ return 0
68
+
69
+ cmd = args[0]
70
+ rest = " ".join(args[1:]) if len(args) > 1 else ""
71
+
72
+ try:
73
+ engine = _get_engine()
74
+
75
+ # ── init ──
76
+ if cmd == "init":
77
+ print(f" Memory initialized at {engine.config.db_path}")
78
+
79
+ # ── save ──
80
+ elif cmd == "save":
81
+ if not rest:
82
+ print(" Usage: engram save \"text to remember\"")
83
+ return 1
84
+ result = engine.store(rest)
85
+ created = [e.name for e in result.entities_created]
86
+ updated = [e.name for e in result.entities_updated]
87
+ if created:
88
+ print(f" New: {', '.join(created)}")
89
+ if updated:
90
+ print(f" Updated: {', '.join(updated)}")
91
+ if result.facts_added:
92
+ print(f" Learned {len(result.facts_added)} fact(s)")
93
+ if not created and not updated and not result.facts_added:
94
+ print(" Saved (no entities auto-detected)")
95
+
96
+ # ── find ──
97
+ elif cmd in ("find", "search"):
98
+ if not rest:
99
+ print(" Usage: engram find \"search query\"")
100
+ return 1
101
+ result = engine.search(rest)
102
+ if not result.hits:
103
+ print(f" Nothing found for \"{rest}\"")
104
+ else:
105
+ for h in result.hits:
106
+ print(f" {h.entity.name} ({h.entity.entity_type})")
107
+ if h.entity.summary:
108
+ print(f" {h.entity.summary}")
109
+ for f in h.facts[:3]:
110
+ print(f" - {f.raw_text}")
111
+
112
+ # ── who ──
113
+ elif cmd in ("who", "get"):
114
+ if not rest:
115
+ print(" Usage: engram who \"entity name\"")
116
+ return 1
117
+ entity = engine.get_entity(rest)
118
+ if not entity:
119
+ print(f" \"{rest}\" not found.")
120
+ return 1
121
+ print(f" {entity.name} [{entity.entity_type}]")
122
+ if entity.summary:
123
+ print(f" {entity.summary}")
124
+ if entity.state.role:
125
+ print(f" Role: {entity.state.role}")
126
+ if entity.state.affiliation:
127
+ print(f" At: {entity.state.affiliation}")
128
+ if entity.aliases:
129
+ print(f" Also known as: {', '.join(entity.aliases)}")
130
+ facts = engine.get_facts(rest)
131
+ if facts:
132
+ print(f" Facts ({len(facts)}):")
133
+ for f in facts:
134
+ print(f" - {f.raw_text}")
135
+
136
+ # ── remember ──
137
+ elif cmd == "remember":
138
+ # Parse: remember "Name" "Fact"
139
+ parts = _parse_quoted_args(args[1:])
140
+ if len(parts) < 2:
141
+ print(' Usage: engram remember "Name" "Fact to remember"')
142
+ return 1
143
+ name, fact = parts[0], " ".join(parts[1:])
144
+ if not engine.get_entity(name):
145
+ engine.create_entity(name)
146
+ print(f" Created: {name}")
147
+ result = engine.add_fact(name, fact)
148
+ print(f" Remembered ({result.confidence:.0%} confidence)")
149
+
150
+ # ── all / list ──
151
+ elif cmd in ("all", "list"):
152
+ entities = engine.list_entities(limit=50)
153
+ if not entities:
154
+ print(" No memories yet. Try: engram save \"some info\"")
155
+ else:
156
+ print(f" {len(entities)} entities:")
157
+ for e in entities:
158
+ line = f" {e.name} ({e.entity_type})"
159
+ if e.summary:
160
+ line += f" — {e.summary[:50]}"
161
+ print(line)
162
+
163
+ # ── status ──
164
+ elif cmd == "status":
165
+ h = engine.health_check()
166
+ print(f" Entities: {h['entity_count']}")
167
+ print(f" Facts: {h['fact_count']}")
168
+ print(f" Conflicts: {h['pending_conflicts']}")
169
+ print(f" Status: {h['status']}")
170
+ print(f" DB: {engine.config.db_path}")
171
+
172
+ # ── forget ──
173
+ elif cmd == "forget":
174
+ if not rest:
175
+ print(" Usage: engram forget \"entity name\"")
176
+ return 1
177
+ entity = engine.get_entity(rest)
178
+ if entity:
179
+ engine.storage.delete_entity(entity.id)
180
+ print(f" Forgot: {rest}")
181
+ else:
182
+ print(f" \"{rest}\" not found.")
183
+
184
+ # ── export ──
185
+ elif cmd == "export":
186
+ count = engine.export_markdown()
187
+ print(f" Exported {count} entities to {engine.config.export_dir}")
188
+
189
+ # ── json ──
190
+ elif cmd == "json":
191
+ # engram json find "query" / engram json who "name"
192
+ if len(args) < 3:
193
+ print(" Usage: engram json find/who/all \"query\"")
194
+ return 1
195
+ subcmd = args[1]
196
+ subrest = " ".join(args[2:])
197
+ if subcmd == "find":
198
+ result = engine.search(subrest)
199
+ print(result.to_json())
200
+ elif subcmd == "who":
201
+ entity = engine.get_entity(subrest)
202
+ if entity:
203
+ facts = engine.get_facts(subrest)
204
+ print(json.dumps({
205
+ "name": entity.name, "type": entity.entity_type,
206
+ "tier": entity.tier.value, "summary": entity.summary,
207
+ "role": entity.state.role, "affiliation": entity.state.affiliation,
208
+ "facts": [{"text": f.raw_text, "confidence": f.confidence} for f in facts],
209
+ }, ensure_ascii=False, indent=2))
210
+ else:
211
+ print(json.dumps({"error": f"'{subrest}' not found"}, indent=2))
212
+ elif subcmd == "all":
213
+ entities = engine.list_entities(limit=100)
214
+ print(json.dumps([
215
+ {"name": e.name, "type": e.entity_type, "tier": e.tier.value}
216
+ for e in entities
217
+ ], ensure_ascii=False, indent=2))
218
+
219
+ else:
220
+ print(f" Unknown command: {cmd}")
221
+ _print_help()
222
+ return 1
223
+
224
+ except KeyboardInterrupt:
225
+ return 130
226
+ except Exception as e:
227
+ print(f" Error: {e}", file=sys.stderr)
228
+ return 1
229
+ finally:
230
+ if _engine:
231
+ _engine.close()
232
+
233
+ return 0
234
+
235
+
236
+ def _parse_quoted_args(args: list) -> list[str]:
237
+ """Parse arguments respecting quotes: 'Name' 'Fact here' → ['Name', 'Fact here']"""
238
+ joined = " ".join(args)
239
+ parts = []
240
+ current = ""
241
+ in_quote = False
242
+ quote_char = ""
243
+ for ch in joined:
244
+ if ch in ('"', "'") and not in_quote:
245
+ in_quote = True
246
+ quote_char = ch
247
+ elif ch == quote_char and in_quote:
248
+ in_quote = False
249
+ if current:
250
+ parts.append(current)
251
+ current = ""
252
+ elif ch == " " and not in_quote and current:
253
+ parts.append(current)
254
+ current = ""
255
+ elif ch != " " or in_quote:
256
+ current += ch
257
+ if current:
258
+ parts.append(current)
259
+ return parts
260
+
261
+
262
+ def cli_entry():
263
+ sys.exit(main())
264
+
265
+
266
+ if __name__ == "__main__":
267
+ cli_entry()