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.
- engram/__init__.py +8 -0
- engram/__main__.py +6 -0
- engram/cli/__init__.py +1 -0
- engram/cli/app.py +291 -0
- engram/cli/formatters.py +90 -0
- engram/cli/simple.py +267 -0
- engram/config.py +72 -0
- engram/engine.py +612 -0
- engram/exceptions.py +41 -0
- engram/extraction/__init__.py +6 -0
- engram/extraction/base.py +20 -0
- engram/extraction/llm_extractor.py +197 -0
- engram/extraction/ner/__init__.py +7 -0
- engram/extraction/ner/cjk.py +63 -0
- engram/extraction/ner/english.py +109 -0
- engram/extraction/ner/korean.py +106 -0
- engram/extraction/regex_extractor.py +188 -0
- engram/integrations/__init__.py +1 -0
- engram/integrations/mcp_server.py +213 -0
- engram/integrations/sdk.py +194 -0
- engram/models/__init__.py +19 -0
- engram/models/entity.py +72 -0
- engram/models/fact.py +58 -0
- engram/models/quality.py +61 -0
- engram/models/relation.py +26 -0
- engram/models/search.py +96 -0
- engram/models/session.py +53 -0
- engram/models/source.py +73 -0
- engram/quality/__init__.py +8 -0
- engram/quality/confidence.py +38 -0
- engram/quality/conflict.py +79 -0
- engram/quality/decay.py +28 -0
- engram/quality/gate.py +120 -0
- engram/quality/pii.py +80 -0
- engram/search/__init__.py +13 -0
- engram/search/base.py +20 -0
- engram/search/fts5_search.py +210 -0
- engram/search/hybrid.py +99 -0
- engram/search/semantic.py +186 -0
- engram/search/tokenizer.py +85 -0
- engram/session/__init__.py +6 -0
- engram/session/context.py +87 -0
- engram/session/manager.py +152 -0
- engram/session/working_memory.py +57 -0
- engram/storage/__init__.py +6 -0
- engram/storage/base.py +63 -0
- engram/storage/markdown_export.py +144 -0
- engram/storage/migrations.py +30 -0
- engram/storage/sqlite_store.py +615 -0
- memorytrace-0.1.0.dist-info/METADATA +138 -0
- memorytrace-0.1.0.dist-info/RECORD +54 -0
- memorytrace-0.1.0.dist-info/WHEEL +4 -0
- memorytrace-0.1.0.dist-info/entry_points.txt +3 -0
- 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
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()
|
engram/cli/formatters.py
ADDED
|
@@ -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()
|