memarena 0.1.2__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.
memarena/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """memarena: benchmark memory layers on your own conversations."""
2
+
3
+ __version__ = "0.1.2"
memarena/cli.py ADDED
@@ -0,0 +1,318 @@
1
+ """memarena command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import tempfile
8
+ from importlib import resources
9
+ from pathlib import Path
10
+
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from . import __version__
15
+ from .errors import MemarenaError
16
+ from .llm import LLM, Budget, is_local
17
+ from .models import History, QuestionSet
18
+
19
+ app = typer.Typer(
20
+ add_completion=False,
21
+ no_args_is_help=True,
22
+ pretty_exceptions_enable=False,
23
+ help="Benchmark memory layers on your own conversations, not on someone else's leaderboard.",
24
+ )
25
+ console = Console()
26
+ err = Console(stderr=True)
27
+
28
+ SystemsOpt = typer.Option("full-context,bm25,embeddings,mem0", help="Comma-separated systems to compare.")
29
+
30
+
31
+ def _version(value: bool):
32
+ if value:
33
+ console.print(f"memarena {__version__}")
34
+ raise typer.Exit()
35
+
36
+
37
+ @app.callback()
38
+ def _root(
39
+ version: bool = typer.Option(False, "--version", callback=_version, is_eager=True, help="Show version."),
40
+ ):
41
+ pass
42
+
43
+
44
+ def _price(price: str | None) -> tuple[float, float] | None:
45
+ if not price:
46
+ return None
47
+ try:
48
+ a, b = (float(x) for x in price.split(","))
49
+ return a, b
50
+ except ValueError as e:
51
+ raise MemarenaError("--price must look like 0.10,0.30 (USD per million input,output tokens)") from e
52
+
53
+
54
+ def privacy_check(destinations: set[str], yes: bool) -> None:
55
+ """Warn before conversations leave the machine. With Ollama only, nothing is sent anywhere."""
56
+ if not destinations:
57
+ console.print("[green]Privacy:[/green] everything runs locally; no conversation leaves this machine.")
58
+ return
59
+ names = ", ".join(sorted(destinations))
60
+ console.print(
61
+ f"[yellow]Privacy warning:[/yellow] your conversations (or parts of them) will be sent to: "
62
+ f"[bold]{names}[/bold]. Use --provider ollama and local systems to keep everything on this machine."
63
+ )
64
+ if yes:
65
+ return
66
+ if not sys.stdin.isatty():
67
+ raise MemarenaError(
68
+ "Refusing to send conversations to a cloud provider without confirmation. Pass --yes."
69
+ )
70
+ if not typer.confirm("Continue?", default=False):
71
+ raise typer.Exit(1)
72
+
73
+
74
+ @app.command("import")
75
+ def import_cmd(
76
+ file: Path = typer.Argument(
77
+ ..., help="conversations.json (ChatGPT or Claude export, or the .zip) or .jsonl"
78
+ ),
79
+ fmt: str = typer.Option("auto", "--format", help="auto, chatgpt, claude or jsonl"),
80
+ out: Path = typer.Option(Path("history.json"), help="Where to write the memarena history."),
81
+ ):
82
+ """Convert a chat export into history.json."""
83
+ from .importers import load_any
84
+
85
+ h = load_any(file, fmt)
86
+ if not h.sessions:
87
+ raise MemarenaError(f"No conversations with user/assistant messages found in {file}.")
88
+ h.save(out)
89
+ msgs = sum(len(s.messages) for s in h.sessions)
90
+ console.print(f"Imported {len(h.sessions)} sessions, {msgs} messages -> {out}")
91
+
92
+
93
+ @app.command()
94
+ def generate(
95
+ history: Path = typer.Argument(..., help="history.json from 'memarena import'"),
96
+ out: Path = typer.Option(Path("questions.json"), help="Editable questions file to write."),
97
+ provider: str = typer.Option("ollama", help="ollama, openai, openrouter or mistral"),
98
+ model: str = typer.Option("qwen3:4b", help="Model that writes the questions."),
99
+ per_category: int = typer.Option(4, help="Questions per category (5 categories)."),
100
+ seed: int = typer.Option(7),
101
+ max_cost: float = typer.Option(1.0, help="Abort before spending more than this (USD)."),
102
+ price: str = typer.Option(None, help="Override model price: 'in,out' USD per million tokens."),
103
+ yes: bool = typer.Option(
104
+ False, "--yes", "-y", help="Do not ask before sending data to a cloud provider."
105
+ ),
106
+ ):
107
+ """Write questions with gold answers and evidence pointers, for you to review."""
108
+ from .qa import generate as gen
109
+
110
+ h = History.load(history)
111
+ budget = Budget(max_cost)
112
+ llm = LLM(provider, model, budget=budget, price=_price(price))
113
+ privacy_check(set() if llm.local else {provider}, yes)
114
+ with console.status("Generating questions...") as st:
115
+ qs = gen(h, llm, per_category=per_category, seed=seed, progress=lambda m: st.update(m))
116
+ qs.save(out)
117
+ console.print(
118
+ f"Wrote {len(qs.questions)} questions (status: pending) -> {out}. Spent ${budget.spent:.4f}.\n"
119
+ f"Review them before running: memarena view --questions {out} --history {history}"
120
+ )
121
+
122
+
123
+ @app.command()
124
+ def run(
125
+ history: Path = typer.Argument(..., help="history.json"),
126
+ questions: Path = typer.Argument(..., help="questions.json (only 'approved' questions run)"),
127
+ systems: str = SystemsOpt,
128
+ out: Path = typer.Option(Path("results.json")),
129
+ provider: str = typer.Option("ollama", help="Provider of the answer model (same for every system)."),
130
+ model: str = typer.Option("qwen3:4b", help="Answer model."),
131
+ judge_provider: str = typer.Option(None, help="Judge provider (default: same as --provider)."),
132
+ judge_model: str = typer.Option(None, help="Judge model (default: same as --model)."),
133
+ embed_provider: str = typer.Option("ollama"),
134
+ embed_model: str = typer.Option("nomic-embed-text"),
135
+ memory_provider: str = typer.Option("ollama", help="LLM provider used inside mem0 / Letta."),
136
+ memory_model: str = typer.Option("qwen3:4b", help="LLM used inside mem0 / Letta."),
137
+ top_k: int = typer.Option(10, help="Items each retriever returns."),
138
+ max_context_tokens: int = typer.Option(100_000, help="Cap for the full-context baseline."),
139
+ max_cost: float = typer.Option(1.0, help="Abort before spending more than this (USD)."),
140
+ price: str = typer.Option(None, help="Override answer-model price: 'in,out' USD per million tokens."),
141
+ include_pending: bool = typer.Option(False, help="Also run questions not yet approved."),
142
+ workdir: Path = typer.Option(None, help="Where memory systems keep their stores (default: temp dir)."),
143
+ yes: bool = typer.Option(
144
+ False, "--yes", "-y", help="Do not ask before sending data to a cloud provider."
145
+ ),
146
+ num_ctx: int = typer.Option(16384, help="Ollama context window used by mem0's extraction calls."),
147
+ ):
148
+ """Ingest the history into every system, ask every question and judge the answers."""
149
+ from .report import print_summary
150
+ from .runner import Runner
151
+ from .systems import ALL, SystemConfig
152
+
153
+ names = [s.strip() for s in systems.split(",") if s.strip()]
154
+ unknown = [n for n in names if n not in ALL]
155
+ if unknown:
156
+ raise MemarenaError(f"Unknown system(s): {', '.join(unknown)}. Choose from: {', '.join(ALL)}.")
157
+ h = History.load(history)
158
+ qs = QuestionSet.load(questions)
159
+ budget = Budget(max_cost)
160
+ answer_llm = LLM(provider, model, budget=budget, price=_price(price))
161
+ judge_llm = LLM(judge_provider or provider, judge_model or model, budget=budget)
162
+ dest = {p for p in (provider, judge_provider or provider) if not is_local(p)}
163
+ if "embeddings" in names or "mem0" in names or "letta" in names:
164
+ if not is_local(embed_provider):
165
+ dest.add(embed_provider)
166
+ if ("mem0" in names or "letta" in names) and not is_local(memory_provider):
167
+ dest.add(memory_provider)
168
+ if "supermemory" in names:
169
+ dest.add("supermemory")
170
+ privacy_check(dest, yes)
171
+ cfg = SystemConfig(
172
+ top_k=top_k,
173
+ max_context_tokens=max_context_tokens,
174
+ embed_provider=embed_provider,
175
+ embed_model=embed_model,
176
+ llm_provider=memory_provider,
177
+ llm_model=memory_model,
178
+ workdir=str(workdir) if workdir else "",
179
+ options={"num_ctx": num_ctx},
180
+ )
181
+ meta_path = Path(questions).with_name("dataset.json")
182
+ info = json.loads(meta_path.read_text()) if meta_path.exists() else {}
183
+ runner = Runner(
184
+ h,
185
+ qs,
186
+ names,
187
+ answer_llm,
188
+ judge_llm,
189
+ cfg,
190
+ budget,
191
+ out,
192
+ include_pending,
193
+ info,
194
+ progress=lambda m: console.print(f"[dim]{m}[/dim]"),
195
+ )
196
+ try:
197
+ result = runner.run()
198
+ except MemarenaError:
199
+ console.print(f"Partial results saved to {out}")
200
+ raise
201
+ print_summary(result, console)
202
+ console.print(f"Results -> {out}. Open them with: memarena view {out}")
203
+
204
+
205
+ def _demo_dir() -> Path:
206
+ return Path(str(resources.files("memarena") / "data" / "demo"))
207
+
208
+
209
+ @app.command()
210
+ def view(
211
+ results: Path = typer.Argument(None, help="results.json to show"),
212
+ questions: Path = typer.Option(None, help="questions.json to review and edit (saved in place)."),
213
+ history: Path = typer.Option(None, help="history.json, to show the evidence behind each question."),
214
+ port: int = typer.Option(8765),
215
+ no_browser: bool = typer.Option(False, "--no-browser"),
216
+ ):
217
+ """Open the viewer on a results file and/or a questions file to review."""
218
+ from .viewer import export_site, serve
219
+
220
+ if not results and not questions:
221
+ raise MemarenaError("Pass a results.json and/or --questions questions.json.")
222
+ for p in (results, questions, history):
223
+ if p and not Path(p).exists():
224
+ raise MemarenaError(f"File not found: {p}")
225
+ site = export_site(Path(tempfile.mkdtemp(prefix="memarena-view-")) / "site", results, questions, history)
226
+ serve(site, port, not no_browser, Path(questions).resolve() if questions else None)
227
+
228
+
229
+ @app.command()
230
+ def export(
231
+ results: Path = typer.Argument(..., help="results.json"),
232
+ out: Path = typer.Option(Path("site"), help="Output folder (static, e.g. for GitHub Pages)."),
233
+ questions: Path = typer.Option(None),
234
+ history: Path = typer.Option(None),
235
+ ):
236
+ """Export the viewer plus your results as a static site."""
237
+ from .viewer import export_site
238
+
239
+ export_site(out, results, questions, history)
240
+ console.print(f"Static viewer -> {out}/index.html")
241
+
242
+
243
+ @app.command()
244
+ def demo(
245
+ view_: bool = typer.Option(True, "--view/--no-view", help="Open the viewer after printing the table."),
246
+ port: int = typer.Option(8765),
247
+ no_browser: bool = typer.Option(False, "--no-browser"),
248
+ ):
249
+ """Show a RECORDED real run on a LongMemEval subset. No keys, no Docker, no model calls."""
250
+ from .report import print_summary
251
+ from .viewer import export_site, serve
252
+
253
+ d = _demo_dir()
254
+ result = json.loads((d / "results.json").read_text(encoding="utf-8"))
255
+ print_summary(result, console, recorded=True)
256
+ console.print(
257
+ "This is a recorded run produced with 'memarena run' (see meta above). "
258
+ "Reproduce it with the commands in the README."
259
+ )
260
+ if view_:
261
+ site = export_site(
262
+ Path(tempfile.mkdtemp(prefix="memarena-demo-")) / "site",
263
+ d / "results.json",
264
+ d / "questions.json",
265
+ d / "history.json",
266
+ )
267
+ serve(site, port, not no_browser)
268
+
269
+
270
+ @app.command()
271
+ def longmemeval(
272
+ file: Path = typer.Argument(..., help="longmemeval_oracle.json (or _s) from Hugging Face"),
273
+ out_dir: Path = typer.Option(Path("lme"), help="Folder for history.json and questions.json."),
274
+ per_category: int = typer.Option(3),
275
+ seed: int = typer.Option(7),
276
+ max_chars: int = typer.Option(
277
+ 16000, help="Prefer questions whose sessions are under this many characters."
278
+ ),
279
+ ):
280
+ """Convert a LongMemEval subset into memarena files (one merged history)."""
281
+ from .longmemeval import convert, select
282
+
283
+ items = json.loads(Path(file).read_text(encoding="utf-8"))
284
+ cats = ["single-session", "multi-session", "temporal", "knowledge-update", "abstention"]
285
+ chosen = select(items, {c: per_category for c in cats}, seed=seed, max_chars=max_chars)
286
+ h, qs = convert(chosen)
287
+ out_dir.mkdir(parents=True, exist_ok=True)
288
+ h.save(out_dir / "history.json")
289
+ qs.save(out_dir / "questions.json")
290
+ (out_dir / "dataset.json").write_text(
291
+ json.dumps(
292
+ {
293
+ "name": "LongMemEval subset (xiaowu0162/longmemeval-cleaned, " + Path(file).name + ")",
294
+ "license": "MIT",
295
+ "url": "https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned",
296
+ "seed": seed,
297
+ "per_category": per_category,
298
+ "max_chars": max_chars,
299
+ },
300
+ indent=1,
301
+ )
302
+ )
303
+ console.print(f"{len(h.sessions)} sessions, {len(qs.questions)} questions -> {out_dir}")
304
+
305
+
306
+ def main() -> None:
307
+ try:
308
+ app()
309
+ except MemarenaError as e:
310
+ err.print(f"[bold red]Error:[/bold red] {e}")
311
+ sys.exit(1)
312
+ except KeyboardInterrupt:
313
+ err.print("Interrupted.")
314
+ sys.exit(130)
315
+
316
+
317
+ if __name__ == "__main__":
318
+ main()
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Di Wu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "LongMemEval subset (xiaowu0162/longmemeval-cleaned, longmemeval_oracle.json)",
3
+ "license": "MIT",
4
+ "url": "https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned",
5
+ "seed": 7,
6
+ "per_category": 4,
7
+ "max_chars": 16000
8
+ }