memor-cli 0.7.1__tar.gz → 0.8.0__tar.gz

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 (109) hide show
  1. {memor_cli-0.7.1/memor_cli.egg-info → memor_cli-0.8.0}/PKG-INFO +10 -7
  2. {memor_cli-0.7.1 → memor_cli-0.8.0}/README.md +9 -6
  3. memor_cli-0.8.0/memor/__init__.py +1 -0
  4. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/cli.py +30 -10
  5. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/eval/counterfactual.py +33 -13
  6. memor_cli-0.8.0/memor/llm/anthropic.py +45 -0
  7. memor_cli-0.8.0/memor/service.py +297 -0
  8. {memor_cli-0.7.1 → memor_cli-0.8.0/memor_cli.egg-info}/PKG-INFO +10 -7
  9. {memor_cli-0.7.1 → memor_cli-0.8.0}/pyproject.toml +1 -1
  10. memor_cli-0.8.0/tests/test_service.py +127 -0
  11. memor_cli-0.7.1/memor/__init__.py +0 -1
  12. memor_cli-0.7.1/memor/llm/anthropic.py +0 -14
  13. memor_cli-0.7.1/memor/service.py +0 -187
  14. memor_cli-0.7.1/tests/test_service.py +0 -25
  15. {memor_cli-0.7.1 → memor_cli-0.8.0}/LICENSE +0 -0
  16. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/daemon.py +0 -0
  17. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/dashboard/__init__.py +0 -0
  18. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/dashboard/server.py +0 -0
  19. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/dashboard/static/index.html +0 -0
  20. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/distill/__init__.py +0 -0
  21. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/distill/distiller.py +0 -0
  22. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/distill/extractive.py +0 -0
  23. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/embed/__init__.py +0 -0
  24. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/embed/api.py +0 -0
  25. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/embed/fake.py +0 -0
  26. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/embed/local.py +0 -0
  27. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/eval/__init__.py +0 -0
  28. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/eval/baselines/__init__.py +0 -0
  29. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/eval/baselines/base.py +0 -0
  30. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/eval/baselines/claude_mem.py +0 -0
  31. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/eval/baselines/graphiti.py +0 -0
  32. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/eval/dataset.py +0 -0
  33. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/eval/embed_benchmark.py +0 -0
  34. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/eval/judge.py +0 -0
  35. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/eval/metrics.py +0 -0
  36. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/eval/runner.py +0 -0
  37. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/feedback.py +0 -0
  38. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/global_memories.py +0 -0
  39. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/hook_cli.py +0 -0
  40. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/hook_server.py +0 -0
  41. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/ingest/__init__.py +0 -0
  42. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/ingest/claude_code.py +0 -0
  43. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/ingest/documents.py +0 -0
  44. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/interfaces.py +0 -0
  45. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/llm/__init__.py +0 -0
  46. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/llm/base.py +0 -0
  47. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/llm/openai_compat.py +0 -0
  48. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/project.py +0 -0
  49. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/query_complexity.py +0 -0
  50. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/recall.py +0 -0
  51. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/redact.py +0 -0
  52. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/retrieve/__init__.py +0 -0
  53. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/retrieve/retriever.py +0 -0
  54. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/session_context.py +0 -0
  55. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/store/__init__.py +0 -0
  56. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/store/sqlite_store.py +0 -0
  57. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/tokencount.py +0 -0
  58. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/turn_metrics.py +0 -0
  59. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor/types.py +0 -0
  60. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor_cli.egg-info/SOURCES.txt +0 -0
  61. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor_cli.egg-info/dependency_links.txt +0 -0
  62. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor_cli.egg-info/entry_points.txt +0 -0
  63. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor_cli.egg-info/requires.txt +0 -0
  64. {memor_cli-0.7.1 → memor_cli-0.8.0}/memor_cli.egg-info/top_level.txt +0 -0
  65. {memor_cli-0.7.1 → memor_cli-0.8.0}/setup.cfg +0 -0
  66. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_cli_smoke.py +0 -0
  67. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_counterfactual.py +0 -0
  68. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_daemon.py +0 -0
  69. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_dashboard.py +0 -0
  70. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_dashboard_quality.py +0 -0
  71. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_dataset_builder.py +0 -0
  72. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_dimension_safety.py +0 -0
  73. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_distiller.py +0 -0
  74. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_embed.py +0 -0
  75. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_embed_benchmark.py +0 -0
  76. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_eval_ablation.py +0 -0
  77. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_eval_runner.py +0 -0
  78. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_external_baselines.py +0 -0
  79. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_extractive.py +0 -0
  80. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_feedback.py +0 -0
  81. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_global_memories.py +0 -0
  82. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_hook.py +0 -0
  83. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_hook_server.py +0 -0
  84. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_hybrid_retrieval.py +0 -0
  85. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_ingest_claude_code.py +0 -0
  86. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_ingest_documents.py +0 -0
  87. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_install_hook.py +0 -0
  88. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_interfaces.py +0 -0
  89. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_judge.py +0 -0
  90. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_metrics.py +0 -0
  91. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_multi_agent_hook.py +0 -0
  92. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_negative_feedback.py +0 -0
  93. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_noise_filter.py +0 -0
  94. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_project_resolver.py +0 -0
  95. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_quality_gate.py +0 -0
  96. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_query_complexity.py +0 -0
  97. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_recall_core.py +0 -0
  98. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_redact.py +0 -0
  99. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_retriever.py +0 -0
  100. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_roi_trend.py +0 -0
  101. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_semantic_feedback.py +0 -0
  102. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_session_context.py +0 -0
  103. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_skill_recall.py +0 -0
  104. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_store.py +0 -0
  105. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_supersession.py +0 -0
  106. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_tokencount.py +0 -0
  107. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_turn_metrics.py +0 -0
  108. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_types.py +0 -0
  109. {memor_cli-0.7.1 → memor_cli-0.8.0}/tests/test_vec_compaction.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memor-cli
