memplex 3.2.1__tar.gz → 3.2.3__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 (123) hide show
  1. {memplex-3.2.1 → memplex-3.2.3}/PKG-INFO +1 -1
  2. {memplex-3.2.1 → memplex-3.2.3}/README.md +52 -10
  3. {memplex-3.2.1 → memplex-3.2.3}/memplex/__init__.py +1 -0
  4. {memplex-3.2.1 → memplex-3.2.3}/memplex/__main__.py +1 -0
  5. {memplex-3.2.1 → memplex-3.2.3}/memplex/_plugin/.claude-plugin/plugin.json +1 -1
  6. memplex-3.2.3/memplex/_plugin/hooks/hooks.json +81 -0
  7. memplex-3.2.3/memplex/_plugin/scripts/hook-runner.py +418 -0
  8. memplex-3.2.3/memplex/adapters/agent_installer.py +952 -0
  9. memplex-3.2.3/memplex/adapters/agent_runtime.py +399 -0
  10. {memplex-3.2.1 → memplex-3.2.3}/memplex/adapters/cli.py +227 -122
  11. {memplex-3.2.1 → memplex-3.2.3}/memplex/adapters/http_api.py +14 -6
  12. {memplex-3.2.1 → memplex-3.2.3}/memplex/adapters/mcp_server.py +133 -11
  13. memplex-3.2.3/memplex/benchmarks/__init__.py +83 -0
  14. memplex-3.2.3/memplex/benchmarks/base.py +268 -0
  15. memplex-3.2.3/memplex/benchmarks/benchmark_cli.py +208 -0
  16. memplex-3.2.3/memplex/benchmarks/evaluator.py +552 -0
  17. memplex-3.2.3/memplex/benchmarks/loader.py +342 -0
  18. memplex-3.2.3/memplex/benchmarks/locomo.py +677 -0
  19. memplex-3.2.3/memplex/benchmarks/memory_eval.py +756 -0
  20. memplex-3.2.3/memplex/benchmarks/memory_metrics.py +151 -0
  21. memplex-3.2.3/memplex/benchmarks/metrics.py +551 -0
  22. memplex-3.2.3/memplex/benchmarks/nq_trivia.py +1073 -0
  23. memplex-3.2.3/memplex/benchmarks/popqa_hotpot.py +958 -0
  24. {memplex-3.2.1 → memplex-3.2.3}/memplex/compaction.py +21 -46
  25. {memplex-3.2.1 → memplex-3.2.3}/memplex/config.py +14 -9
  26. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/associator/domain_classifier.py +87 -9
  27. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/associator/entity_aligner.py +17 -22
  28. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/associator/ref_linker.py +64 -35
  29. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/associator/term_mapper.py +7 -8
  30. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/dictionaries/__init__.py +3 -2
  31. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/engine.py +107 -74
  32. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/extractors/docx.py +8 -3
  33. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/extractors/image.py +13 -7
  34. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/extractors/markdown.py +69 -26
  35. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/extractors/pdf.py +33 -20
  36. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/extractors/vision_mapper.py +14 -15
  37. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/handlers/clipboard.py +5 -12
  38. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/handlers/file_handler.py +19 -12
  39. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/handlers/url_handler.py +24 -19
  40. memplex-3.2.3/memplex/core/hooks/__init__.py +11 -0
  41. memplex-3.2.3/memplex/core/hooks/collector.py +241 -0
  42. memplex-3.2.3/memplex/core/hooks/hook_event.py +38 -0
  43. memplex-3.2.3/memplex/core/hooks/registry.py +128 -0
  44. {memplex-3.2.1 → memplex-3.2.3}/memplex/llm/enhancer.py +3 -9
  45. {memplex-3.2.1 → memplex-3.2.3}/memplex/llm/fallback_chain.py +9 -3
  46. {memplex-3.2.1 → memplex-3.2.3}/memplex/llm/injection_guard.py +5 -5
  47. {memplex-3.2.1 → memplex-3.2.3}/memplex/llm/providers/__init__.py +2 -0
  48. {memplex-3.2.1 → memplex-3.2.3}/memplex/llm/providers/anthropic.py +2 -3
  49. {memplex-3.2.1 → memplex-3.2.3}/memplex/llm/providers/local.py +5 -4
  50. {memplex-3.2.1 → memplex-3.2.3}/memplex/llm/providers/rule_based.py +30 -8
  51. memplex-3.2.3/memplex/logging_utils.py +270 -0
  52. memplex-3.2.3/memplex/metrics.py +382 -0
  53. {memplex-3.2.1 → memplex-3.2.3}/memplex/models/memory.py +11 -4
  54. {memplex-3.2.1 → memplex-3.2.3}/memplex/models/misc.py +9 -5
  55. {memplex-3.2.1 → memplex-3.2.3}/memplex/models/search.py +2 -2
  56. {memplex-3.2.1 → memplex-3.2.3}/memplex/models/task.py +2 -2
  57. {memplex-3.2.1 → memplex-3.2.3}/memplex/processing/graph_builder.py +39 -35
  58. {memplex-3.2.1 → memplex-3.2.3}/memplex/processing/merger/__init__.py +1 -1
  59. {memplex-3.2.1 → memplex-3.2.3}/memplex/processing/merger/confidence_calculator.py +7 -10
  60. {memplex-3.2.1 → memplex-3.2.3}/memplex/processing/merger/conflict_resolver.py +17 -9
  61. {memplex-3.2.1 → memplex-3.2.3}/memplex/retrieval/dedup.py +9 -18
  62. {memplex-3.2.1 → memplex-3.2.3}/memplex/retrieval/embedding.py +10 -22
  63. {memplex-3.2.1 → memplex-3.2.3}/memplex/retrieval/reranker.py +4 -9
  64. {memplex-3.2.1 → memplex-3.2.3}/memplex/service.py +294 -75
  65. {memplex-3.2.1 → memplex-3.2.3}/memplex/storage/__init__.py +15 -16
  66. {memplex-3.2.1 → memplex-3.2.3}/memplex/storage/changelog.py +1 -3
  67. {memplex-3.2.1 → memplex-3.2.3}/memplex/storage/feedback.py +46 -31
  68. {memplex-3.2.1 → memplex-3.2.3}/memplex/storage/lite/store.py +80 -86
  69. {memplex-3.2.1 → memplex-3.2.3}/memplex/storage/vector.py +12 -10
  70. {memplex-3.2.1 → memplex-3.2.3}/memplex/wiki/community.py +24 -24
  71. {memplex-3.2.1 → memplex-3.2.3}/memplex/wiki/compiler.py +41 -50
  72. {memplex-3.2.1 → memplex-3.2.3}/memplex/wiki/generator.py +17 -13
  73. {memplex-3.2.1 → memplex-3.2.3}/memplex/wiki/search.py +38 -28
  74. {memplex-3.2.1 → memplex-3.2.3}/memplex/worker.py +38 -23
  75. {memplex-3.2.1 → memplex-3.2.3}/memplex.egg-info/PKG-INFO +1 -1
  76. {memplex-3.2.1 → memplex-3.2.3}/memplex.egg-info/SOURCES.txt +22 -0
  77. {memplex-3.2.1 → memplex-3.2.3}/pyproject.toml +36 -1
  78. memplex-3.2.3/tests/test_agent_hot_paths.py +286 -0
  79. memplex-3.2.3/tests/test_agent_runtime.py +199 -0
  80. {memplex-3.2.1 → memplex-3.2.3}/tests/test_associators.py +3 -5
  81. {memplex-3.2.1 → memplex-3.2.3}/tests/test_config.py +10 -11
  82. {memplex-3.2.1 → memplex-3.2.3}/tests/test_core_engine.py +9 -9
  83. {memplex-3.2.1 → memplex-3.2.3}/tests/test_graph_builder.py +52 -23
  84. memplex-3.2.3/tests/test_hooks.py +1335 -0
  85. memplex-3.2.3/tests/test_install_scripts.py +239 -0
  86. {memplex-3.2.1 → memplex-3.2.3}/tests/test_llm.py +35 -36
  87. {memplex-3.2.1 → memplex-3.2.3}/tests/test_models.py +13 -12
  88. {memplex-3.2.1 → memplex-3.2.3}/tests/test_service.py +18 -14
  89. {memplex-3.2.1 → memplex-3.2.3}/tests/test_storage.py +73 -38
  90. memplex-3.2.1/memplex/_plugin/hooks/hooks.json +0 -43
  91. memplex-3.2.1/memplex/_plugin/scripts/hook-runner.py +0 -166
  92. memplex-3.2.1/tests/test_hooks.py +0 -453
  93. {memplex-3.2.1 → memplex-3.2.3}/LICENSE +0 -0
  94. {memplex-3.2.1 → memplex-3.2.3}/memplex/_plugin/.mcp.json +0 -0
  95. {memplex-3.2.1 → memplex-3.2.3}/memplex/_plugin/__init__.py +0 -0
  96. {memplex-3.2.1 → memplex-3.2.3}/memplex/_plugin/skills/mem-explore/SKILL.md +0 -0
  97. {memplex-3.2.1 → memplex-3.2.3}/memplex/_plugin/skills/mem-manage/SKILL.md +0 -0
  98. {memplex-3.2.1 → memplex-3.2.3}/memplex/_plugin/skills/mem-search/SKILL.md +0 -0
  99. {memplex-3.2.1 → memplex-3.2.3}/memplex/_plugin/skills/mem-write/SKILL.md +0 -0
  100. {memplex-3.2.1 → memplex-3.2.3}/memplex/adapters/__init__.py +0 -0
  101. {memplex-3.2.1 → memplex-3.2.3}/memplex/adapters/claude_skill.py +1 -1
  102. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/__init__.py +0 -0
  103. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/associator/__init__.py +3 -3
  104. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/extractors/__init__.py +2 -2
  105. {memplex-3.2.1 → memplex-3.2.3}/memplex/core/handlers/__init__.py +0 -0
  106. {memplex-3.2.1 → memplex-3.2.3}/memplex/llm/__init__.py +3 -3
  107. {memplex-3.2.1 → memplex-3.2.3}/memplex/llm/provider.py +0 -0
  108. {memplex-3.2.1 → memplex-3.2.3}/memplex/llm/sanitizer.py +0 -0
  109. {memplex-3.2.1 → memplex-3.2.3}/memplex/models/__init__.py +46 -46
  110. {memplex-3.2.1 → memplex-3.2.3}/memplex/models/feedback.py +1 -1
  111. {memplex-3.2.1 → memplex-3.2.3}/memplex/models/graph.py +1 -1
  112. {memplex-3.2.1 → memplex-3.2.3}/memplex/models/paragraph.py +0 -0
  113. {memplex-3.2.1 → memplex-3.2.3}/memplex/models/source.py +1 -1
  114. {memplex-3.2.1 → memplex-3.2.3}/memplex/processing/__init__.py +0 -0
  115. {memplex-3.2.1 → memplex-3.2.3}/memplex/retrieval/__init__.py +0 -0
  116. {memplex-3.2.1 → memplex-3.2.3}/memplex/storage/base.py +0 -0
  117. {memplex-3.2.1 → memplex-3.2.3}/memplex/storage/lite/__init__.py +0 -0
  118. {memplex-3.2.1 → memplex-3.2.3}/memplex/wiki/__init__.py +0 -0
  119. {memplex-3.2.1 → memplex-3.2.3}/memplex.egg-info/dependency_links.txt +0 -0
  120. {memplex-3.2.1 → memplex-3.2.3}/memplex.egg-info/entry_points.txt +0 -0
  121. {memplex-3.2.1 → memplex-3.2.3}/memplex.egg-info/requires.txt +0 -0
  122. {memplex-3.2.1 → memplex-3.2.3}/memplex.egg-info/top_level.txt +0 -0
  123. {memplex-3.2.1 → memplex-3.2.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memplex
3
- Version: 3.2.1
3
+ Version: 3.2.3
4
4
  Summary: Memplex - Memory Complex: multi-agent knowledge graph memory system with 3-layer retrieval
5
5
  Requires-Python: >=3.11
6
6
  License-File: LICENSE
@@ -13,24 +13,59 @@
13
13
 
14
14
  ## Installation
15
15
 
16
- ### Claude Code Plugin
16
+ ### One-Command Agent Setup
17
+
18
+ No source checkout is required. The npm entrypoint matches the common
19
+ `npx <tool> setup` pattern used by modern CLIs:
20
+
21
+ ```bash
22
+ npx memplex setup
23
+ ```
24
+
25
+ Install into a specific local agent:
26
+
27
+ ```bash
28
+ npx memplex setup --agent codex --project-path "$PWD"
29
+ npx memplex setup --agent claude-code --project-path "$PWD"
30
+ npx memplex setup --agent openclaw --project-path "$PWD"
31
+ npx memplex setup --agent hermes --project-path "$PWD"
32
+ ```
33
+
34
+ Install every supported agent config on this machine:
35
+
36
+ ```bash
37
+ npx memplex setup --agent all --project-path "$PWD"
38
+ ```
39
+
40
+ Uninstall:
17
41
 
18
- **Option 1: Local plugin (for development)**
19
42
  ```bash
20
- # Clone and install locally
21
- cd plugin
22
- /plugin install ./plugin
43
+ npx memplex uninstall --agent all
23
44
  ```
24
45
 
25
- **Option 2: Via marketplace**
46
+ The npm wrapper creates a persistent Python environment at
47
+ `~/.local/share/memplex/agent-venv`, installs `memplex==3.2.3`, detects local
48
+ Codex, Claude Code, OpenClaw, and Hermes config directories/commands, then
49
+ registers Memplex with each detected agent. It uses `uv` when available and
50
+ falls back to `python -m venv` plus `pip`.
51
+
52
+ Python-first users can use a persistent tool install:
53
+
26
54
  ```bash
27
- /plugin marketplace add ./marketplace.json
28
- /plugin install memplex
55
+ uv tool install memplex==3.2.3
56
+ memplex setup
29
57
  ```
30
58
 
31
- **Option 3: pip install**
59
+ The raw hosted installer remains available for shell-only environments:
60
+
32
61
  ```bash
33
- pip install memplex
62
+ curl -fsSL https://raw.githubusercontent.com/articultur/memplex/main/scripts/install-agent.sh | bash
63
+ ```
64
+
65
+ ### Claude Code Plugin
66
+
67
+ ```bash
68
+ memplex setup --agent claude-code
34
69
  ```
35
70
 
36
71
  ### From Source
@@ -41,6 +76,13 @@ cd memplex
41
76
  pip install -e .
42
77
  ```
43
78
 
79
+ Uninstall:
80
+
81
+ ```bash
82
+ curl -fsSL https://raw.githubusercontent.com/articultur/memplex/main/scripts/install-agent.sh | \
83
+ bash -s -- --agent hermes --uninstall
84
+ ```
85
+
44
86
  ## Claude Code Setup
45
87
 
46
88
  After installation, initialize Memplex:
@@ -26,6 +26,7 @@ def main() -> None:
26
26
  Delegates to :func:`memplex.adapters.cli.main`.
27
27
  """
28
28
  import sys
29
+
29
30
  from memplex.adapters.cli import main as cli_main
30
31
 
31
32
  sys.exit(cli_main())
@@ -1,6 +1,7 @@
1
1
  """Entry point for ``python -m memplex``."""
2
2
 
3
3
  import sys
4
+
4
5
  from memplex.adapters.cli import main
5
6
 
6
7
  sys.exit(main())
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memplex",
3
- "version": "3.2.0",
3
+ "version": "3.2.3",
4
4
  "description": "Multi-agent memory system -- persistent knowledge graph with 3-layer retrieval, compaction, and wiki",
5
5
  "author": {
6
6
  "name": "articultur"
@@ -0,0 +1,81 @@
1
+ {
2
+ "description": "Memplex memory system hooks",
3
+ "hooks": {
4
+ "Setup": [
5
+ {
6
+ "matcher": "*",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "shell": "bash",
11
+ "command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${MEMPLEX_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/articultur/memplex\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/articultur/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/hook-runner.py\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"memplex: hook-runner.py not found\" >&2; exit 1; }; _PY=\"${MEMPLEX_PYTHON:-$(command -v python3 || command -v python || true)}\"; [ -n \"$_PY\" ] || { echo \"memplex: python not found\" >&2; exit 1; }; \"$_PY\" \"$_P/scripts/hook-runner.py\" setup",
12
+ "timeout": 300
13
+ }
14
+ ]
15
+ }
16
+ ],
17
+ "SessionStart": [
18
+ {
19
+ "matcher": "startup|resume|clear|compact",
20
+ "hooks": [
21
+ {
22
+ "type": "command",
23
+ "shell": "bash",
24
+ "command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${MEMPLEX_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/articultur/memplex\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/articultur/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/hook-runner.py\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"memplex: hook-runner.py not found\" >&2; exit 1; }; _PY=\"${MEMPLEX_PYTHON:-$(command -v python3 || command -v python || true)}\"; [ -n \"$_PY\" ] || { echo \"memplex: python not found\" >&2; exit 1; }; \"$_PY\" \"$_P/scripts/hook-runner.py\" session-start",
25
+ "timeout": 60
26
+ }
27
+ ]
28
+ }
29
+ ],
30
+ "UserPromptSubmit": [
31
+ {
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "shell": "bash",
36
+ "command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${MEMPLEX_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/articultur/memplex\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/articultur/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/hook-runner.py\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"memplex: hook-runner.py not found\" >&2; exit 1; }; _PY=\"${MEMPLEX_PYTHON:-$(command -v python3 || command -v python || true)}\"; [ -n \"$_PY\" ] || { echo \"memplex: python not found\" >&2; exit 1; }; \"$_PY\" \"$_P/scripts/hook-runner.py\" prompt-submit",
37
+ "timeout": 30
38
+ }
39
+ ]
40
+ }
41
+ ],
42
+ "PreToolUse": [
43
+ {
44
+ "matcher": "*",
45
+ "hooks": [
46
+ {
47
+ "type": "command",
48
+ "shell": "bash",
49
+ "command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${MEMPLEX_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/articultur/memplex\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/articultur/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/hook-runner.py\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"memplex: hook-runner.py not found\" >&2; exit 1; }; _PY=\"${MEMPLEX_PYTHON:-$(command -v python3 || command -v python || true)}\"; [ -n \"$_PY\" ] || { echo \"memplex: python not found\" >&2; exit 1; }; \"$_PY\" \"$_P/scripts/hook-runner.py\" file-context",
50
+ "timeout": 60
51
+ }
52
+ ]
53
+ }
54
+ ],
55
+ "PostToolUse": [
56
+ {
57
+ "matcher": "*",
58
+ "hooks": [
59
+ {
60
+ "type": "command",
61
+ "shell": "bash",
62
+ "command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${MEMPLEX_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/articultur/memplex\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/articultur/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/hook-runner.py\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"memplex: hook-runner.py not found\" >&2; exit 1; }; _PY=\"${MEMPLEX_PYTHON:-$(command -v python3 || command -v python || true)}\"; [ -n \"$_PY\" ] || { echo \"memplex: python not found\" >&2; exit 1; }; \"$_PY\" \"$_P/scripts/hook-runner.py\" observation \"$MEMPLEX_TOOL_NAME\" \"$MEMPLEX_SESSION_ID\"",
63
+ "timeout": 120
64
+ }
65
+ ]
66
+ }
67
+ ],
68
+ "Stop": [
69
+ {
70
+ "hooks": [
71
+ {
72
+ "type": "command",
73
+ "shell": "bash",
74
+ "command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${MEMPLEX_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/articultur/memplex\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/articultur/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/hook-runner.py\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"memplex: hook-runner.py not found\" >&2; exit 1; }; _PY=\"${MEMPLEX_PYTHON:-$(command -v python3 || command -v python || true)}\"; [ -n \"$_PY\" ] || { echo \"memplex: python not found\" >&2; exit 1; }; \"$_PY\" \"$_P/scripts/hook-runner.py\" summarize",
75
+ "timeout": 120
76
+ }
77
+ ]
78
+ }
79
+ ]
80
+ }
81
+ }
@@ -0,0 +1,418 @@
1
+ #!/usr/bin/env python3
2
+ """Memplex Hook Runner -- dispatches lifecycle hooks for Claude Code.
3
+
4
+ Called by plugin/hooks/hooks.json with subcommands:
5
+ setup - Environment check on plugin install
6
+ session-start - Load project context on session start
7
+ prompt-submit - Inject relevant memories on user prompt
8
+ file-context - PreToolUse context for Read operations
9
+ observation - Auto-collect observation from tool usage
10
+ summarize - Session summary and compaction
11
+
12
+ Usage:
13
+ python hook-runner.py setup
14
+ python hook-runner.py session-start
15
+ python hook-runner.py prompt-submit
16
+ python hook-runner.py file-context
17
+ python hook-runner.py observation <tool_name> <session_id>
18
+ python hook-runner.py summarize
19
+
20
+ Output contract (Claude Code hook):
21
+ {"continue":true,"suppressOutput":true} - Non-blocking, no output shown
22
+ stdout content - Injected as context
23
+ exit 0 - Success
24
+ exit 1 - Non-blocking error
25
+ exit 2 - Blocking error
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import logging
32
+ import os
33
+ import re
34
+ import sys
35
+ import time
36
+ import warnings
37
+ from pathlib import Path
38
+ from typing import Any, Optional
39
+
40
+ # Suppress noisy logging from dependencies
41
+ logging.getLogger("sentence_transformers").setLevel(logging.ERROR)
42
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
43
+ warnings.filterwarnings("ignore", message=".*unauthenticated requests.*")
44
+
45
+ # Output contract prefix for Claude Code hooks
46
+ OUTPUT_CONTRACT = '{"continue":true,"suppressOutput":true}'
47
+
48
+ # Rate limiting
49
+ _RATE_FILE = Path("/tmp/.memplex_last_obs")
50
+ _RATE_LIMIT_SECONDS = 30
51
+
52
+ # Private tag stripping
53
+ _PRIVATE_TAG_RE = re.compile(r"<private>.*?</private>", re.DOTALL)
54
+
55
+
56
+ def _strip_private_tags(text: str) -> str:
57
+ return _PRIVATE_TAG_RE.sub("", text)
58
+
59
+
60
+ def _find_plugin_root() -> Optional[Path]:
61
+ """Find the plugin root using Claude Code's convention.
62
+
63
+ Searches in order:
64
+ 1. MEMPLEX_PLUGIN_ROOT env var
65
+ 2. PLUGIN_ROOT env var
66
+ 3. Claude plugin cache: ~/.claude/plugins/cache/articultur/memplex/<version>/
67
+ 4. Claude marketplace: ~/.claude/plugins/marketplaces/articultur/plugin/
68
+ """
69
+ # Check env vars first
70
+ for env_var in ("MEMPLEX_PLUGIN_ROOT", "PLUGIN_ROOT"):
71
+ root = os.environ.get(env_var, "")
72
+ if root:
73
+ p = Path(root)
74
+ if p.exists():
75
+ return p
76
+
77
+ # Search standard locations
78
+ claude_config = os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude")
79
+
80
+ # Try cache versions
81
+ cache_base = Path(claude_config) / "plugins" / "cache" / "articultur" / "memplex"
82
+ if cache_base.exists():
83
+ # Get latest version directory
84
+ versions = []
85
+ for d in cache_base.iterdir():
86
+ if d.is_dir():
87
+ versions.append(d)
88
+ if versions:
89
+ # Sort by name (version directories)
90
+ versions.sort(key=lambda x: x.name, reverse=True)
91
+ for v in versions:
92
+ scripts_dir = v / "scripts"
93
+ if scripts_dir.exists():
94
+ hook_script = scripts_dir / "hook-runner.py"
95
+ if hook_script.exists():
96
+ return scripts_dir.parent
97
+
98
+ # Try marketplace
99
+ marketplace = Path(claude_config) / "plugins" / "marketplaces" / "articultur" / "plugin"
100
+ if marketplace.exists():
101
+ scripts_dir = marketplace / "scripts"
102
+ if scripts_dir.exists() and (scripts_dir / "hook-runner.py").exists():
103
+ return marketplace
104
+
105
+ return None
106
+
107
+
108
+ def _ensure_memplex_importable() -> None:
109
+ """Ensure memplex package is importable."""
110
+ if "memplex" in sys.modules:
111
+ return
112
+
113
+ # Try to find the project root (parent of plugin directory)
114
+ plugin_root = _find_plugin_root()
115
+ if plugin_root:
116
+ project_root = plugin_root.parent.parent
117
+ if str(project_root) not in sys.path:
118
+ sys.path.insert(0, str(project_root))
119
+
120
+
121
+ def _init_service():
122
+ """Initialize MemplexService."""
123
+ _ensure_memplex_importable()
124
+ from memplex.config import load_config
125
+ from memplex.service import MemplexService
126
+
127
+ cfg = load_config()
128
+ return MemplexService(config=cfg)
129
+
130
+
131
+ def _project_path() -> str:
132
+ return os.environ.get("MEMPLEX_PROJECT_ROOT") or os.getcwd()
133
+
134
+
135
+ def _session_id(default: str = "claude-code") -> str:
136
+ return os.environ.get("MEMPLEX_SESSION_ID") or default
137
+
138
+
139
+ def _user_id() -> str:
140
+ return os.environ.get("MEMPLEX_USER_ID") or os.environ.get("USER") or "default"
141
+
142
+
143
+ def _init_runtime(session_id: str = ""):
144
+ """Initialize the shared agent runtime for Claude Code hooks."""
145
+ _ensure_memplex_importable()
146
+ from memplex.adapters.agent_runtime import AgentMemoryRuntime
147
+
148
+ return AgentMemoryRuntime(
149
+ agent="claude-code",
150
+ user_id=_user_id(),
151
+ session_id=session_id or _session_id(),
152
+ project_path=_project_path(),
153
+ )
154
+
155
+
156
+ def _rate_file() -> Path:
157
+ return Path(os.environ.get("MEMPLEX_OBS_RATE_FILE", str(_RATE_FILE)))
158
+
159
+
160
+ def _read_stdin_json() -> dict[str, Any]:
161
+ try:
162
+ if sys.stdin.isatty():
163
+ return {}
164
+ raw = sys.stdin.read()
165
+ if not raw.strip():
166
+ return {}
167
+ data = json.loads(raw)
168
+ return data if isinstance(data, dict) else {}
169
+ except (json.JSONDecodeError, OSError):
170
+ return {}
171
+
172
+
173
+ def _hook_payload(data: dict[str, Any]) -> dict[str, Any]:
174
+ payload = data.get("tool_input", data)
175
+ return payload if isinstance(payload, dict) else {}
176
+
177
+
178
+ def _first_text(data: dict[str, Any], *keys: str) -> str:
179
+ for key in keys:
180
+ value = data.get(key)
181
+ if isinstance(value, str) and value.strip():
182
+ return value
183
+ return ""
184
+
185
+
186
+ def _tool_name(data: dict[str, Any], fallback: str = "") -> str:
187
+ return (
188
+ fallback
189
+ or os.environ.get("MEMPLEX_TOOL_NAME")
190
+ or _first_text(data, "tool_name", "toolName", "name")
191
+ or "unknown"
192
+ )
193
+
194
+
195
+ def _print_contract(content: str = "") -> None:
196
+ """Print output with Claude Code contract."""
197
+ if content:
198
+ print(content)
199
+ print(OUTPUT_CONTRACT)
200
+
201
+
202
+ def cmd_setup() -> None:
203
+ """Check environment on plugin install."""
204
+ try:
205
+ _ensure_memplex_importable()
206
+ import memplex
207
+ from memplex.config import load_config
208
+
209
+ # Verify memplex is importable
210
+ version = getattr(memplex, "__version__", "unknown")
211
+
212
+ # Check config
213
+ try:
214
+ cfg = load_config()
215
+ except Exception:
216
+ # Config doesn't exist yet, that's okay for setup
217
+ cfg = None
218
+
219
+ # Initialize service if config exists
220
+ if cfg:
221
+ from memplex.service import MemplexService
222
+ service = MemplexService(config=cfg)
223
+ health = service.health()
224
+ print(f"[Memplex] v{version} installed. Status: {health.get('status', 'unknown')}")
225
+ else:
226
+ print(f"[Memplex] v{version} installed. Run 'memplex config init' to configure.")
227
+
228
+ except ImportError as e:
229
+ print(f"[Memplex] Setup failed: {e}", file=sys.stderr)
230
+ print("[Memplex] Install memplex: pip install memplex", file=sys.stderr)
231
+ sys.exit(1)
232
+ except Exception as e:
233
+ print(f"[Memplex] Setup warning: {e}", file=sys.stderr)
234
+ # Non-fatal, exit 0
235
+
236
+ _print_contract()
237
+ sys.exit(0)
238
+
239
+
240
+ def cmd_session_start() -> None:
241
+ """Load project context and inject relevant memories."""
242
+ try:
243
+ runtime = _init_runtime()
244
+ recalled = runtime.before_prompt(f"session start {_project_path()}")
245
+ if recalled.context:
246
+ _print_contract("[Memplex Context]\n" + recalled.context)
247
+ else:
248
+ _print_contract("[Memplex] No memories yet for this project.")
249
+ except Exception as e:
250
+ print(f"[Memplex] session-start: {e}", file=sys.stderr)
251
+ _print_contract()
252
+ sys.exit(0)
253
+
254
+
255
+ def cmd_prompt_submit() -> None:
256
+ """Inject relevant memories based on user prompt context.
257
+
258
+ This hook runs on every user prompt submission. It reads the prompt
259
+ from stdin and queries for relevant memories to inject.
260
+ """
261
+ try:
262
+ data = _read_stdin_json()
263
+ prompt = _first_text(data, "text", "prompt", "message", "user_prompt")
264
+
265
+ if not prompt:
266
+ _print_contract()
267
+ sys.exit(0)
268
+
269
+ recalled = _init_runtime().before_prompt(prompt)
270
+ if recalled.context:
271
+ _print_contract("[Memplex] Related memories:\n" + recalled.context)
272
+ else:
273
+ _print_contract()
274
+
275
+ except Exception as e:
276
+ print(f"[Memplex] prompt-submit: {e}", file=sys.stderr)
277
+ _print_contract()
278
+ sys.exit(0)
279
+
280
+
281
+ def cmd_file_context() -> None:
282
+ """PreToolUse context for Read operations.
283
+
284
+ When Claude Code is about to read files, this hook can inject
285
+ relevant memories about those files or their content.
286
+ """
287
+ try:
288
+ data = _read_stdin_json()
289
+ payload = _hook_payload(data)
290
+ file_path = _first_text(payload, "file_path", "path")
291
+
292
+ if not file_path:
293
+ _print_contract()
294
+ sys.exit(0)
295
+
296
+ filename = Path(file_path).name
297
+ runtime = _init_runtime()
298
+ recalled = runtime.before_prompt(filename)
299
+ if not recalled.context:
300
+ recalled = runtime.before_prompt(f"file {filename} {file_path}")
301
+ if recalled.context:
302
+ _print_contract("[Memplex] Related to this file:\n" + recalled.context)
303
+ else:
304
+ _print_contract()
305
+
306
+ except Exception as e:
307
+ print(f"[Memplex] file-context: {e}", file=sys.stderr)
308
+ _print_contract()
309
+ sys.exit(0)
310
+
311
+
312
+ def cmd_observation(tool_name: str = "", session_id: str = "") -> None:
313
+ """Auto-collect observation from tool usage."""
314
+ data = _read_stdin_json()
315
+ payload = _hook_payload(data)
316
+ tool_name = _tool_name(data, tool_name)
317
+
318
+ # Rate limit
319
+ rate_file = _rate_file()
320
+ if rate_file.exists():
321
+ try:
322
+ last = float(rate_file.read_text().strip())
323
+ if time.time() - last < _RATE_LIMIT_SECONDS:
324
+ _print_contract()
325
+ sys.exit(0)
326
+ except (ValueError, OSError):
327
+ pass
328
+
329
+ if tool_name == "Bash" and "command" in payload:
330
+ tool_input = f"Bash: {str(payload['command'])[:200]}"
331
+ elif tool_name in ("Read", "Edit", "Write") and "file_path" in payload:
332
+ tool_input = f"{tool_name}: {payload['file_path']}"
333
+ else:
334
+ tool_input = json.dumps(payload or data, ensure_ascii=False)[:300]
335
+
336
+ tool_input = _strip_private_tags(tool_input)
337
+ if not tool_input:
338
+ _print_contract()
339
+ sys.exit(0)
340
+
341
+ obs_text = f"[{tool_name}] {tool_input}"
342
+
343
+ try:
344
+ runtime = _init_runtime(session_id=session_id)
345
+ runtime.after_response(
346
+ user_message=obs_text,
347
+ assistant_message="Observed Claude Code tool use.",
348
+ metadata={"tool_name": tool_name, "tool_input": payload},
349
+ )
350
+ except Exception as e:
351
+ print(f"[Memplex] observation write skipped: {e}", file=sys.stderr)
352
+
353
+ # Update rate limit
354
+ try:
355
+ rate_file.write_text(str(time.time()))
356
+ except OSError:
357
+ pass
358
+
359
+ _print_contract()
360
+ sys.exit(0)
361
+
362
+
363
+ def cmd_summarize() -> None:
364
+ """Session summary and compaction."""
365
+ service = None
366
+ try:
367
+ service = _init_service()
368
+ compaction = service.compact(scope=os.environ.get("MEMPLEX_COMPACTION_SCOPE", "project"))
369
+ stats = service.stats()
370
+
371
+ summary = (
372
+ "[Memplex] Session complete. "
373
+ f"Memories: {stats.get('total_functions', 0)}, "
374
+ f"Edges: {stats.get('total_edges', 0)}, "
375
+ "Compaction: "
376
+ f"processed={compaction.total_processed}, "
377
+ f"merged={compaction.total_merged}, "
378
+ f"removed={compaction.total_removed}"
379
+ )
380
+ print(summary)
381
+ print(OUTPUT_CONTRACT)
382
+ except Exception as e:
383
+ print(f"[Memplex] summarize: {e}", file=sys.stderr)
384
+ _print_contract()
385
+ finally:
386
+ if service is not None:
387
+ service.stop()
388
+ sys.exit(0)
389
+
390
+
391
+ def main() -> None:
392
+ if len(sys.argv) < 2:
393
+ print("Usage: hook-runner.py <command> [args]", file=sys.stderr)
394
+ sys.exit(1)
395
+
396
+ command = sys.argv[1]
397
+
398
+ if command == "setup":
399
+ cmd_setup()
400
+ elif command == "session-start":
401
+ cmd_session_start()
402
+ elif command == "prompt-submit":
403
+ cmd_prompt_submit()
404
+ elif command == "file-context":
405
+ cmd_file_context()
406
+ elif command == "observation":
407
+ tool_name = sys.argv[2] if len(sys.argv) > 2 else ""
408
+ session_id = sys.argv[3] if len(sys.argv) > 3 else ""
409
+ cmd_observation(tool_name, session_id)
410
+ elif command in ("summarize", "session-stop"):
411
+ cmd_summarize()
412
+ else:
413
+ print(f"Unknown command: {command}", file=sys.stderr)
414
+ sys.exit(1)
415
+
416
+
417
+ if __name__ == "__main__":
418
+ main()