3
- Version: 0.7.1
3
+ Version: 0.8.0
4
4
  Summary: Measured memory for coding agents. Fire and forget — no API keys needed.
5
5
  Author-email: Nimit Bhandari <nimitbhandari17@gmail.com>
6
6
  License-Expression: MIT
@@ -73,7 +73,7 @@ memor service install
73
73
  memor daemon
74
74
  ```
75
75
 
76
- That's it. Every prompt now gets automatic context recall. Open the dashboard to see it working:
76
+ That's it. Every prompt now gets automatic context recall. `memor service install` also starts the dashboard as a background service, so it's already live at http://localhost:8420 (and is recycled whenever you stop/restart/uninstall the service). To run it in the foreground instead:
77
77
 
78
78
  ```bash
79
79
  memor dashboard
@@ -218,10 +218,13 @@ memor install-hook Install hook + download model (interactive
218
218
  memor daemon Auto-ingest + distill (background watcher)
219
219
  memor dashboard Web dashboard on localhost:8420
220
220
  memor version Print installed version
221
- memor service install Run daemon as background service (launchd/systemd)
222
- memor service stop Stop the background service
223
- memor service uninstall Remove the background service
224
- memor service status Check if the service is running
221
+ memor service install Run daemon + dashboard as background services (launchd/systemd)
222
+ --no-dashboard Install only the daemon
223
+ memor service restart Restart both services (use after `pipx upgrade`)
224
+ memor service stop Stop both background services
225
+ memor service uninstall Remove both background services
226
+ memor service status Show daemon + dashboard status
227
+ (dashboard port: set MEMOR_DASHBOARD_PORT, default 8420)
225
228
  memor query <text> Search memories from the CLI
226
229
  memor reingest Wipe DB and re-ingest everything
227
230
  memor reingest --project <name> Re-ingest only one project
@@ -328,7 +331,7 @@ cd memor-ai
328
331
  python3 -m venv .venv && source .venv/bin/activate
329
332
  pip install -e ".[dev]"
330
333
 
331
- pytest # 287 tests
334
+ pytest # 298 tests
332
335
  ```
333
336
 
334
337
  ---
@@ -36,7 +36,7 @@ memor service install
36
36
  memor daemon
37
37
  ```
38
38
 
39
- That's it. Every prompt now gets automatic context recall. Open the dashboard to see it working:
39
+ That's it. Every prompt now gets automatic context recall. `memor service install` also starts the dashboard as a background service, so it's already live at http://localhost:8420 (and is recycled whenever you stop/restart/uninstall the service). To run it in the foreground instead:
40
40
 
41
41
  ```bash
42
42
  memor dashboard
@@ -181,10 +181,13 @@ memor install-hook Install hook + download model (interactive
181
181
  memor daemon Auto-ingest + distill (background watcher)
182
182
  memor dashboard Web dashboard on localhost:8420
183
183
  memor version Print installed version
184
- memor service install Run daemon as background service (launchd/systemd)
185
- memor service stop Stop the background service
186
- memor service uninstall Remove the background service
187
- memor service status Check if the service is running
184
+ memor service install Run daemon + dashboard as background services (launchd/systemd)
185
+ --no-dashboard Install only the daemon
186
+ memor service restart Restart both services (use after `pipx upgrade`)
187
+ memor service stop Stop both background services
188
+ memor service uninstall Remove both background services
189
+ memor service status Show daemon + dashboard status
190
+ (dashboard port: set MEMOR_DASHBOARD_PORT, default 8420)
188
191
  memor query <text> Search memories from the CLI
189
192
  memor reingest Wipe DB and re-ingest everything
190
193
  memor reingest --project <name> Re-ingest only one project
@@ -291,7 +294,7 @@ cd memor-ai
291
294
  python3 -m venv .venv && source .venv/bin/activate
292
295
  pip install -e ".[dev]"
293
296
 
294
- pytest # 287 tests
297
+ pytest # 298 tests
295
298
  ```
296
299
 
297
300
  ---
@@ -0,0 +1 @@
1
+ __version__ = "0.8.0"
@@ -46,15 +46,18 @@ memor — measured memory for coding agents
46
46
  GETTING STARTED
47
47
  memor install-hook Install the recall hook + download model
48
48
  --agent claude|codex|copilot Choose your agent (default: claude)
49
- memor service install Start the daemon as a background service
49
+ memor service install Start the daemon + dashboard as background services
50
50
  memor dashboard Open the web dashboard at localhost:8420
51
51
 
52
52
  SERVICE MANAGEMENT
53
- memor service install Install and start as a background service (survives reboots)
54
- memor service status Check if the daemon is running
55
- memor service stop Stop the service (restarts on next login)
56
- memor service uninstall Stop and remove the service completely
53
+ memor service install Install/start daemon + dashboard (survives reboots)
54
+ --no-dashboard Install only the daemon
55
+ memor service status Show daemon + dashboard status
56
+ memor service restart Restart both (use after `pipx upgrade`)
57
+ memor service stop Stop both services (restart on next login)
58
+ memor service uninstall Stop and remove both services completely
57
59
  memor daemon Run the daemon in the foreground (alternative to service)
60
+ (dashboard port: set MEMOR_DASHBOARD_PORT, default 8420)
58
61
 
59
62
  QUERYING
60
63
  memor query <text> Search memories from the command line
@@ -218,7 +221,7 @@ def eval_counterfactual_cmd(project: str = typer.Option(...), db: str = "memor.d
218
221
  import os
219
222
  llm = OpenAICompatLLM(base_url=os.environ.get("OPENAI_BASE_URL", "http://localhost:11434/v1"),
220
223
  api_key=os.environ.get("OPENAI_API_KEY", ""), model=llm_model)
221
- summary = run_suite(cases, store=s, embedder=e, llm=llm, k=k)
224
+ summary = run_suite(cases, store=s, embedder=e, llm=llm, db_path=_db_path(db), k=k)
222
225
  typer.echo(json.dumps({k: v for k, v in summary.items() if k != "cases"}, indent=2))
223
226
  typer.echo("")
224
227
  typer.echo(f" Win: {summary['win_count']}/{summary['n_cases']} ({summary['win_pct']}%)")
@@ -638,11 +641,14 @@ def version():
638
641
 
639
642
 
640
643
  @service_app.command("install")
641
- def service_install():
642
- """Install and start the daemon as a background service (survives reboots)."""
644
+ def service_install(
645
+ no_dashboard: bool = typer.Option(
646
+ False, "--no-dashboard", help="Install only the daemon, not the dashboard."),
647
+ ):
648
+ """Install and start the daemon + dashboard as background services (survives reboots)."""
643
649
  from memor.service import install
644
650
  try:
645
- typer.echo(install())
651
+ typer.echo(install(with_dashboard=not no_dashboard))
646
652
  except FileNotFoundError as e:
647
653
  typer.echo(str(e), err=True)
648
654
  raise typer.Exit(1)
@@ -651,9 +657,23 @@ def service_install():
651
657
  raise typer.Exit(1)
652
658
 
653
659
 
660
+ @service_app.command("restart")
661
+ def service_restart():
662
+ """Restart both services (use after `pipx upgrade` to run the new version)."""
663
+ from memor.service import restart
664
+ try:
665
+ typer.echo(restart())
666
+ except FileNotFoundError as e:
667
+ typer.echo(str(e), err=True)
668
+ raise typer.Exit(1)
669
+ except Exception as e:
670
+ typer.echo(f"Failed to restart service: {e}", err=True)
671
+ raise typer.Exit(1)
672
+
673
+
654
674
  @service_app.command("uninstall")
655
675
  def service_uninstall():
656
- """Stop and remove the background service."""
676
+ """Stop and remove the background services (daemon + dashboard)."""
657
677
  from memor.service import uninstall
658
678
  typer.echo(uninstall())
659
679
 
@@ -13,8 +13,7 @@ import re
13
13
  import time
14
14
  from dataclasses import dataclass
15
15
 
16
- from memor.types import Artifact, Scope
17
- from memor.retrieve.retriever import Retriever
16
+ from memor.types import Artifact
18
17
 
19
18
 
20
19
  class Outcome(enum.Enum):
@@ -114,20 +113,38 @@ def parse_verdict_json(raw: str) -> CounterfactualVerdict:
114
113
  )
115
114
 
116
115
 
117
- def run_case(case: CounterfactualCase, *, store, embedder, llm,
116
+ def run_case(case: CounterfactualCase, *, store, embedder, llm, db_path,
118
117
  k: int = 8) -> CounterfactualVerdict:
119
- scope = Scope(project=case.scope_project)
120
- r = Retriever(store, embedder, k=k)
121
- trace = r.query(case.query, scope)
118
+ """Judge a case using the PRODUCTION recall() path, not a bare Retriever.
119
+
120
+ This mirrors what the hook actually injects: same-session exclusion,
121
+ the 0.3/0.15 score threshold, the per-tier token budget, and 600-char
122
+ truncation. Without this, the eval over-recalls (self-recall echoes and
123
+ sub-threshold hits production would never inject), inflating ties and
124
+ producing same-session "losses" that cannot happen in production.
125
+ """
126
+ from memor.query_complexity import route_query, Tier
127
+ from memor.recall import recall
128
+
129
+ tier = route_query(case.query)
130
+ if tier == Tier.SKIP:
131
+ return CounterfactualVerdict(
132
+ outcome=Outcome.TIE,
133
+ reasoning="Production routes this query to SKIP — no recall would occur",
134
+ confidence=1.0)
135
+
136
+ result = recall(case.query, case.scope_project, db_path, embedder=embedder,
137
+ k=tier.k, threshold=0.15, max_tokens=tier.max_tokens,
138
+ session_id=case.session_id)
122
139
 
123
- if not trace.hits:
140
+ if not result.hit_ids:
124
141
  return CounterfactualVerdict(
125
142
  outcome=Outcome.TIE,
126
- reasoning="No context recalled — nothing to evaluate",
143
+ reasoning="No context recalled via production path — nothing to evaluate",
127
144
  confidence=1.0)
128
145
 
129
- recalled = "\n\n".join(
130
- f"[{h.artifact.kind}] {h.artifact.text}" for h in trace.hits)
146
+ # Judge exactly what production injects (post-exclusion/threshold/budget/truncation).
147
+ recalled = result.formatted_context
131
148
  holdout = "\n".join(case.holdout_texts)
132
149
 
133
150
  prompt = COUNTERFACTUAL_PROMPT.format(
@@ -136,11 +153,14 @@ def run_case(case: CounterfactualCase, *, store, embedder, llm,
136
153
  return parse_verdict_json(raw)
137
154
 
138
155
 
139
- def run_suite(cases: list[CounterfactualCase], *, store, embedder, llm,
156
+ def run_suite(cases: list[CounterfactualCase], *, store, embedder, llm, db_path,
140
157
  k: int = 8) -> dict:
141
158
  verdicts = []
142
- for c in cases:
143
- v = run_case(c, store=store, embedder=embedder, llm=llm, k=k)
159
+ n = len(cases)
160
+ for i, c in enumerate(cases, 1):
161
+ print(f" [{i}/{n}] judging case...", end="", flush=True)
162
+ v = run_case(c, store=store, embedder=embedder, llm=llm, db_path=db_path, k=k)
163
+ print(f" {v.outcome.value}", flush=True)
144
164
  verdicts.append((c, v))
145
165
  verdict_list = [v for _, v in verdicts]
146
166
  summary = summarize_verdicts(verdict_list)
@@ -0,0 +1,45 @@
1
+ import time
2
+
3
+
4
+ class AnthropicLLM:
5
+ def __init__(self, model: str = "claude-opus-4-8", api_key: str | None = None,
6
+ *, max_retries: int = 8):
7
+ import anthropic
8
+
9
+ # Disable the SDK's own (silent) retries so our visible backoff below
10
+ # is the single source of waiting — otherwise the SDK sleeps on 429
11
+ # internally before raising, which looks like a hang.
12
+ self.client = anthropic.Anthropic(api_key=api_key, max_retries=0)
13
+ self.model = model
14
+ self.max_retries = max_retries
15
+
16
+ def complete(self, prompt: str, *, max_tokens: int = 1024) -> str:
17
+ import anthropic
18
+
19
+ attempt = 0
20
+ while True:
21
+ try:
22
+ msg = self.client.messages.create(
23
+ model=self.model,
24
+ max_tokens=max_tokens,
25
+ messages=[{"role": "user", "content": prompt}],
26
+ )
27
+ return "".join(b.text for b in msg.content if b.type == "text")
28
+ except anthropic.RateLimitError as e:
29
+ attempt += 1
30
+ if attempt > self.max_retries:
31
+ raise
32
+ # Honor Retry-After when present; otherwise wait out the
33
+ # per-minute token window (tier-1 limits reset each minute).
34
+ wait = _retry_after_seconds(e) or 60
35
+ print(f" rate limited (429); waiting {wait}s then retrying "
36
+ f"(attempt {attempt}/{self.max_retries})...", flush=True)
37
+ time.sleep(wait)
38
+
39
+
40
+ def _retry_after_seconds(err) -> float | None:
41
+ try:
42
+ val = err.response.headers.get("retry-after")
43
+ return float(val) if val is not None else None
44
+ except Exception:
45
+ return None
@@ -0,0 +1,297 @@
1
+ """Manage memor background services (daemon + dashboard) as launchd (macOS)
2
+ or systemd (Linux) user services.
3
+
4
+ Two units are managed together so they cycle in lockstep:
5
+ - daemon (ai.memor.daemon) — ingest + distill
6
+ - dashboard (ai.memor.dashboard) — web UI on MEMOR_DASHBOARD_PORT (default 8420)
7
+
8
+ install/stop/uninstall/status/restart all operate on both units, so stopping or
9
+ reinstalling recycles the dashboard alongside the daemon. `install(with_dashboard=False)`
10
+ installs the daemon only.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import platform
16
+ import shutil
17
+ import subprocess
18
+ import textwrap
19
+ from pathlib import Path
20
+
21
+ DAEMON_LABEL = "ai.memor.daemon"
22
+ DASHBOARD_LABEL = "ai.memor.dashboard"
23
+ # Back-compat alias.
24
+ LABEL = DAEMON_LABEL
25
+
26
+ STATE_DIR = Path.home() / ".memor"
27
+ DAEMON_LOG = STATE_DIR / "daemon.log"
28
+ DASHBOARD_LOG = STATE_DIR / "dashboard.log"
29
+ LOG_FILE = DAEMON_LOG # back-compat alias
30
+
31
+ # macOS
32
+ PLIST_DIR = Path.home() / "Library" / "LaunchAgents"
33
+ # Linux
34
+ SYSTEMD_DIR = Path.home() / ".config" / "systemd" / "user"
35
+
36
+ DEFAULT_DASHBOARD_PORT = 8420
37
+
38
+
39
+ def _is_macos() -> bool:
40
+ return platform.system() == "Darwin"
41
+
42
+
43
+ def _find_memor_bin() -> str:
44
+ path = shutil.which("memor")
45
+ if not path:
46
+ raise FileNotFoundError(
47
+ "'memor' not found on PATH. Reinstall with: pipx install memor-cli"
48
+ )
49
+ return path
50
+
51
+
52
+ def _dashboard_port() -> int:
53
+ try:
54
+ p = int(os.environ.get("MEMOR_DASHBOARD_PORT", str(DEFAULT_DASHBOARD_PORT)))
55
+ return p if p > 0 else DEFAULT_DASHBOARD_PORT
56
+ except (ValueError, TypeError):
57
+ return DEFAULT_DASHBOARD_PORT
58
+
59
+
60
+ def _units(memor_bin: str, *, with_dashboard: bool = True, port: int | None = None) -> list[dict]:
61
+ """Describe the services to manage. Each entry has the launchd label,
62
+ systemd unit name, program args (after the memor binary), and log file."""
63
+ if port is None:
64
+ port = _dashboard_port()
65
+ units = [{
66
+ "key": "daemon",
67
+ "label": DAEMON_LABEL,
68
+ "systemd_name": "memor-daemon",
69
+ "description": "Memor daemon — memory layer for coding agents",
70
+ "args": [memor_bin, "daemon"],
71
+ "log": DAEMON_LOG,
72
+ }]
73
+ if with_dashboard:
74
+ units.append({
75
+ "key": "dashboard",
76
+ "label": DASHBOARD_LABEL,
77
+ "systemd_name": "memor-dashboard",
78
+ "description": "Memor dashboard — web UI for memory metrics",
79
+ "args": [memor_bin, "dashboard", "--port", str(port), "--no-open"],
80
+ "log": DASHBOARD_LOG,
81
+ })
82
+ return units
83
+
84
+
85
+ def _all_unit_labels() -> list[tuple[str, str]]:
86
+ """(launchd label, systemd name) for every unit we might have installed,
87
+ used by uninstall/stop/status which must act regardless of with_dashboard."""
88
+ return [(DAEMON_LABEL, "memor-daemon"), (DASHBOARD_LABEL, "memor-dashboard")]
89
+
90
+
91
+ def _plist_path(label: str) -> Path:
92
+ return PLIST_DIR / f"{label}.plist"
93
+
94
+
95
+ def _unit_path(systemd_name: str) -> Path:
96
+ return SYSTEMD_DIR / f"{systemd_name}.service"
97
+
98
+
99
+ def _plist_content(label: str, args, log_file) -> str:
100
+ if isinstance(args, str): # back-compat: old callers passed just the memor bin
101
+ args = [args, "daemon"]
102
+ arg_xml = "\n".join(f" <string>{a}</string>" for a in args)
103
+ return textwrap.dedent(f"""\
104
+ <?xml version="1.0" encoding="UTF-8"?>
105
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
106
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
107
+ <plist version="1.0">
108
+ <dict>
109
+ <key>Label</key>
110
+ <string>{label}</string>
111
+ <key>ProgramArguments</key>
112
+ <array>
113
+ {arg_xml}
114
+ </array>
115
+ <key>RunAtLoad</key>
116
+ <true/>
117
+ <key>KeepAlive</key>
118
+ <true/>
119
+ <key>StandardOutPath</key>
120
+ <string>{log_file}</string>
121
+ <key>StandardErrorPath</key>
122
+ <string>{log_file}</string>
123
+ <key>ProcessType</key>
124
+ <string>Background</string>
125
+ </dict>
126
+ </plist>
127
+ """)
128
+
129
+
130
+ def _systemd_unit(label, description="Memor daemon — memory layer for coding agents",
131
+ args=None, log_file=LOG_FILE) -> str:
132
+ if args is None: # back-compat: old signature was _systemd_unit(memor_bin)
133
+ args = [label, "daemon"]
134
+ exec_start = " ".join(str(a) for a in args)
135
+ return textwrap.dedent(f"""\
136
+ [Unit]
137
+ Description={description}
138
+ After=default.target
139
+
140
+ [Service]
141
+ Type=simple
142
+ ExecStart={exec_start}
143
+ Restart=on-failure
144
+ RestartSec=10
145
+ StandardOutput=append:{log_file}
146
+ StandardError=append:{log_file}
147
+
148
+ [Install]
149
+ WantedBy=default.target
150
+ """)
151
+
152
+
153
+ def _port_in_use(port: int) -> bool:
154
+ import socket
155
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
156
+ s.settimeout(0.2)
157
+ return s.connect_ex(("127.0.0.1", port)) == 0
158
+
159
+
160
+ def install(with_dashboard: bool = True) -> str:
161
+ memor_bin = _find_memor_bin()
162
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
163
+ port = _dashboard_port()
164
+ units = _units(memor_bin, with_dashboard=with_dashboard, port=port)
165
+
166
+ warnings = []
167
+ if with_dashboard and _port_in_use(port):
168
+ warnings.append(
169
+ f" warning: port {port} is already in use — the dashboard service may "
170
+ f"crash-loop. Stop the other process or set MEMOR_DASHBOARD_PORT.")
171
+
172
+ lines = []
173
+ if _is_macos():
174
+ PLIST_DIR.mkdir(parents=True, exist_ok=True)
175
+ for u in units:
176
+ path = _plist_path(u["label"])
177
+ path.write_text(_plist_content(u["label"], u["args"], u["log"]))
178
+ subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}", str(path)],
179
+ capture_output=True)
180
+ subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(path)],
181
+ check=True)
182
+ lines.append(f" {u['key']}: {path}")
183
+ header = "Installed and started launchd services:"
184
+ else:
185
+ SYSTEMD_DIR.mkdir(parents=True, exist_ok=True)
186
+ for u in units:
187
+ path = _unit_path(u["systemd_name"])
188
+ path.write_text(_systemd_unit(u["label"], u["description"], u["args"], u["log"]))
189
+ lines.append(f" {u['key']}: {path}")
190
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
191
+ for u in units:
192
+ subprocess.run(["systemctl", "--user", "enable", "--now", u["systemd_name"]],
193
+ check=True)
194
+ header = "Installed and started systemd user services:"
195
+
196
+ out = [header, *lines]
197
+ if with_dashboard:
198
+ out.append(f" dashboard: http://localhost:{port}")
199
+ if warnings:
200
+ out.extend(warnings)
201
+ return "\n".join(out)
202
+
203
+
204
+ def uninstall() -> str:
205
+ removed = []
206
+ if _is_macos():
207
+ for label, _ in _all_unit_labels():
208
+ path = _plist_path(label)
209
+ if path.exists():
210
+ subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}", str(path)],
211
+ capture_output=True)
212
+ path.unlink()
213
+ removed.append(str(path))
214
+ else:
215
+ changed = False
216
+ for _, name in _all_unit_labels():
217
+ path = _unit_path(name)
218
+ if path.exists():
219
+ subprocess.run(["systemctl", "--user", "disable", "--now", name],
220
+ capture_output=True)
221
+ path.unlink()
222
+ removed.append(str(path))
223
+ changed = True
224
+ if changed:
225
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
226
+ if not removed:
227
+ return "No services installed."
228
+ return "Services stopped and removed:\n" + "\n".join(f" deleted: {p}" for p in removed)
229
+
230
+
231
+ def stop() -> str:
232
+ stopped = []
233
+ if _is_macos():
234
+ for label, _ in _all_unit_labels():
235
+ path = _plist_path(label)
236
+ if path.exists():
237
+ r = subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}", str(path)],
238
+ capture_output=True, text=True)
239
+ stopped.append(label if r.returncode == 0 else f"{label} (failed)")
240
+ else:
241
+ for _, name in _all_unit_labels():
242
+ if _unit_path(name).exists():
243
+ r = subprocess.run(["systemctl", "--user", "stop", name],
244
+ capture_output=True, text=True)
245
+ stopped.append(name if r.returncode == 0 else f"{name} (failed)")
246
+ if not stopped:
247
+ return "No services installed. Nothing to stop."
248
+ return ("Stopped: " + ", ".join(stopped) +
249
+ "\n They restart on next login. To remove: memor service uninstall")
250
+
251
+
252
+ def restart() -> str:
253
+ """Stop and reinstall both units — use after `pipx upgrade` to recycle them
254
+ onto the new binary (the running processes keep old code until restarted)."""
255
+ stop()
256
+ return install()
257
+
258
+
259
+ def _macos_unit_status(label: str) -> str:
260
+ path = _plist_path(label)
261
+ if not path.exists():
262
+ return "not installed"
263
+ r = subprocess.run(["launchctl", "print", f"gui/{os.getuid()}/{label}"],
264
+ capture_output=True, text=True)
265
+ if r.returncode != 0:
266
+ return "installed, not running"
267
+ for line in r.stdout.splitlines():
268
+ s = line.strip()
269
+ if s.startswith("pid ="):
270
+ return f"running (pid {s.split('=')[1].strip()})"
271
+ return "running"
272
+
273
+
274
+ def _linux_unit_status(name: str) -> str:
275
+ if not _unit_path(name).exists():
276
+ return "not installed"
277
+ r = subprocess.run(["systemctl", "--user", "is-active", name],
278
+ capture_output=True, text=True)
279
+ state = r.stdout.strip()
280
+ if state == "active":
281
+ pid_r = subprocess.run(["systemctl", "--user", "show", "-p", "MainPID", name],
282
+ capture_output=True, text=True)
283
+ return f"running (pid {pid_r.stdout.strip().split('=')[-1]})"
284
+ return f"installed, {state}"
285
+
286
+
287
+ def status() -> str:
288
+ rows = []
289
+ for label, name in _all_unit_labels():
290
+ key = "daemon" if label == DAEMON_LABEL else "dashboard"
291
+ st = _macos_unit_status(label) if _is_macos() else _linux_unit_status(name)
292
+ if key == "dashboard" and st.startswith("running"):
293
+ st += f" → http://localhost:{_dashboard_port()}"
294
+ rows.append(f" {key}: {st}")
295
+ if all("not installed" in r for r in rows):
296
+ return "Not installed. Run: memor service install"
297
+ return "Service status:\n" + "\n".join(rows)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memor-cli
3
- Version: 0.7.1
3
+ Version: 0.8.0
4
4
  Summary: Measured memory for coding agents. Fire and forget — no API keys needed.
5
5
  Author-email: Nimit Bhandari <nimitbhandari17@gmail.com>
6
6
  License-Expression: MIT
@@ -73,7 +73,7 @@ memor service install
73
73
  memor daemon
74
74
  ```
75
75
 
76
- That's it. Every prompt now gets automatic context recall. Open the dashboard to see it working:
76
+ That's it. Every prompt now gets automatic context recall. `memor service install` also starts the dashboard as a background service, so it's already live at http://localhost:8420 (and is recycled whenever you stop/restart/uninstall the service). To run it in the foreground instead:
77
77
 
78
78
  ```bash
79
79
  memor dashboard
@@ -218,10 +218,13 @@ memor install-hook Install hook + download model (interactive
218
218
  memor daemon Auto-ingest + distill (background watcher)
219
219
  memor dashboard Web dashboard on localhost:8420
220
220
  memor version Print installed version
221
- memor service install Run daemon as background service (launchd/systemd)
222
- memor service stop Stop the background service
223
- memor service uninstall Remove the background service
224
- memor service status Check if the service is running
221
+ memor service install Run daemon + dashboard as background services (launchd/systemd)
222
+ --no-dashboard Install only the daemon
223
+ memor service restart Restart both services (use after `pipx upgrade`)
224
+ memor service stop Stop both background services
225
+ memor service uninstall Remove both background services
226
+ memor service status Show daemon + dashboard status
227
+ (dashboard port: set MEMOR_DASHBOARD_PORT, default 8420)
225
228
  memor query <text> Search memories from the CLI
226
229
  memor reingest Wipe DB and re-ingest everything
227
230
  memor reingest --project <name> Re-ingest only one project
@@ -328,7 +331,7 @@ cd memor-ai
328
331
  python3 -m venv .venv && source .venv/bin/activate
329
332
  pip install -e ".[dev]"
330
333
 
331
- pytest # 287 tests
334
+ pytest # 298 tests
332
335
  ```
333
336
 
334
337
  ---
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memor-cli"
7
- version = "0.7.1"
7
+ version = "0.8.0"
8
8
  description = "Measured memory for coding agents. Fire and forget — no API keys needed."
9
9
  readme = "README.md"
10
10
  license = "MIT"