From 4aade5031668f9e2f8a5dd0f99d62f541652b640 Mon Sep 17 00:00:00 2001 From: xlrrrr Date: Thu, 26 Mar 2026 00:09:50 +0800 Subject: [PATCH 01/45] fix: rename platform package to platforms --- openspace/agents/grounding_agent.py | 2 +- openspace/grounding/backends/gui/provider.py | 2 +- openspace/grounding/backends/shell/provider.py | 2 +- openspace/grounding/backends/shell/session.py | 2 +- openspace/{platform => platforms}/__init__.py | 0 openspace/{platform => platforms}/config.py | 0 openspace/{platform => platforms}/recording.py | 0 openspace/{platform => platforms}/screenshot.py | 0 openspace/{platform => platforms}/system_info.py | 0 openspace/recording/manager.py | 6 +++--- openspace/recording/recorder.py | 8 ++++---- openspace/recording/video.py | 4 ++-- 12 files changed, 13 insertions(+), 13 deletions(-) rename openspace/{platform => platforms}/__init__.py (100%) rename openspace/{platform => platforms}/config.py (100%) rename openspace/{platform => platforms}/recording.py (100%) rename openspace/{platform => platforms}/screenshot.py (100%) rename openspace/{platform => platforms}/system_info.py (100%) diff --git a/openspace/agents/grounding_agent.py b/openspace/agents/grounding_agent.py index f66ae99..4e36cba 100644 --- a/openspace/agents/grounding_agent.py +++ b/openspace/agents/grounding_agent.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional from openspace.agents.base import BaseAgent from openspace.grounding.core.types import BackendType, ToolResult -from openspace.platform.screenshot import ScreenshotClient +from openspace.platforms.screenshot import ScreenshotClient from openspace.prompts import GroundingAgentPrompts from openspace.utils.logging import Logger diff --git a/openspace/grounding/backends/gui/provider.py b/openspace/grounding/backends/gui/provider.py index ec551bf..3f9389e 100644 --- a/openspace/grounding/backends/gui/provider.py +++ b/openspace/grounding/backends/gui/provider.py @@ -4,7 +4,7 @@ from openspace.grounding.core.provider import Provider from openspace.grounding.core.session import BaseSession from openspace.config import get_config from openspace.config.utils import get_config_value -from openspace.platform import get_local_server_config +from openspace.platforms import get_local_server_config from openspace.utils.logging import Logger from .transport.connector import GUIConnector from .transport.local_connector import LocalGUIConnector diff --git a/openspace/grounding/backends/shell/provider.py b/openspace/grounding/backends/shell/provider.py index 1122ef6..2884fbf 100644 --- a/openspace/grounding/backends/shell/provider.py +++ b/openspace/grounding/backends/shell/provider.py @@ -5,7 +5,7 @@ from .transport.connector import ShellConnector from .transport.local_connector import LocalShellConnector from openspace.config import get_config from openspace.config.utils import get_config_value -from openspace.platform.config import get_local_server_config +from openspace.platforms.config import get_local_server_config from openspace.utils.logging import Logger logger = Logger.get_logger(__name__) diff --git a/openspace/grounding/backends/shell/session.py b/openspace/grounding/backends/shell/session.py index 38879ff..7dad2e2 100644 --- a/openspace/grounding/backends/shell/session.py +++ b/openspace/grounding/backends/shell/session.py @@ -375,7 +375,7 @@ If you already have the exact command/script to run, use run_shell instead.""" if base_url is not None: try: - from openspace.platform import SystemInfoClient + from openspace.platforms import SystemInfoClient async with SystemInfoClient(base_url=base_url, timeout=5) as client: info = await client.get_system_info(use_cache=False) diff --git a/openspace/platform/__init__.py b/openspace/platforms/__init__.py similarity index 100% rename from openspace/platform/__init__.py rename to openspace/platforms/__init__.py diff --git a/openspace/platform/config.py b/openspace/platforms/config.py similarity index 100% rename from openspace/platform/config.py rename to openspace/platforms/config.py diff --git a/openspace/platform/recording.py b/openspace/platforms/recording.py similarity index 100% rename from openspace/platform/recording.py rename to openspace/platforms/recording.py diff --git a/openspace/platform/screenshot.py b/openspace/platforms/screenshot.py similarity index 100% rename from openspace/platform/screenshot.py rename to openspace/platforms/screenshot.py diff --git a/openspace/platform/system_info.py b/openspace/platforms/system_info.py similarity index 100% rename from openspace/platform/system_info.py rename to openspace/platforms/system_info.py diff --git a/openspace/recording/manager.py b/openspace/recording/manager.py index d3e23ed..fd6d7ed 100644 --- a/openspace/recording/manager.py +++ b/openspace/recording/manager.py @@ -500,7 +500,7 @@ class RecordingManager: # create video client (internal management) if self.enable_video: - from openspace.platform import RecordingClient + from openspace.platforms import RecordingClient self._recording_client = RecordingClient(base_url=self.server_url) success = await self._recording_client.start_recording() if success: @@ -510,7 +510,7 @@ class RecordingManager: # create screenshot client (internal management) if self.enable_screenshot: - from openspace.platform import ScreenshotClient + from openspace.platforms import ScreenshotClient self._screenshot_client = ScreenshotClient(base_url=self.server_url) logger.debug("Screenshot client ready") @@ -539,7 +539,7 @@ class RecordingManager: async def _check_server_availability(self): """Check if local server is available""" try: - from openspace.platform import SystemInfoClient + from openspace.platforms import SystemInfoClient # Use context manager to ensure aiohttp session is closed, avoiding warning of unclosed session async with SystemInfoClient(base_url=self.server_url) as client: diff --git a/openspace/recording/recorder.py b/openspace/recording/recorder.py index d1f7678..aaee792 100644 --- a/openspace/recording/recorder.py +++ b/openspace/recording/recorder.py @@ -23,7 +23,7 @@ class TrajectoryRecorder: Args: task_name: task name (optional, will be saved in metadata) log_dir: log directory - enable_screenshot: whether to save screenshots (through platform.ScreenshotClient) + enable_screenshot: whether to save screenshots (through platforms.ScreenshotClient) enable_video: whether to enable video recording (through platform.RecordingClient) server_url: local_server address (None = read from config/environment variables) """ @@ -91,7 +91,7 @@ class TrajectoryRecorder: parameters: tool parameters screenshot: screenshot bytes (if provided) extra: extra information (e.g. server field for MCP) - auto_screenshot: whether to automatically capture screenshot (through platform.ScreenshotClient) + auto_screenshot: whether to automatically capture screenshot (through platforms.ScreenshotClient) """ self.step_counter += 1 step_num = self.step_counter @@ -145,9 +145,9 @@ class TrajectoryRecorder: return step_info async def _capture_screenshot(self) -> Optional[bytes]: - """Capture screenshot automatically through platform.ScreenshotClient""" + """Capture screenshot automatically through platforms.ScreenshotClient""" try: - from openspace.platform import ScreenshotClient + from openspace.platforms import ScreenshotClient # Lazy initialization screenshot client if not hasattr(self, '_screenshot_client'): diff --git a/openspace/recording/video.py b/openspace/recording/video.py index 72aa273..65e7c1c 100644 --- a/openspace/recording/video.py +++ b/openspace/recording/video.py @@ -1,7 +1,7 @@ """ Video Recorder -Communicates with local_server through platform.RecordingClient +Communicates with local_server through platforms.RecordingClient Supports local and remote recording (through configuration LOCAL_SERVER_URL) """ @@ -9,7 +9,7 @@ from pathlib import Path from typing import Optional from openspace.utils.logging import Logger -from openspace.platform import RecordingClient +from openspace.platforms import RecordingClient logger = Logger.get_logger(__name__) From 2193069e974b56ab9c90d6553a06b85a35073d23 Mon Sep 17 00:00:00 2001 From: chaohuang-ai Date: Thu, 26 Mar 2026 01:04:17 +0800 Subject: [PATCH 02/45] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index da61f1c..f57e2d5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ [![WeChat](https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat&logo=wechat&logoColor=white)](./COMMUNICATION.md) [![中文文档](https://img.shields.io/badge/文档-中文版-F5C6C6?style=flat)](./README_CN.md) +One Command to Evolve All Your AI Agents: OpenClaw, nanobot, Claude Code, Codex, Cursor and etc. + --- From f7007be82c1359db18ccd056eb33be87df001c49 Mon Sep 17 00:00:00 2001 From: chaohuang-ai Date: Thu, 26 Mar 2026 01:04:48 +0800 Subject: [PATCH 03/45] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f57e2d5..f0ed67e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ [![WeChat](https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat&logo=wechat&logoColor=white)](./COMMUNICATION.md) [![中文文档](https://img.shields.io/badge/文档-中文版-F5C6C6?style=flat)](./README_CN.md) -One Command to Evolve All Your AI Agents: OpenClaw, nanobot, Claude Code, Codex, Cursor and etc. +**One Command to Evolve All Your AI Agents**: OpenClaw, nanobot, Claude Code, Codex, Cursor and etc. From dd8740e37aa33afb97d3894f821eef1a857a30f8 Mon Sep 17 00:00:00 2001 From: xlrrrr Date: Thu, 26 Mar 2026 11:14:54 +0800 Subject: [PATCH 04/45] feat: re-scan skill dirs on each call --- README.md | 2 ++ README_CN.md | 4 ++++ assets/cli-typing.gif | Bin 0 -> 445951 bytes openspace/mcp_server.py | 32 ++++++++++++++++++++------------ 4 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 assets/cli-typing.gif diff --git a/README.md b/README.md index f0ed67e..c9bbb22 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ **One Command to Evolve All Your AI Agents**: OpenClaw, nanobot, Claude Code, Codex, Cursor and etc. +openspace --query your task + --- diff --git a/README_CN.md b/README_CN.md index a47342e..046e9ad 100644 --- a/README_CN.md +++ b/README_CN.md @@ -15,6 +15,10 @@ [![Feishu](https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat&logo=larksuite&logoColor=white)](./COMMUNICATION.md) [![WeChat](https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat&logo=wechat&logoColor=white)](./COMMUNICATION.md) +**一条命令,进化你所有的 AI Agent**:OpenClaw、nanobot、Claude Code、Codex、Cursor 等 + +openspace --query your task + --- diff --git a/assets/cli-typing.gif b/assets/cli-typing.gif new file mode 100644 index 0000000000000000000000000000000000000000..ec677396fbf14c88c15cdd48ecae1f2a42292a0a GIT binary patch literal 445951 zcmeF42UOF0x~`KDk`NMzN{N6P5CH)tDj;AX^dd-9I+oB8P!SL*f}!`0fHVWrL5TDw zMnIYfSU`#eP!Sb0Aiae92WR%4IcH|}%$=Ei=H3&RF4wXwh2`Gsd!Fz8ey@g>x}3Z@ z0agK80D-^|7LG0a=q&>1tvk@%JJCG5(7e0QSR8u0AWC32YR7KWPGJ;o4@yu3wMUG7 z|2~w21p7fe`yo8L^Z|AS8KmN2enBaI9G+hg&o6`*5X1`z;gL#mNELac`cb6DQKa^9 z97z^=l7Q4xVmqV4W}wPutj=btiLlW_TqGjwPazyn!><{^-HhQLrf@Gaxc6CvlRo0A z0m9i3;bM4IPSMFsO2}7UIPQpO<#Exf<8bnMc#t_P$O0B(35&3XMcTlkY+y0AusA!| zEeF=ymsm5LSTmhjb6lZRPw0IwXxR;D`3-1=H?-0RT76Tjoxt+IkEPL{8u)7lcEmvv)yr z*`S3S&{8gFB`@7mDnm`Z>V)>V@tO4tMwMYwAB*-g8uo*jU;bbC3<$2XpH$O7qoPDq zJGdXu0tWuhf=f^~N#Og9Z=j9;Oh5<*2#gTWq~btm9!ZjMJ2kU87_(bDP4j-%<8bUD z``7LF@3uwbRKf(b%CbA+#m*F-?I_E6l7zSHOw%gQeVQug^5u0$d0x*Qf}+Sv5;gx> z7XLx$xz37$7kR|H+UeSrg?&ZF<@RqnD~n#0l1|wSI8{v2*v(%Qo_kVtZ@Aj&eP{ZK z>XJA0?mrGTkoxoAJ|wfS@6@TGjy8vElQ`eCXnDcUj{ii4PHox8C&`B#-gecNpKSD& zao?$1SMm8p{+XilPwTE}WLc}#XXw^fO%2z$e0}@WOn5RY){A}D$%dNg_wDf#(a52Z znNPiUPuw~Apl+^Dt<>RN_k;R{?<36&mVMZ>4NE^JUKCyE+3Dc|ntK1_j^4w@wG|Ke zWm?a}hwJMgm>5ah3QT!PMu_B(_@QJG=V1_evC#l7L%Y$y9JP?qAOW|D(O{h4HDZWx zqTN`iSg!k4W$~(sv2c8Ajk&T&UvCiqm4;DuewhR-7J;knf*Jx&1H{t^xd{*}m%<3f zFM_DP_hW*-MGi(wW+eH2qI})6-I}t{y&sa5Tg`O)@e9aIrqEf8XLrfQ0Au0uQ~;Y6_Y6+v59}6Swyi zoDbs4Ewl;Zg4}Oy-LdqE68tSQ()3%+yoO?!meor;ky@+xjPU&{lNGNnuT0f^Dp~o~ zu<&i=I}I+iN^jx4vO3)^TDm&Zb@==0k6sO_wb_?OSJvhRY)jYXUtj;ewlESb#aR51 zbcL}rkypxCo~-`PSfRH`t*_3$y0X5u_^EWAvAXab_!o$xfX)e%A$)W)LWct3ZKuH$ z=)RZ;3RG0{5z>_IhwY-k4!1wz@TB|WP?-n~&8Dp>^Z+rPOr%kJ6HgsI5Fe3=vej(n z8=?ovb!Bo~Z*ShYOb=!dP+6E@%@!fP=@3nwtgT7yEg}ljp~Q$Rt~|}h;-=GK#$8!F z)$Nb>drpUwP1EF!sUxU7xi+nC=ae656FPYcuR6Nj>VBlt zBl3=Z((3UX`jN57=*m03(9z?w{NoM?olk&k_xkb8QV_cN%DkPuK?<{(n88Jf+Nds=Yv zdgqIb<=H#}x{w&G{W6PhE?-l(@N`nAzC+rKTo?a*L1GX_^GHtb!T6`&0G-) z?VwPv-Cr0sS8S&%A^W7$Fu!o_o>Qd5!Qmmp+M&4;_osW#4W_(mT%IfSIZ7t6QJNkk za#KTei!FvjAo(Jh)S#&^7NRExJKYYzhz1a=-Sq}fJmwouy} z`3=lYgdT8}L7QQfS=9&57HBWjk5uksD;_r7?^IoX8C~l6%3-keTVI2a>J`ty6YrMi zE5WvS$r~zS@7@kAJOud^aS7F5z|0$=!6&JH4^x5)ax+mxbn})n0xX|r(I0-E8gw|~ zC7WC_xWwBLtVUs#IXmFLeViI*GEBiB+URo5krfY7tFbyh|NKo{RAOFRZ&k@_{F#$`Y$1PO&81LMkQhOKQ^@4|UEg zw#jc|AbDu78Hr0zoeb@ud|mz0lav6HsPc@3$)24JybxJ+sXK68JMTge+7-=WzVvlm z`N;CKX;&Kegm%E9EZ_6wZdV0v42xuMZP6CeY^03tm(%etD$joO&1b#z#jxHpwZyxL z6SJ@HIp5;*((cNV%)OS|bt>75zI`hR)^)V*9C&hip9(eAY|`ckf0Dq2GBVMNuw9f? zNHVU9x$|`Da{UZckK3P}=bn|!(Ruipbib;{lT&W>^$8=W$n9^B!~qTO}@WNfF7c^%4J@%*m|TfB;sbn z2hoSk?PnM>$@)>|FpUtet3Yn(~ySsg#JzSJAtI4grBe>PkH0I6-9m$%MX zoASCa`ewaxas61#bn3fdC>w3*)rCnASHK$m)wNS&dh4h8)z;>E9^QR(g==l)X0Ofk zO~|56wC_z8cd}aR&FE9!hyk)(6`9qX%pvCs&Lv|sF1jgC^Q6sqr`IqIEswh zwEN3a0N{A~0o<=muBZcmgLMz`CE=BD%LZ^r2wM`2WW?GdxWlM!zxgI`j2auu zNSoh}i7d8yG+->MGhdecw0PS#qMz989J>_W7Yd^cK2MpiI4nun($f2?e|`fT7|N`~ z4d8GK5GLJoxIS#O|1|&{=o09s&f&4bN7YTbCC-7v035luYVK9~fnJ9bkP);-5C`7a zpE@i$SzY>_o9f5g)yFg0kAjy-_>1a{7P^tE&*LF}5?!Mlhx$OT3|P32>WppOc8gt5 zR)yP;3ffk``1o)n;krt3kn|*|6#$MCUWE5=l?iPGj)PhTqlPzIBfi6=h+m+$`}ruX5+H;CU;hEJ-S>^ zA6FPQ$yjB*@9@Ow)A5z#1oy+&vxTwfY{%S=-*AadIXub@hXoUTvpQ_e^5_>PI{P z9JVKaqztc)%k|WDU+?~rv9kJ!002ku$=NLawFyl<064m5bC0ZjCPvr2%sV+(Xtwsn zxTmhax?3OQwDvWE2Qkq0&Gvr$+T<14BSWu_kNZ;Bru0QLK_e%x3&JV5Ydv03;aLlf zNdsao0I`bRxPBMKpd)43$9VPLwLvqcV=i1C-SvL1-E3z~f>ir^sUDA~Cy)Pter}kM zVqb2IV9b(Py$L_fDRYo#t{48dj_v>b<7N)>%t4+x$TJ6d<{#U|8SbuA@{{GO0{?KOTk?oI1wzEyPbIo@1Ep`i! zFD|xTTxz?x+;(xT)1J}ku>Qp1Z;$-{vp@SQa9{-F-2cg9HK_SUv6|rVzfr8_gSZM5 zt8x6rVztS?zgSJ__b65i`fJ5%Z{hz~v6`TH$DU-8bLbw;1YtFi?1a#cDRwzZR<@Hj35SHNrQF)xvMMvThWs zsr_cLS|~I-+hWFA{j`3e{a#y}*^OegravoIOWG_}iz_?D0~D*V{#LP?$&6%nqE!N0 z}IEj&Q68tZ>rv6|Yyq*!g1S**q^R{Iwft9go#{julZpD0!%>61z9rii4%AW!u^9zfoq*;rHMS&`5mX>i?% zfD&?{4_+nZj3)bT#W64>geDRZ5k_dP?M?2kAaD{56|#Gw>KPD-;3+F>;qAVD{S}2q z-g?Iq`Y_kpeOa`zp+e=#zO`b`Tw9av4Of<5O*PPh2U9}!#W-=R+4w;Uh#&Om%gKu1Ymsdh0yA z@^&bjwqrs&2(w)KxQK+?U!nVbYHVc$amR+mha7BveGY<~oexlXL^G-ks)cm-Tgo`y>Wxw^L|098O zd4B;apkN*x(ByQrhu8fF&)23;dE7wH!TuBCE~bTnpi#*%M+mEIW_uo4!M5P!e3}1g zR__{LXHHFPm<~4v_fj=hDU)W%HHfr`E*@OyUO3S|Qs6i8sBXGAq|L{?&;?|^H7pIq zg(I@#6-hRSx|0P4e9u5V!zgBW zD`*-Cd7s2i#UpQxxMN;A>|FH2C#x!e45-&s_hbF#lKmB>{q+p|)m{A+umQ@(D#{=O zN2-~`;Q;NLfJ0nRqJfKIjjxVepuVxP+Jb(Hwf|}3z_SB^{HoTbSnu<4K~{E3FdW#f z2Bf*@Vm%OaIa!Uc800J$?0~)I>K^Ql4H`=dzA+Gdlq=W=8{#Jy5?~w>kYg=HIu<+_LECx;c*gcT2j zl`Mu)vEgNM;T6W=Rqo*lte~2j@SS3qhQ)B$L^w?@;-hFpi+jZL!dB9MTXXe=9oIX#D7Y=p2luG=^Vl~bQIq)>M;Ebm15q<_ zc64LRSGnj#JIn9J(a_jv#(??i0Pnh7%)7_YVDN7=*8O*qjZsDI`C%IQdN<#Ga!8?t=Nq8jUVF24ch zWtE`Isz1pu!bL68Mg33tFU3b2K)|${C;pTI)5#}(0l~cBlK=}Agz6QA6N@8=02G$R zoGy<&{SyuAlMH^+VdHJXhrriAXT;|84NPp${HvtcSM)0^4wqhdO(p$A#*uRK|3Z!9 zHI@@~RscJGskfSJu>Ly$7NlsYxZ;)IV4j?%K<}Z>Q z)co(R$kzBhNG|TLkz5AiA0s)X%uexQYFU^#F`-3Gq6u{<0)NlxR2?f31&V82Cj1pvtvTD?S8-l~d=-9Bh<5Bm=z zIdAoU3CUTpFe|c|71{rqitG@Je+9{@Iszm|X#FF}*$%Mezx|cuqIwa;$G?(XI?m=! zO7Jfvcf?d@@=-Y=D-SzEc*OP0XrN?Akxcy_TEok zkNfq3T*#_MQn4X&?v(J(TZJywPtN6x6Y4G*K;6o}_J2!RXi&T~dH(SewV6cT2lPlO z-F-y1<}%(uXtEzx=K%ZZfKfI9M_=BvTf%j9Mt#4%xp#xNiL0_naG!3k2sGB+8RzRy1#ijP-9BlIgNWZ4yX1bfeN660vD9jKT>N2xU z_oL!&XvEY$qPapnhQ@ggO7pZw;WU~*4RQmGbtk*nx%^6;5u@Gv8c;B2mhr=i2k<;% zQMh21ubm(3UVnOf0)c&m+4{W@M$mLYE%^ENAPydV9yMwz=_H_Z9{&2;iG|i9jKq5% z3;76gZl1#az5@v7BZ!;k=Or-$N+NAzmMe?TQ{UdYH>JWKw!iPW4`!!QD~#pwjWHLe zw@putNABpIjeK@zewN1Rv-ii1fP(#yfG>!$DPF|NHm~>dvx}{e6`xUy#f3D@%du}7S3i}uVFidaP_JWY_#4w&*_C8EyoQi&a^KQ;JMD zP4t+Jfxs6z#GMFdNPb2mWUoEG2j%?TRegyvY#v0YG|^t~8p4-ga2ccg5oblGKw{;q2N#>s(Kto+jqSOiCQTdtUB#hUwSz# z)!ARpyO=HpIT@>;wKI<8QHEkTQr#?G#9Z{iTK%B<7#bU01xT)4%|+9Q5f1|IehE2^ zu=`Gg0HBA>MTOcp2LdD~rY4UuiN&+*v0{yExB5-RTi0=AonsGAId-)trwRPiDj&l#kz{e?u?F1ici;w>@5ZF#dSK zP1<-|BO*R=F<$+1Jd6m|{tVh(5WlA*USs>M(@QGCCQ2gmw~TM!k`4gff+JNs!c4a( zT6|VDl25epNL18Mw5v^Y98A2tl<2fQ$yq+h)g;NyBgy@Cl4otwjlm?Jr6lt9WIy@j z0F&e(kK~Zs$ziq05rfH5OUW_YZ^y~sPB6Kh=y5yw_U)9~+i8QhGnQ^swx?LKfwD|e zcI?IE-cH#Pno>BJGSQw=vOTp_4pU~5nyiTdlTz#0f&o&lUrIGL=Ay}`VeGhSwbI)6 z-gt14t79-N&z7fqd%Eiawry{Ef2i}5C2Y2kNBV2|D?=ujzQOddz4n7k>Cgii6CHNr zgS?*yGf*}ee?Kb!+;{?<{1cK<0=rQ=g@5Aljvaqx=_!4HT|ow^c$i;MiXVsP7s3k& z;su28IGhl$@%(FeeE5L)&jsi)S;>FR4%FEY;bQp90#xnj#t!rsNIpTp|MyL($q5-V zT^V2>`U@>v8_3%kDL9xM`DH!oa^cu7XJA0?mJRsQmRYeJtVWR@6@TGjy8vElVH>&jf}O$>^_m9Q(N}&N%A3w zZ-WEP<2@9W@SVDK6`x<^pSdn<+F$hJGp{_ML>Hnw&#!dp=2jU{4@6;MgzDE?M4H!e%FXW0&Wwd z!MGszt;#})c4MJpxpL;pBJ$6|BuX2`v?Wd^Kw*-dlI0NxspL~z(GC?`G;x=TmHfjr zMG8K|=~(1ov`*5n?-LHxo!+fU`jG!VQ9=CuZcS-~{D)+fva43{cDV)PDUPyzY`2_F z4t8jyIbHmen06x@cKb$DfqE(#CO$z4@R_wq3+e7Sm400|MO4FUe(&cyfrj>TKCe&e667A~Nv($9> z?ykIMtZngh>#14^rv~1qX4D6~qYl%J0(r$Vos!kr`&tgTg+Hs__El`Z`UlgaA8Pk4 zO#bL07+Fbnbr@DSKJ7My-s^q=o0@$=yfQg`ANR!UP{+cTF7=mZuTNDzRC#DtQKosN z!nINN=DqnL$9m6+s<6u~Vl~m1xh~cwRNY%(yuHD`?3Q;`O1nD4ec{sLXo$r2J$Z&f z2|4-agH%~c8*|eaKPKB=&I~`@EjO=m*uKN^ISD3j6`SZ#vizmE`rGnXYTN#m$%mg=U0GjS{8aid6f*mLeI10NfZ&=m2p=7o z`6&?Ib{b59?u&__Kt(klAx-Ii*e(j}aQh<;Pr5%2m5I>MY}%Sa4-nJIL>jd>@zl`+ z@e!FQTg_&^A$pKpS0=~x_U4_-^k4=7m4yk`Y!Tv{W~MCvjVVk0_{(GYn!1IjlR95! zZI-)>p2^eh%T}K&G=6F?SJc^;Yrj$ME+Jj7ZI~Sfl)LLX;GdWpR&JELNA5A};Cod! zG*`0oEcrY;rLESDd$Zh~zlJ4yCbQJ%-WL+@lY!RxXhe{jqeb4Aflkw^l9*%myM&Jn z=A54|r*J#i93F*tyY&}l>fW=zqcEhHJYS)4j&aY?cK8fzgn-cu48S-$`GN2lei7au|^JMvuSh=O!yTl~#$(wJ^6;zkUB?6W8=e`4| zyd@nZvMPQavz36f(Nx<{JQXBD#Ist4sO>u{$O;!3LD~@?-{>I(vIxgC+*d30<4x;T z60Ul%!%_&3C%5en9V*Rx)sv{YCkAPbK2GA3I$3rAgC{_RO}NdSuEwoDirRuDvVBc1 z3mtyu2kDn5DT6~c7(z*3AWo-uJRaE;gWM%IqQSz=EFcNge{3~);1q_jtDGb(~bcC1}B-zcK zdWKS#Y%%FP5Z7bw(@RT4Ys^S8IO|C_47TJ}7k*1C+aa4ThAQZ^9h6BfxcI ztsD@T9a@a0_2ZY#&qMJ7TI$W~1Jf4Xpd(}qW>L`wO-Sxp{bGFQBdeQ^p>B<@f1l59@!F)P{;fG%Fx`~k|HFOy7|fllcws=m z?&j4aUnJ)&7r(PcasQPgCqZmIfN+q)Ir3UB>1e{EU>>2S(-{Y@u9O&`EZfl-?B3bEOjMZKoK|jVy z9qXkf0s&XBY6Y<~R3O?SN`|(9PS{}5EF_A?(m1CW)e1Vn&4!g`*OEm+T+v#IFv22d zF^0hKTeP6i6)SBm=7~-)C}eEj!x>n54yY+O>BnQB`FW11PR7nu{Q$BO5#u2VBAmiq zTgL=i4Y-QX6;D&bjOmKz3l?1@0vjGV8^akt;gz%)PSaGo9ipGu3s=Fhw7Kbr5fC~u z@a<4ettw895k-AM0JjLoLZa0iJq!Y3?O)_vCXpB}tY-|-;y8j3m0cj8^FfIBqs1tC zjcT);$)OmfI}~er4BGdwwP{S`8AHxRighi9@GQw{oQ|p{u|K15G`N{hiUr1VM{kvn zRgsSNd2GL36{PdZ;Vr>o<`bt4g7CrEo73c)yGE?o?KnBBSVwC%6jf<2oe_C8ms4;g zw(l^Sg^OKyFlsI@?)XwX-jyX(jqO0Mk^wGWE0bg!rLI}vKOBO>UYj`%OkdSEF%2C~G?YiXW4xsX9 zrH&f5l0fAXME0&;^hYYXgNn?vBSd^ohEPB`y{Iudx;q9b+)EG{VQa>r-x4{7oKRID z}y(O<%`%b7TPHEzBvVbd&S=`4|yEb+Q5 z$)T+M%UO87y9X8SN}Jx5^}H*Wa#x}5?$M#U$CvLC__CE1vQHCA9)}*v92I1seo0fAdRo^U~oZ(HYUrnkkhEp%eS!D(=LY(^Y<>@|Cg8q zea%0_s{f))ur>TMpWt-vD3G}Ri%Q}DK7IWk;}ib-0{;I_Ce$VB|3f~ZGt2b9`vf4u z{SV26PqqKpZXSqs|FW28nuLF!Nf_;vpwec-0FxlBF4-mh0(Izq2w)N}H5_QiU8(pB zCZQ292^O0s;ovVOq3&0cFt=e6mO3;tHcUdsjg}3Q(DC<~geJfwSV)W8ANpw$IR4xu zlx~`YgXOV+Nm%|(lTZtogc2(*z$CQBo#KB6xQG8RlQ79N2~3laI)ciRYt!m>PWh2G zp_8ZZs-xSj?ngR3BJb!Ytsc*z9~p~`uDs(59X&qFKkk6g`TtInkm>fXFbR+ilR)bD zgGn&EPS#hP?JG6lF1Fw;G7#dLr-r19TZ*1A$x@ilze#qqN-`bn_H@fjnu6k_>rIM$ z<}30eFWRvv4L#o}Q%Q|vOlfT=vnT^NuH62J6^7G#7P~qmlD{&`ha+i%LLpj9WzDikmgvqcK#MHm#kTvIL zECvT1Ugh-Bw`64%1|6d-jcvW71Ue-K50+`*G{xY2t%=iNhtG20u~fc2`w!rugg}?B z#hojANd8B*j0*94q^B8+i{gFtwduZFk_h&JGK*yUuEoxF0u8jq$=Wbxw3FYh4S~uJ za3gy>{%!|mfZrc@eyH7be1p5rp;ZoIUnIQ@abet({(nMTy+c1F4(9XO$P}_;2@Qta_I@QjgOr#h|QQK zneh=!+d}rhzTzQ3Ze0E7-8FP`9m890IarM7ykBnK-mxqx=5wkFwPCThSBtBA8Ks zm@9_cs5J=m;_65I>Xpy@-_$^luYQzS?b&hYZE!UU!dF6L<>uLirhePA(f1E8OvXg7 zib=&-l{rH=1R7dz<>3g@XHBrz;fIiriKXo?nr^~t%486BKe+E&PD^~#06jQh=`HuR zIA1~n^tyNETYg!e zki(Yk3yP0%J|B!sC#uP)8ci+=!toY-yQ}pp0$~DoBlECjC!TPR*MYIy8I*7SrrP`+ zh5)azfB;b2PnI0;^J@XAQW3X|40y>(t<~m+hY7=E@H<~es7&&eutBj{*`$suW)lO}@qT=X0kC#|@erre zKzv$E1K4u7Qhl^@|_3b3oInTD658?@>RTiToiq?c6hRq_U-Gt%^ESasw z)7X$ejpWaJ^gw}+t zy$Ip!4`BQVh_4B6SX3*RchL@H!$LWX5+M+MRwK8tV>K2+7{Xa6>)H`TVY2lWE|&^1 zP}P8WG}h$7K;&DjniPobD9vm=-X2BOk0&ctI9a#g2!p-XGi%K6VF=&lEVJl}E7cZ@ zG0uSNfCLAK4|-=D1JhCkjK?hu^jq8Ibds?vRX=FMczACZ50Fh+O@u%d=Z5hB z*_hB3Qx02p1IB}hoTqZGx0}C8j;tAADAu{hNSnkfB*bV2vfac(55`>9c#H_5>0gUc znsKsTb%HX^u;%2OKjMkzcjMf(7eu5ctW!CmSUQ4wjS;2Z#C%hZxIx zB-&k6k=wqZKQf(cW~k^#a2qq)Ra-YsDBPwFL)f87enw`m#-qQ2Zc6sY3Cktw7$^GN zN(7ef`zjKBavT8zLe{wSD&%^7e*`Gu)-+F|#zb7m=Mb=Te3VH_?q{W#4#Q*`h(7_M z&t|)Vfjl?7(uXIM_1+Hh2+H&b50f{_TS}$XDjg;pCt_H3Sh1E4I*-Yw9;)Jqs@gCi z52=?j*jz4Er4GrZKpET9UJs^o=!380*sS|Dg$O<*h==oT+l@o5$VVXCwGmgSD{PJK zI2Dr`7LtBoFDUzSI%DY$Odou81RhOd(^g{(#cyg63NoM|nnENbcOW4oiVe7h;}CES(&8*(m|k0x4gySq0^#@|TQT5ANbKDs=y4kQ2@T16k+5w@ z5$S~N!E=m&&_x6Y+6qD;pkC6^r}ZArfRN`>$se%PS$q|$vu~=3Md7)p=32s-tmN74gI|9%XzZ5?awRZF-VL2m|laLigC^&4H)0=-= zp|G&7&;nXmyj(~fQZ3;tsxU38@+_)JDXOb0Y8WbNTrQ&V6*nmqx0n{UdKR~*6nEAY zcMTPHFBkXn-FvQZ@1^Oze$RUYDffoz?!6wm_jdW-2w%yVLdgfyl5x+HiIkErbtTte zpvmQu3ULfwp|n`Mbk?&p)4p`Et~6Q!v$|Y*QyT*YQK1sKK+1wOmAc=Q3pq@EbCHYX zD|M?`3fzHfo7eq_i#*u+`_hBhEl=*_j)Za#W54m0i3|q`n_+f&l}Uzk`|M6FgFY)e zSnq|e=RM?A&JHjCpGsN$60z7Tx^Z3pr$-2XPs)Prj0&59Dx0x7o2e$kMh|h3h_F9} za6ApaW(0RLhI^R8z0Ba=XAw^Nh`%plLEJnx{~Kb&N?#5LTKse)_Qnc8*5b0+QQ#W= ze^QThM?juOLte%}hTrT+m8h zx~Eizn!4ZSP5NKUS|oUA{t~zNYkCAYRR0;Y$O$_6U$PeWHg6Sv#w~zbg`ZiAe@Kr2 zZlo*wH}uG0$pzrf{Wnh+ej1XWrwf1k@}22MewUPm%rsK+ehcJ@gl>X#kmOUj_d18l zA^#RI^Nlv)V`Zlp6V7H}h9{?pvZD$|WH-N=8E8+n;! zO5OH{ltnJz9B^80evIlzq&PSkmBq%0&)UW<9T;YJKM+{nsv!3{T(v*||S6?h8BfE&5J z;YOx*HXw!{cQr_<5uPkh^Rfz_;5zvXBZe+hAni{Na47ibY zf7tjJvlp9gBo7_ou<1rhH{3}0$)@Lhb;T{;Og7wz=O5h&SuEd|yWUuhFBxznY36_% zQ3KovS%R9$J$;U4(~YD(;w~Rhh6-RI3;j(z*%%eBlQD#8n^z6kZoCZIcVNyfhV3=I zO;KHE>?Zv#pM)3=JmDM7Q;$VM$W8LvN5{Nh`hnP_nk^7wPhX)Fh_h$RI zEeuFO^ysK9KipeZ7lXu&i3IlR%FV{z&WGe5>7OJC6(mfk?A{-%&*?x1_qiWrM92G* z!9pV)_r&~DG*&sbVDW6T{|sGo0w&v}N4lgeu(E4HopT$@xkQ2; zdiWuqbk;VT))BAJ19_}{bx!3^yiWRp3t2PX636$Nh9FfudJ}zy1rJ#PEhy46$)L+-LmHS7w%L7` zL2)au5qdRyZ_-}sQ27fIV1hie$1He3PH#tc0+f(B?j6Fy_r850o^f&X%CWDO-48H0 zsf-BDvrdA@i|vnPCSn~RJI{{F^~j5w_rBDmXTHHn2B&C^g21X29{Y^e$Dg{QB{4X# zBeDD_NDLygt@$lP6NJxbi1u4gZRwM%L|i^*4@MsAU*n)~cO4k>TmL?;5MBRz&qYwF zkWj#0qRV!WF;d2V=q6V5zGB(b!$bKVZ0lz7(XoOA>!Tj*pw(r?=hF`xek=+=guOyT zPZ5N(Uyp}p)K6%*%msX#G()qCTgof{_5_GrQEZ*0F4pQvuR$R(RuHky@QN# zcWzi0^^j7{XqzJ+i(XUu3U5lE?TtQ;lcIera~9$dXlaelVc=-eM?J6=HxB~R#e3%~ zK)Sfk$RP4PwiW^Q3Xm=jA6a@M=-cg&!}-FxX`fC2SvPkL7y3CKiqag-atTj>Q|yJ+ zJ;)1Ibd0@TE{gFUsyEI#6yrNgMZfO#eNE#$8pKIAvgm*lp3y8O#UgK=He1j(}(JbGNC|)33-IM6o(q_rAsMt#O zgQ!AK?XI0r%^B#LNEQJOX z3-H0NiviMZ!Jvd-T{!~O$*+rt6Nd>IFbDzM2#L$oU=JKSYxR;qaMZAz*9hI?hGJ>) z+QY-iP(h~2SzME|pa97SIl@Jnf4aJFJ&%70HuAZqTBZ!*nv?$tK-ob3q-{8$Ef=NL zIUS5GhVXXz?E6m9Sk>%@q4rwGRGFyX4;Yy@n!+FoCTa8@pIbs$<@-;o-kA467J zpjnQ|0f!0~-)mY-;R#U)G{f@J(JJ=6#8@7WSnO>el@diCP@F+nv#EmgdXctyHZ}AJ zzlmt^+Bj8x$7|L4$z+Do7=1ISLbt*cgzw?m2&(AivNH}r^b%29X0Pqo3)*$j0v!Od z#d-9RKNg1Bv>`^s0ec1B_KKE zTA&;7NlDPsibipHNsn_72hZvQ2MGnr@?`y6AgIEk6CiZ1(e#7KN_tMdOB0GjiVc53 zyzr*bIYqGnA}m8~mOXzII?fk^-IF73wIMMi;C~R7d*m+nE+&=9r|#fN)(T`JgCy&%1qv=zceqQyRFBhezr=w*McnIhnk z;weVR!dtw+IK{SGdKN_A*Gion%-Cj?8Y{!HoeSwA!ui^XY@f?|BF}cg$=SY&9XuPf zCz0bhV0j4WDO$w(NP31`(ujN*SULl>#O_Fg<^_SwFdTX|Xs^#^--v%sXNVcE_>VeB4k#nodz6z`xV?qP|D_kxdlaHG(@d0Ji3I>HA2o zYiJ5%cV^c(C*Ck`XBc2bvbb^Zn{-4XRX>4(aE$>Axq^x)ls!S5fC-Wq+Au-UotCdj zwtxw`hs$@F=cwtm1%Kc;Y8VBoxb{GlfTXiFle2bwLayc&7!4IXue;l@sf4si>^*e! zL@&Cl7a6TZxWB1{dWjrk7<4gyQwb4KFF_m=G<4^t64KOGW+Ss^JF&ONpg-X_ddTb) z66@upyQk#uA%`=L$830__nzu2HB_!Aap-V;&L=8c9Mrd&-QW2nS}jI@DF41wkou05 z`(ndks<^V_6^#4Vu4Q}q%P$x^V$906)@K|{Ef3Hv7xu~*2`^XRuaLF_Z6#LRG%Y{w zRjwdjp)*_o`%q>DEkAjrQa;t^s3B6ZzCypg^6V4!)5Db{{wkB%N(-|p8?P$6)GGV> zD#zif%PUn*{MF7!s$I>h-Mp&ZQ>#7et8WZf`>a%x`D^@+)C8E-1bNkjq}GJh*F+50 zM6J}s@Ylv2sZB7eP4ucwPOVL;uT2}S-2(+>@Yh|Dz+{=#Ih?4=O|7#DuPYp`J9h+A z!e4*p1O|+&uPQ1BJW+Lh{a!P!hLw7D2d+x?h8C~9S_iJjsSU>VJe|W0yN0mMUmKpE zxYag{r3f8)Fi;fNZ-#l6`rxfuNMHQ}==6gRD}iJ6ydP5=vqc;Kb3D;LanWDitN1sm zBH%5;pQkuKpCkNvhjUWp0PrZ`fAK{B4|<9Pjp~QxMX>k&Cokm}2Ii)Fhwc*sCUgPL& zdZNx>GBoOdCz9E`#%cQ16R{$J3=LbS#+{7}&7B(`HayYi-}XcufG6tynV~6^zF?yd zT;uTkxhE>y^hE0ATLcQU`F_(AJqA3{eJfSK6FrZ626&?7|1?j8()gELx>5DZ`KHvlp;m*vnj_zC06ZPC5{u(;2|?>0P9 zMEe)bBKraU(8wuOFo;b8csA zv2&F{B#+Ai*Eo4CptL<0)8_deSQK3~&0lgBqC<`9gv|B4Ta}!^?M!7ommRr z9|3CRA6Y}lf_76m$?Gh;kcg2+#z$``IE(fkvasvX_cz}`LN`Y7(H=s+G<{uyKtTy? zl>2DO7%zxE1`<^Rl|K9Aop6`$<3!D^OM)}%Uz>X$VklC@5uE271(9|Gk7d6cxCAb_ z(cV&3hCKV${C4Wvq<@6cnLW>fL;QOMy!QvPKry~%vrBx6hou#%1TwTDv*!nhf>@3~ zMY7|-P1Kp=r;pEY+wL9^3UMoCMJ!kcA0->e1&5t^`>?dS{)3R1`khoIx9P04+5A^` z!3`-Pm_kaA6^OETx!7SH?ERS0UcKJcYx`#5$`=)zAW*uw^*#`FdY#y|eqPba6+xVl zR96SbVy=}g+}?>CcdECw9tVRJxMG9rSXFb|x;VMQ}$qigDN4ba*gvo*4 zt(@H>Uj2uy3UP!zBE2uh9Ra|Ge#L@0|Y$hrOU4BofT#u|ww7zUx=51joZ z7t5uknrUF*LRR9`BBWGX9yN}Z%)JE3z9}if$vA^t%eUyww*bqB&B_t%Mi?R0upoAM zt7%b!p-3F#PzX2haD|CVrW(R6CS*fpRRkSzO@FF5im-x zh9bR0L^>D*X#xg81O$vyq^VI6L5fk3W{acJ3I7|NnLT^&b9Omvt#j7-pLx#1kPBYd z`}g^3TR2&3vdugkVB5TO(bz?!))Z*CLRI&z$3;Wv8I&7QcPIf7l2AFGpgu%EghXa{ zQpPnIwLJKb;R-bx+^XgB+`wh?L=Y)L-k_g1%f)W5vb{$JX>_K8FtKE5QjTS+s;SHM z+omZT>RYl0Tn}ZYncUa~)rX)JomnHItf4b=q^L1cDR6SircO|rj&4rEr?uF8C4*PB z7^_vnjqOvP?#x(1gh!u7Vy(;+fUqd^`^+VHJ3IHB?~R1IxKnUR#2N`nn*%Md%0%PEj$FK;T-Bg zfj_gGa>aoO*G`MHgU>HP`Fo(|5kb28cyt1gODE1~TzRr1=BFyZ60Sdr5w;5*#&Tee zLV~56tvlqu*qaBv#z7|Vj4sOrY9P0Qu7+==!-b}vTD=SP1V3J`B!FJY(#8twj=Y6DBDFf zhoh9Qr(`MF#-(1R+kXKF&^rmK)TQVSH z>}=*3<*s7kFH^a@4LCppr;!z3VP#e2Q;e#*zZr@qRs#;OuLNDW%RqwH4CG`Y)B~VspA>fQNZH0v zq?88!OIYbuhhoEBmF$9?Oo5e34zd!&zu^*I1Na;4tUyisxojYxh96?Pm3#-YEH17~ zgD?sZ9&xeR0@Q%^UKuv(8h7Dk+6ax%!NX?JQ0el;W}(Qrqwp#&KK32XS$Mw3<3@;e z&5|jE(RQmn4Yyc>-qkbUZikwur^B10Oj{*wy6x8bkFeAyyK4LIgy?fk^&@H%uGdzz z)i%u3jztCTBiHF9)H2Fzg=ux~tnZ43k7S^?joJ)u7uV}t_$Rx?uvKm|LQCF>U(d6dhJg;7`b$POFEPF@~+a%J++$q zejx(&+4~yp`kR&Ow;iw7^sC>IUawtKzq_|yXSN1Z<;Eks8%>Wln)x+aq&HgCG#>A5JUQD)S8lS|-NZQF#PVyhPj7OpX>yu90tlPf zy$$e158TolT#rBS+TY-r{=jdx9`WRX6{n^idZQM2ym`AafI8J2IeUL!X>+XKJ$-6( z-0qfdQO)|)mgL!5*2R|e{gpARS~8Sds~lTmJ~v(UYo%JXqF}B0y{$P$t%dtZCA-_s zD7N`h+bW%+N|pcSCu&N|7A}1JFYMj?`x5I9VcWWS;pWFbA+r5fGOYg_g)N+|2b~G| zV=VTcMA({s-@*BTZ2x8i=a0K1f4i_j2T1;hyv89a?{NDY$8Ki7kKK@W{|93?KET3- zkH2N?X7V3Bb`$hJ8N12&?~dK9YEv&4{v4%#Fkzeirl5mL+4B|C6 zh}V?>w=-tyo0|zQPaQ+!%QrmFX498qaTg-e1D?$kV$|t ztUq||o$&&%4gJPzvGMTqAH1f@f4#Jhy$^b#MwLsyCs##k{Qg9(5+?WAIe3BB>4yN%gWvH12Jvq{^3C)MFLGIZJ7;ao5EcHW{u$t3rDA#=qFxy#39A z)$r1HALvW&zm7ZKHQ6*TdZ2ln2Ex16tON-^D?uBwZlbQH89`&jB75|jl~YZlKrrm~ zqCxU9UEr9-gG--Fe?jK*l(q-ljG5XJYvyFZIhy~og!?%V4&%RGaVS$tk)VJ$*`V95 zH6FI}2vr2nom{esP|n*hDwFH8cZt!hhLtNWM&vYE1e3bZnQ`xNNE``f6S_}v{al<^ zw18t$xi!yOov1)!uQ;?`N^wUF1`!HA&4$`5tvSXz+2CiLWb%@)i%u)Q2$l&r_?ZWd z-RQ9d!X?thr)I)}4gB6bQCLgew@#t`_ymRQWHPB6S>24k#+XXD=jwDUI5^e-*w) z6uO7q?gra2*Ya9!EB0!@pl>=tiEmi15_^xhrOo#3{YTY9T8Ekxx4AD4$lodbIMD8B z{&9Gvz*Vzd$Km@+J2oP76nbbU+Nr1`55hxN4f%)o4eaO-y=@?LamI4L4zKqCZg2?8 zQu189*!itZhflT=07oj^c z=PrJhWWXMOljrnb>BYfxblZ;upuo=dotW}&f-y=48^df{XrM926OQzsrx6#4DQ#qy zgajH12suCitOIVd9(63q`>lDY94y4o zUc9>lHTUSpl$;_&Z|M`o=OgAsPNxf(%l+=J)} zp(Y`)3ub1S8dAy~c$ z4Np+`f&&Xd*>nc5?69~OY?3tiWb-0SNfL10i!rSL-r$}Q(j6A5 zuvlaeNoG9|Q`V6#;_c4J8U-n&Bp9dc#nF+mv{17PFollDIW6!t!2J|%+cO!jxjuTX z9+~#b+B8ErUzS01@!)=G4qK%Yt$udRa znmA<-A^~)8*J-XH)V&t?P`v{LFd9elZ}Tz zX&a}to6^3MwNSlrLwbO$y7gd>H^WHGI{61)L^gBFM?kB*th)5<0IcT(!686K*v zL63}R%n zv0e@6n7r6453UAq0>kT4j)^j3lpMNkH&6n1yKFF&_gT~7HP3HGjWr;qaKmkVyn!FqrKe;1wQBRTA zovOhkMEC`nZ9=L!P+2>CW7Q1t6Y2CqYo~`hWqtkUp2}@Am5U87P~Z7ycM?9lq!2W2 z8fbEpt#7BcRW)4qmg7k!(9Jk}U}X-nzQy5-Mp##(gMk`rPD08DLeP5eT-3a8B#u@B zAr)^l8M~$!hXr{yn#9lY;9*gzO)k+$2X1Jjz`TlnNxtB=b!Z1=1e8g&wa& zBY#ICF~J1<34?`mFg;PWuf-0KGxEhXUUI}cbVnGHct|cCXz(@2t%T6<7Y@#HhA~6J zMp@JuCKzukqZo2E?^=3-Fo+i7d0pD zcZ!ZSjcwQE9@me5zxT3V%hg^RL#}B>OUuFi`d9Y1)bDC7X}2+;x9W7X6z+b&>utS} z-qy=x$ECO0mba?QwpQeZ+J!0JcsOAJ(1NOANrZ&3$Vom$K=(fi- z4^55R?nXb{xVF7__oE@jOal)Ls~lw`ccZ(veQ@@Xc)#Hr<;P!-KW1-xJf8k|y5{k0 z?_-uzDI~WAw>HNr89(>S|P4aNSG6nmOoj%=ZWlzCvyHz6f&MD)jm<~d$MBg z33>U`)q9?`Z zmjC9E7a@n#{!bn9A>@!{3=|=07sOjYbO&32*!{m9-)sp7CQU zORXFxch~EJQaX4~j`v!wS^>2bdJd{yvr6u>d;P+5PFnrNu<0j)Rp^!rcZDH4M3^|N5^Mj$KHF#dXobFfZnT(47-}~$u z31LqTKo0pUWwJCzUX}}tH;2V1rK4f{S1sR*{#EDPSsP>~to1PAoc4XXz**h9rHRB@ z+If-b_g+3wEx$<;SB7H*MAt7}CP!XcSH$qk0RiVqgT&#(p@>$V{Aao=ckUG77|9}V zGqnDUoQq>S1#-5c8)G&c7~K^RRB)wB@5oOuX+!(f1-m-Q$clURt9Gqvg6gF-+f$)O z9@rw$jJ-S)Jj1(z!}-$u^UhY|W+LKt#ZcPOY+_Y}?%Jwftwr_NCR+lv6)<qg!6s<3PSoV&|E%7+FEKS3tEA>%NI_Vlf!^u%1B(Ps(mpGP_@O)R zXEKi`oDdq?xoRM#ahk)y@+WygNXOUU*< z02>)}F!TkA%Q?mOUb%bTGvW5(Pm4&q+g_4*U*-;s12(YbXnEdPuyR~koIoLH2zO(G zI48hu<~n{9um&b5V;dMVbToE7fZ?F-@LBhUDfM=KN|k}T8NkU~(yKb)K`b;7f(aUT zQQu%Mp}Eclql21);6iW9PRLJiPMs*Ag9BC&=GO#SCKBLZ1NJ83Q02(9yz@b3-(gd* z0Gq}F!ZkhzHVAjpM8O!SAQoX2G*V4IBi}_)Yz|~tgl=6cbFxt;nuNp)sn-Rpelqw$ zz_JljA~!JZlfWI7+fJnK?lYyDrbhm!F^KepgNb>%<6H zB*n-hf%n+~rqbXMHeBI>8wy-!z;*>D4FpZZm`(xV3!6ACj(Jt- z2-_BB*@Pi(3(ZT89aGrrAwB_pL-@!spDBP++=zKWuxc{1I?vr{94A4b4o>3XG6WHc zfL0__$}^|H=)D!;00VCcMU7uE<>Q0~uD?|Ic|8TtQovf9>~))Sj^r6Ke>9@e%N`#R z`>rxxJ%S2~WSre4GJY4R2^*r3vT=Kd{HGL{5Pja2V(jUr(CJ=#*Ix`(U`|5xNxBGG z=Xmo1eQGFxGj_2sH@MRf2IzoOT3o!P!73@LT6a9dGLdKsixryrbE!_>^|Qr3$`aB~ z*=P{b&w777MFvihF-QVU6q)*pAfLt-avV)tSauM83KsBlz3}q;PWJp4z)6l^V-(4P z?o*9|I5skl7r8*7jJRz(z}JF=U8;<=-AP~tckUU+wDj$3Vvi~VlF-$v3`j!lz|47i=Ex|eN|6yKD?iPi%Sld!(ctQz3T;qlABHk4BZT2g<7DHph>>k7b# zn-ON$p>q7D(^n4%n%r6tQAzOz&|JVe`jX`jpR@di$c_1Qe0YDuhoDkHKvM~ zVoJ8R5>sv@GZ$!-gPGvwk*$iW6~Rpa*2DsomGSLSVvKR(;o?$DR=QKg83`hw_@%@( zs52E9#7Ick?2+`(K4nc>H;it{1k;h4B2Pn5xyBzIB62*kH+jB zkMhWO-PA*o5*LG=gu~bKErn-JsQ@=4%R<|nwU=3Haj7{1`>!Ozjua+1oWoq70*+&0 zjHJC4gckzr9UlIZF!SWD;%d2yR3(s&Wqcyz&WMoTLV*)BPfOPAjmD?H(c_(Lda*0p}Xb@SneIqZdr{+LXWx#fKNKo%tJ10pT`M%xxfZMIDTGey9 z#g(eLl1tSjsq42+d%d2b91z$;gldv{d+ri&(b_rKy7H0l-M6S(=UWSqx=EaHt%CTp zFkermu>Q1H|3*NRmf0Cu-FL@>JK~g00z3FgJI5$L(>>eXWl#3pOUOlpUzXV}q*?%i zVb6*L$WqBpKPMhcXDsEgIjyWYVKI<^uC#Xc@-E$$UaE;ZD-kjblbJ_2 z#lE3Iu(hZ!Y0<{wx_PicfU}R)#K_wID&CExTgI+s+(d@e!sikk1xE zx=Qlq)8P0Jc=jK<%Ew+4p%BV@Wq7DO{_nafz-Dt$`JQ#RdMO+D*dF@B$6|D#J-C>U zDiYy_=!6#>tU5?tN5`}Y@WX6Emk66pFKn4DX;*&wa@}`pZFtBmmz+1@>9wju4r#eI z#&rEdU6lj+Y7d{1=bmcgt&O;*IOAt;lAk(NJT>Zgx(k{fxq#I9pu2sKzSfD3oiQCp z_E@g=d`fkGrW^B2bJz3Z2cEaz);+LE|4@vce&2Iej2VQjj(Zx-Y8zSGIt@%a?e@H2 zDRiDfzR+L$!maj&l2`WOPzIYr)BCzJ=el{zUtZnwGUvq0T>qE(87~WKUl#SfESY=BU*2!d6k=o8>PdY#s3L&p07FQrn;8Ec^OlzxYGfTW8tNCtgK3%Z+Eea_v+2 zRQyU({On7gLi^k+JWNg{r^b%`M=d$Q_bF9-z=%w{O4!Ye_`wY|FD&6vZz?S!zE`ERiG(Z68p_62O+*=lfg0b8&7$u3~4;(rZWUqaZ*{1l*t+{KZ2fC{@1JXXPwnTw1GYX| zz*f$Y-`-Sj@#l8@fvtDi{)DZT5VnT=j;)%Plkofgz}7j2J^l}DbzY~)57t}z{0Fvz zM}FJhTa6co?YCGe8$VKy-uoN2&Sft|pMPMhiNON4l7GY2+>7wc->|iEm7IXn_#Inw zVDfj!Kd@CmmHX`f16y-W$yJJfVC$HTd_MUHwh98}%j|!{)>Zgo0KIuX^#`_kA=E=p zMVoOTY_%uLc8W0c_8{3`I2?H6jMbOv$p@!Qkv1=Ss2f7+SI!&Wgw7hqIoI$wNWOti z?tbQWX*y=q;#~YhR@9O;B)nzk&G=`l&3$?iv?HpE9pfOQP&}v{$UrATN=1 zez&%54H}sqmfUg*ZGHVdXH~v?Y0_@+mnd(UCjPDM(p^aZ{k|>V?BVd`oR~B7x+{}c z(50(CfACkR=3LdhRWm%-fXnfLXKi2;obslBRM=+7|F9_VJcu8e|MoM{J2>qb5a_Z88dH ze0pYw_CX2h@>Lu^C?VaskdU7J)yu2*{2iUD_~+`Ny2{+jH<^>q9#g@yDsv+eMAg$f zWbo3)6DtX196?baGeCcDWd?1)ze|)rI$KbA^bn+Up)uVl{W5#A%9} zdhPB48@%Ng=L+h|omN5pyhE~y<&ERw8~Y7DUH0>yznAs&9bbP3`o!=9DgGB;+}+v3 zhQYaJaFV;!>z#5jk$e+SJ}MDkZ0;~CKq0xnu({n=09?bjZxwmMxj+NWZieG7m&fc8 zu-EX<+T`Nl<>!|gctNe>i0~|Y(9c+o7^Ve(z_)*_;qUhXx0wt|iwN%Mzf(Mp4a(|1Fq{WlV9z8>d3M1XOY{7M{i6wo3ND$fhkug4v~4PhGfDx)GR_wksJAkSf!+!F zq%g252{!8qh;0Ez>r>{IpbPUD){4UM)jZi;cJQ-;dP!0xm_OIV!HEPzXO{|>(DGAa5yL1YT!Gd-X(g>kH4L3?=x z%rr%?R3bPl4CkF0M*7fOaLnfj-aIV>aLX`B%mDNN%bn~ms_@c2>&Igj+-8H>& z7IsM!F*atzJTWkj;Mz20&mFbyFRTIe(zwW2Ga8qZXFQ&~Xj|dX zbg7OeBH=p+>p&dr4xJ6**i=XsLRt6FC{#5=PX*A}$jTK{N>hk0^Ih(k7cKHB!pUAW zHj761mb@D`XxK`=}UkMdzgHI?tw_B9-#nDXsyUQ&KlI0rqhFJKe<#&|(5DItz_@D6w> zIULn-xg&{hlS??K3P4*0WGlPH!xX$lY4n&c@+(F{#sX_|E~VareMLDp!3T_UZD*CJ+@1 zu{97CbCaKA^V>peFDQY-X^c-AIJO9h3jQ1ze|) znfj7aNk2W!A-ok74$6a@yB&HpaFrzdD6iCLydECXeP$*;`H?DBjhrs{x33#*B)%ZOx}~8(DMgR3HbfIqUpb zvHf&k**%tW zbg5tsnN+@MvPjzNd!ozNxI51!X6^^IrzOOk>zL2!jMpS5sL{Pie`F&^tmQ$!pRrb< zdBCf{8|Tpk@5C?}Bit5cIxc z=A;mpr7b8q_JsApvT_*)I=FIxkA1E|7$Xy&kg=|5k8Jd-DV3N`HsO{$cAXGZ0Wobn z{2*^(N}z_0`9;*cDgm(OU}{9TelB5%hOY%N76LzZQOBGsyoQfIi^sDCQBbmbT!?x6 zz1Xc1R4*}w=LvAVY{D=He;33M(m)a&-AKp3;SmPu_9@IzGK0kQ~y`5KlIO@5FLK|YSjP5t4*R0d!C>rA{rA|qValQ z_7%qE)eB7#f$&=SqxZMvBE^1DNzd!GA3c%_nU3cACunvB>iW|s-e{~Ci23^F=aU1P zI)>W<2DIx2cJ~kHNCvf-!czZ5UNL-5XZQgUa0?iA+D!7Q z8$NAcGVCWAw*5HF(HS|ZLkbBPu{ubCaYmvKzJ;(kX2mG&q-^}jQDYa`D3{SxoxwO4 z*)+*0=px6{8Po1pNI5u`|LAo_zk)~RSjnSTMJGvllCkoD&g(1QArHT+KKP`vPX3PM z-HoVse_``q*nGiR_s7wf|C4d=U)cQr#KORyzp(kv?D)G+ccW^f?DfBDrXH?~hZl+O zm=wXwFb?Z!s%(dicl;S^Vf3`oq^}v@`ENmy+A8x3)}-{e{hj ze_``K7dEHqyo1^wEVDs+Zuvzo}05;3~hRyr_8L)ZvZ`eFR`p09lcF1>Z-c$dN!Di|Y zY*zjIVe_7lzX6+deqgiQ|3+-~{DIAi{|wmNME!xy@c%Q|+(r4{fz2Z70yazgC~zE^ zD2%&+`hGo2RUSM|vuhzmMODDRTG&-X@i5H}iif@P8SlYptMBnJ^b8oj_MCen!I9*F z2?@d}f`o$a@i2h>EgmMtuWC6B8;pd82=HGCSR=5NW;ZkvF{&WB#Pj@Jn$7+v(#-uX z&BEWL8U5dq=8jAA+yvv6gaZ-ZrCI9I&)rEbQphVp=#+`MLWr-C85a?fWK_WcRasC} zJdnhQ>!zII`4}f&zL(FWOi>ebm*~wlzK_SxYUJJq1qV#z-$~PtL&dw`Z4*&x|=Dqzf z1rBvjRVYH1km(IUTB0z8aip74BFaQf6Q9a}9aW6Zg?WX`2rc5%<_xREXjlk)-ZtLX zGgETO%drd85M^4Eu5|6YqUPfY6~-PMsTdD-&Vo1xkc9)?;A*S14UuW8)mLpC9kbk}y-CU62Ny0I zLz!{IITIFfQiTO&#tu~rnXx^PBUus#VKdPu1uB6hHazaD&Jas;yGsu^?jGK+R9N5G z3!U-23Tx*}iu*VLp8$L;$Wn5PwN)zYee1d@(lmiX-IB*Vrwux#GCpeLS>+bMJ1+Hf zQj+tT0wMgz5!_9}7^+gnGs~P{CDcuNlzS)E#LA8@10JQ>If*FdV=NuRFbfR0r;14U z%B_1@Tr(a2t7_pJ?ZUWy7fc$V#RPAg_5Lt7l)KsWUn=c=WWbBJ8FxjLbu_!5q^SYg zMbEoIX)gNgB(aBI#8m_1su^EY0CqKlFAtrXF1}aDsBZ>63mMVfdwJR=%GQ=!K^Y2k zWO~3ACooOoPyrQI8yS2>lh{?N@J#eW;KG|WEX@2s>J%?#tit#H7_XrfTv5n)8=hsQ zpK3Tw6sQ4jq3qbyB~b}{Otbq&2Uqhm;ACg3CLd|HEc(mi%V%y~d5$o=ETab|Uz5j# zUY6Q_OIkq|bs;?8yu#k-baq)-2y}BOYW&*VlDJiNpbQr?!7Ij2=glq2_gD{on6W_? zptX@ddLwHLNP-J1Z922u*PoR<<19|guQLE|h=?}Uh58p$gUSnZz?K%FE^lCsVe1;rh|a1k@hGL3Z!Z8GHuAHrJPPK5 zu@&E=Ci## z4tSR8wd1>L4w>`e$OcAxnbA;VXdB~&NmFXRPY#qR(^GVMp-kBuE*s%H{6KMAV^Dgl zSl5RsYJS25VQbMjy%-sFl`uIlgPK}B<9F~jvDo^n$qi{ zn>v@ZBZi4)<6+QP57or}WMz!^{%m8~gDdYT8bW^K5E>fBnRhxpr*Zqda!I%H6Kf{S zLqqx!PsZeeOe{xgp;!j6;u+!;zXj=*Jx{7G6iFT#$r0{B)iN17pw9X%#NL(x2lAQj z7;s5Gli5qbkkBoH#Xa_c{fN(D~M_>Uu(}mlnv1{3&J-Ndw zl=x6o0KB(ayr1F6ufqru|30 zEXL;W`7%fti-zkY6UIP7Ckb~QM8C(J;q32hT7GQr_cKuIPrP7+1f^*i=1cr~JMgA0 zF{W(~bsswG3lA7RJJBbqJqk6=j)i?|E%p00^))W?#U*@SAG4H zn~v%{=pCNx{raKrUB)Z8&e0j?ez%8x^Tqw7JtExZqfpaKB0QS)7k%+}2I@rk%-2uW zy^=V;#?)0St$6eJSb*J7T<}k}IbI7-Um~(Ilv1lZOFa#~xUdSA=S^=Q4Mq*vw?)y9vPk5^z zIC3dq)acQOe&A@j%OLB?C3DbD}!`H1PPFTX_>_vgn>+^Yb$ju|7V3NMd z>yVSn^_?`FKb>LwRl~`DU)qa4VT1hvPsa> zRX(R+6Rvz#NPEN8lXDonXs)Ukzj))#k+o4b1=8^f+dA!;F;51i7vuL#k6ep8Cmk`E z%t&+?O4%Ry>tO1^pkJSCFmG~ryJ6+M&u=qM-VYqg^cx=?zOu5QWr*i;4w;u8{jH1|C;mZ8XPFZOh2DGQ*UM3gI4VF^P&#gd_Q(;YOnc`MHIKK?iKAbJZowj*U8` zKXK|=_%6mf&BxziTY(ebvA`#!>zKkfTMaQf7^Hp%JNmQBa*Xhe0{ zIY=U?)841VIhaGBzPpPpJJIgfW2U>u#ZmMyO?zPfA+=3}<~3+IVG|H2qmNl$3q ziQ!%^sm+t3eLs6UB&<5z?U1`uqz5QEKAgNnIbIZ-FiueH6bwYYX&935>mHIkF$ zGCi7~S28_Tbo=AsGw;h^xXgU094?vpSTpf)=956`r&)1>{F&L$%^Pma{@Skf zY4(fI;HSB-9p-1|zIEB%m>ci){4_Tq3j0YiIhb@tGBuKSLo)sT_9w}V_`y%}v->dg z`MJsA8}pLciBHfc5XS?g3NwNzpvy2E%(8D{+uQI}feZe2?HG z4kqKWqz(RZ<}d#Y&g_X`bW5JgaZ{rqDt0 z+g9z@eU9ZHhYpHAaBRB~aQXW|@dFNKHx>?x`!nD}+MLS%8^z|guAiJxGh8mco#fki z{n(Uo*ZrZh(&zC-^bE(YCT+RcaC{+I_Kro7&iKvbO}30ZTclgf67y5=#rCz8mV3R% z%jnCC9nClHL-mSCd#_3KNNjpw<<*M(qKCyUafTe7UApKFA8X;q@yh16S@?jN z=Z2))l?4Y%-13&`-mQ3E<#55pt)Q*%V=4`vF4*X?T>+(8`9{7N)LpEU58v9mS3WEJ zK8#35Y1P1Moq2m<3YGA!f;V#3906<(t^fGQRoPSFK{5(mNUis9xzqMk7@iAPO{Olr zTiK{`AGm<3=HZJ;`ddt&T#!=`a1Ma=Y5}k)8v7n>SBDF&)?`2_n6xNp`3=-1fDrI? z-~sqCOsD)_(EO8u6@~|YRYU?PH{{fVspdwYO*FUxp}72)$t#-i=?q(_Zyw{{+%vcg zTtcgVU?wS*=rr8J$b9+9PQEZ-7ky0DqC0NQ>v-12$ zsU0_=OFv$^E6>LoY&__vtLS^bDf!V?R9c}-mem_#TyqG`eK%Fge$5lA2m0DKJI=Pd zLAd;Y2T~GGBqe+LftXF5e{<^X*=X4Fyvbjr4*I_%e<607Y3|)ZI}syU1zXuj_;ojz z1fq|xc{wML%AR@wbJ5$i(x)Qp+V%q<&uNL_`Y{3)?DSow-S?N|#L}Tl<{tve5lux0 zT{R=|afq)!VeMlzDHX+(lay{ze#zc>5)kJzKiTDwt6!RO@9w4@%FRb6SIUf>IAb9u zqQ=>k4@$6^M_cxx)OWjPGFu#yK5&RN0QMGeg zJ~mw!`Y%IxU!tDr>ZidwPvTTGPYlEKe(9B6o3y3%_M>leZ?p0**n8V(Renk*u6bqn zD(vnTDcJR2W>-i{S3i#8QPh-IPc+WYp^*r^k)~a#d z&0WD3pbzCFmy*6FLw35p>B^VGv5pD#9}YEL=$%*pwoS_Ryuk3w9oTM50xX!6v1#cdC+n0$7V9(?n-E8p+xw|gUq-IRyFHouaRq(|Svihz1UR~`L% zhV$eM0&mCc=2PByvd`vGjGME^C%Y z*~cej3j(Lh;0~RqrnaKan-P|S?lYl5d&2B&CK^&a64>03FQ|yCJ?tf zh`2+Y&>$nSn1+8xB7Wl#kw)B-Tng8oSx5&TOfn`bDJ@*q(y!=Sza136C|8A?+#39F z^5TPIQZJ6dmgxCY$GJDdqvq%rCUhezJzO2eCAf)aNc$1I=G4NKEo~V>gFIt`6H85u zoH~j(%O%b{!`vPsG=OZ0Z5(Cj!n7^Qp)>fhCw80X*;C<^as|c=XphW^pQB&WO1Y%% z5KkR~$qo|d`H4>5i7r!#PDa?}RncV2q&tm>47yn&&3qBkhvE@CYnw=sJHIJ~^;$q_ zo3IUYpqwOogRF#{Nrrj^rJa^Avj$GLOn6L-o*GD)<8!CC!X270Q`ztc%QK0|6bF_c zdN3KxJ-s9+k-ammOf|`KUy?^k+V$~G@J^` zFa~&(%To+)0c6AM$9%^ONeYW8pg4HoCfC68EYHY>QSe-rhz_n0rBd`!4n}F>(ZmV| z%8G)taYZYP1~OK3>=ufuHX1UEAS7BQnQPp8C0{3Y2Acx za>RFhI;m$-)JnG?322kgO30-l99^K;eJFRH*d?<*RSmT7NI-AL3GtrW}% zw`q#8I+ckX417hsS8(3qMagXO{56Ti(TX3Mp#cPUB@^hsLpL8bu9O zMNNh9dpq)lsu_kD@SqrzoXo1>Q8H6frw}NIMD#@VHJkN8=9Sl`vg2o7q*{6v4OtZ} zZ9qEmOWwaM`8Zwj-UrUYoVadvBlPV#Ekn~(5;e$J zg)AB5)#}1hho%&hm&6t|U|KhNS#)njJGn5nHfms2aI z>rtl?;jId{U3b+SJ$8HVHJKnX@@JkG^_z4A-PDUrRkKKNv!j?wfW%I%a+}=BgFxaG zr}~Iux1qs*SSv7$uJ-iV(x{)!n6+;QdKfgnPPc!bQ-8qjYNJ-yySWlSDj-zVE zUb1j2#l~9q5a07E_u8Y-VL=2S@e>=RWsX_DvyN?Yqap2{39}|@J7V!#OvHNPvm|@t zr0X`CnUrzt^wHRO2Yd}5+btplMAVIE*Dcut4ugc7t$x!>E?%s_-)Ccm9KyyKTREKWPuK6+I~`Sy zHb13BJsj=)E~@eyi$A%fPRF1gPOnehRXSaNcW2T~$-ZbeY9z&wYd=FGR%lmDy8=Oq z&ikz0LZ6YH7`2BfNSQ;tnnAfwzAcQqP@J@E0%@k{^z*t_qbCii_^ z;4dKw5K?f72&f36h>D600hKp&un`2Qf>D#uM56Q}L^@IoA{`+V>1Yt73sICJmZ&I* z3Q>9yLNWAsUv%$v)+zg}bI(5a+n@$vgrznmvS46{$X=r6X-=zI0&d#r@iLp9pMVo>0-sQujZ&-g zqZ+S{xt4}MFv`7F*@Vq^0@1X`Iu@+CAVkaY@};0)jpnwUeQm7uuM~9!eDES?5yB!g zO+DqN*rsS@2l733saXXsEg7_8d2ji1@%uBDkTm1#1b9 zIMX)w7AEKGbde3}GG6Cb;6e}aTdTIcQd&MG{%f2V|8ty}Ca}&FD50{JskqWlV0dJ0 z9*qqmv1LfSFc-B{oK^><&i6jYSLDsd1#GMWp{$^V+ti8{ol$*_r@#@)Z4L_PFG`V2 zdh)Qc(=qky{4%86H+nq)15A-t%OoBPo$d4J>AN?6HYU$UMRM0@88AfKWwFCzST+|G zwD`&>v{pVG^^S_0Tu*QEwtGSmEG}}Ix&>@ zw|AhP7fcxjt*nGCqc3qIeh3O!GEgrG*nT3W3aCSi!ywcC__G9EFNWH<1CLckKH(EE zU+CCg0)|CHo#Wp7TJh%U^5-4>f?Dq^Rm9uR_9frHYRE-*k1ZR2-C){hqdo9Vcj&g^ zDCEo~#yYa5)a^Y&hCL1m-3e|z^ER9{4|;C8YfpdH{T^)yzXVM?8SgUh{_%n%aH*$z z!x!mG#;eDBfUDhW$2n5-89DNGZ+fr& zodTm!ciS90N53ZBjy-rs7PeFRKoXWpr(z~D3NRALFkdI zQ@8Su-`Q^7`pm3mDxMj2N6F5x&8llEq3ZdaU7oFNwhL2{7q65~K7T&wXn2bPv-ISHV#-0$C#@b?Vre^NDXG$3 zZ&DIM(-Ges1XQhx@A4inxA4!w+^E8tC(jEXs={CV-qDpc&Z}fS(*2`n9vdnYQKq|3fDw!B{EiXn33k? zUl0f-7Alt-YP$4QB1=qLTtYbLAX7{7sX@qETLK`cE{7?cd%6SvIz))Hs!nsuy_l!g z)-x1>2#2E&h92+pAJSg8Yi{F_@kf0M*_wbcUOzT0?Zz228+|H$Z|rM<&>s%jttV7M zfqVvb=SRT?fx`m)1Hr}pa--Voi&$KK4nR@(*a(MFwU3mq$L$4_kq^D}QzM?=xRSPs zC3sO&WoplmKTTUy;CSG{nLR^Z%&e!UpIqMR@qBnt<-lsK5*^v~*F-XN69aKd+nNDSqG=e7?9wdifYk zgKpF3y;+WisX{(jyoNKFekJ&c;i0i~T=#_M!l#C(G~=xtOI8<7C}!wM!4}0p?~An? zzcD9^KBi$vjg7$aTUctr3!Ka7M2CsqTww{jL6Ftkt!?I!T5#c#5@>d=|5;6$?oFYNN|-fV&8NLIOFeFIKez4wl?T8zod?{CI4^BGs?k&7kM=f(~V zF9KftP1NsEJ-vX|kLp!C&%g}%t3$_))Jx~}v9}Ws2lN{(tjh74y1P!}zLsL4!kN1k z7>eBRa=0e{O8SiP5N=)2OMSIMX~>>T2k$7`FHpc+2gyrfZai0H z5Y$Md`dIy>Xy+!xeB(78h@#_ytRRY>4$y@t+7*5$kLIj`6N4z4y+YA?Ol%h8+QM6y zv-ZA6e6R)*ldObOIzwj-*N4+{lZ9}J@9o71VMQy9W{2EdmHMmhhaXlDuJ-0EOm zh@$t=OprozaZb7kI7AXC#->mzuQ?AOjPuFg+2K}AIJ6zU>>$~k4#L@iR%T&BroP&G zD-=D~910oRW;*nlCL2H$%^;5xt_DbkKdUlZ%QkZ-Xnx5huge1+*pwErh@f0pdnd(= z0)3MeincN!jS{Y#{G@28L$;`W**O$z54b~*dFeF8EcC{h48WIN;l+QYXxKJ-X)YL( zPc9(hpQ@615Jj^oYs7^fD94Ih#zOC^iMPUejX*gqR%Sj{kq+*uirT!*%#&>+p{)7F zoP-gHtmwpck&x_@?BjEmBG6z6+)i&Eg@S?(^0*i&-j5(B}E?{jlQGtp`bQ z4&Yh9Tc_eymMVD<i~C^Yf?KbYqvrR;FVpD@+A!w|)O;f>-rMC?66mbwhs;UV zdGEA(Cc$q$mFk@cqbIUN()=Sp`E5zh)J}`Dgem<(zqM07vB6PhOlLm%U~-5R-@}#n z*1yK`mW~Q`VRed0Q}V)Fl-Ml#ZbFJP1-bN=(o6&8l~ZRam*;u`XCxLmBT%D8I?YLV zI-mO3FioH)vDq7JtxiMLn2HeK(VWvY2+iHCD^{H)m25Q-&ru`kIDm!hjAdr#vTXXi zcDgPleIf4FtOTySCEcpx`a+}2oGabXE@Rfj^O!w;H;`osFc5`_f(zNmg+$H+hF;<6 z^nOzhG0nZtK;ag8hZoa6J*5VTZ)QfNc3O1 z+CBx80b-EB*)<=sA8yNWm&)-unkeK5iumLNcA4-C;)OyCP2&-oT85-SHYj6Gni0FB zJAhegjMp{8&R#%TbtUUI-I=|Rl`4WKGGf|!fwS2uzCr=cJWr^VKCO(x(!id4vWX+; z>=A_^0kWv<10&T*-((APLW>rr zZ?Y+%w<@`T1>;EH{i=Rfi5raQY8%8)ci7sTPJV*wO*%U`ANOh#hT7+Cq z0e2?{RC|Z?sWZP?X`Wyqg;SZRS|&P`xs3>lGLe`=rQ$mTbR2D3Qc8mhEe;r%CW~oa z%Oh7p*%|b+h6)aPpD-Wit2@9Kot1|z0aCai0t1Gg~)Zgl-VLOLLNEVV6 z6ai8ZIg19CdE@HloEHv~(j7s@2t`CEyJMuxIW*;57j9rXFe8Fk%yuq^44YfVpB5P1 z1~jXXZ?47f90}94dXP|i>adt;f-*e3)k_m$;Y~0N7t=H%hUTKkK@wFk}AC)CLL5nv%?*vbhoLYv8Yb;tyhSz)`}Mi#={TLy)^rU!-=LovYLwd zw_NSO(}aX}&kD*wjS(SXo>gbQ?Y2ZdxKRxKxUzOuyUcwD;ORtKlt*cYV&-c>33YO( z!2Zf#jxM{R4rTpVho)f zoZRV&EhoxbqSM;MzPC`~MMUtLN`w?A^=idbw0vhU1mq0<6>$|7gC z2n*9R^{FlKxzPs-$alrcttxQ&$$ZeB)zsc}yKa%SDk^Js`Z;`-Zn_1p!DGJC zSB5$6QfuUokUy;p%p+h1nBFC_Fr+Dr!9agd#tsA6SJ1n9Y{ZjdO(Y$W1ILbvC@RSZM{2i;G=zNd-ywc$4@quQ95m3e56C_}j% zHUp6G=yzh+VP)()0G%g`l()WQw4n?3lAtMT8hc!uysTSj`T2p)Ye7DTZ_3eh=j^-1 zu`TCR_Ho4Ozs&bpMp$V}bn8mmnhCu8qQEzj@z<7nsVm>j+2a3I`L5AU+ob_X^wgZu0My;(tAC+c&u%SWWNS;HfgOr2~wsB6up{DwJZS>F#$Y}_y%u!kl#Yz-W{|1z=hM~!~?Z)x<<_4*Z!j^Pi{ zWrlBR4BxUHPVgU2N*_+CA5QBYPUjCZWJa$``cGIfK-SRz!W)*Nfm7}kv)d)_^yCpY#&=3D5cF+I6& z?=^-x;g5UEP4Hyi zP5fz@`fu^|>NT4r)+$K+MC^6z8X+Z7Tt^w3$TVQmDG|5&m%*4=Aru+PkJzs0fr)+Y}9 zaV?sBTGifM-O)nb+4|5W>%&y*-Ir|-yAqGMpVRQV0Genyn(JJ$&~^Dm*=LV>5|4SE zKkh?0=?_({{U~^Ud(BIxVJ@e`p|-WFMYN|?te!Ba-V~~8+FfHI%#j&FU7A8N=ng^c0 z9dscBDpvo}>}bcrc$fR})Uw3O50aoiS1M9ntJBnHna5=`q7PuC}%{^N4=(+rDdhGlD}<%=AvS9xdH zd1v0_pJ^|&=_t1SP-6SJl*lPN_qF_7f8~XNstdz4q;C(&+y=_jBfH<*)ALOZi!F}) zXO8!S-j)ToSA~73jrdd_*#p(6pU?-JVut|$_@^?pn9O0nzmu=w=a~7gvt<9r^Yw}6 zTE})iVDCM)dduzDD3y;Fvrp``yHzeId+qpMLs{C`ANcyz>Yruma)_^`ewL}b`h?$L zf0n7Q{S#kjuJCo%)az|4W$JAkmsj|j|L1(I0`c_&h_9u>J!+sb^*4yG!oJ1*kRG>T3tGKH|B?_?Lyg&$A>SE& z>FEz0niFYjasa#j{%={aqnBn}M5dV@H$RsK0~)M#Un`mq%f0|t1#5qrnR8t;%^vpg zOj!$K9?YAhj>z0w=yCZ>Gv2x4+c$(#lyytCSOik?d^hYL|DIa3^P=Dk4n3^5xAyxW z_p)`W=<*>}DSuzgw}Z7(A38W^CHJQH9hjYfZ&+KYG}j#H#q=96Neim~UJ`kG`cObV zvWors+2_pjS^(nJMjET=lirixO78# z%%%TyMzi3;+H2Gvzkp4Q{cVexSxoz3ZA)x(Met2MsPKXls8Z-3xm< zy;h*Q?2L@iP|f>zX2WXxt7Ep{p$~UnG`N~>7@VJ8>dW>LtxNUqS;pS$G0!+HFCH&$ zU|x)Dz?0bnEJ_Z!Z2#{Ip4m_kL}(B|&@N3#KJkXet)Hu=qLi^~exXZEk7c9vCaI%<|=* zxHr2vdHXw%Z>mZ(O=k<=Pt+X3+ZQuERx#1RJPc$)HhNI;7??13nBNBrHp4`21&{#7 zMGx2HN#+nQ%PU_oL}C}?Xyyah*gWtID=?{p{0;9dFN+CIz>I!|IVWGzk1{#E7#RmMpb181`b<`_eABw?XG#HEKxoijiSW7$KX#z2L0z-2r z-hRphEXLcfvIBqfBA>AX9q{(9ioo*@awik~z@DyMovcoQ>lWg9hGs|>sq(hxJ z&$EiaIn~gLB$G7i=^Uzv4g-+rL%AqG-i zb3D}94lp#k)h8MLn>YDXn3s8O5YGe=tb+A2#6o=CzZKWjN&b`sDi@Fz1w~t$ux#dK z{_2>BD$s^Yaqv zS3~gPV29QUEeZ+|Mcnv$2{qeHw5hgdp}~_in*Fn}i~Caa2e2;5q<$7S>Pc#I051@H z4HN=plSxm!!8k8+J3krRLccUa$MBNCCN_ zlFV4Eni1el9%O(YMC_JUiz-z*?F%QxMu09Vm^l;JSn}m4R1vlr!`z5aMnr zOVzIP)fq<$p~Eudp-R}Ie5&`_gop7AXm?+P4p06mYA^%C21Am*) zp+wl?1y954sfDxoZ?2yzAPd*4t+08&UBIGi^aQ8ip64>0qI zo%wIoOXcF-Vx>)Qi)pHOl6#(k{gqfw;SFXNxtjv!d68eTVcTo(D}ChO-zj@zSiNvG z#y?mzbb4P+G27DqdT$KHiONV45wandM0alR#_Y6^WiRHvI@tWC& z_pR0O90caG^Ye--3P0AP5OB>kD1EWC(zT{EvhYgVoP-FLxM!+o-@XG_Q%Ewl2QBgT zj~qc!Z}OIpAd-dRGH=I?X71aOl_DvsXPrc=E(^}Zrfma4BW`IeQm#9I(w+BWQvH@M zB$d)ZMpq!G7CcX*h!5*5FkP$Z;E~Gu*CX{BOZD0ZP_Z<)8plWTjz~PwG)z`A@)+)x z9q4Htu&UGEn*uJa3*5Mrw9c{;x`cRzclhlh7-Id%p%5&jl6tsL7K82>^6I9J+V^n- z?UpD5G|TqxzJE*Cf}y%BM~K|1)_aI>I1nbk9RwOBreMMCeU3~VgOAk13B6?#n2$vP*>R;^dC|fiU%DhUN zW}`O)czf1o0|{J*GSgUG2=US35v`uOGvWRb&K&7U#ulw(O!Q|W^ji?i8Ei1=h%@X& zeI~s4EGD{zh~G*_y$7&^(BcIh^PGV?PpFq70cTmL=d=}tXLX(B%_P4yPr58 zs6uZbO9znm=qryYb~DfcCHO5&REHRLh=wipJBxq|*aMi)1ndal_mYjGaNeHUjHuv< zK4pvgF<`T+ImeE2C?}0{Wlg!qwVy8Q_71l{(iuqf8*sxKImnngX=po->+)S}k6+Pf zcb6F&>$Ch|rS03Td-de;fEdfb?xA6wp@VKiXKoEOmJdbu4<*^246+;gEoR6CImjXn z2gMKP$ec`lIdEHJIAeV9l_P^`Gr~+iAxIP`zuTd*dDvQK`0?fuuQj9fGN-EU>ek2% zmb}cr#~*EpF_!*79_`e<`mv?>l8dWsdsO<^d;f2lIWIfwzkS+e*wy`wBQyS0W4zCH ze87KvD1CgSetfKZe4Ibdm6@2*n3%Dh;Q3F?rB5u>Pb_s$@c9$KMlKBG!q0M%0bJozA%$smfa>|EGnW8Tc9!_KMJs%COGgoxMJa^E;Xa7r0e8t&+)xTh@Pp zvpFgS%ziQ%zk2kJ3yUnIw&i+0~A9D5^e{uGB;!kJCmj0@VJF$K``|@9$-7H|m z+1-CRd%_7mfK!xfHxkbjlA<|dtgTHyFH|rEi8_kKH6prHPUWt?oyM_kXR5}DRcHHk zT+Q>(ul*DM)CgVf))`(pb43&M@W`W%Cgos$JOhtJRLzRX&fiMXo!c4JoM_MNO zba*C8)K7B*Q6SpB?y=}DMVde!F%;9bX^kxnzjm8!h}u{tW}oaA&!FwyGP~m|23|0J zd!l(`)4J-GDXyL@s!u-&}-0*%DD5+Ch{RbG0v&dC4%emE83c5!|K z55wS#RFwA1w*{?d=5zVQVw9~7jgQ=@%?Tegkw85|YSex@w(a%xA6Pn~n;ym(mc#CX zD~o&~xTSs&5M6WD7}-1*mV5Bw4Ykf!G7G{F^54=M5TCT$vl0UN9@j<)f`I{tR6=gp zEx*l;(aL8~gB31qXrDeM2J5z%iM@BuVR;SXMX78MvqLWM#}+yE<6TqgwrtSjV)goG zwr)H4YXgb;i!DoS9`Uham~$@K0fyntl{cQpY=(0MK3N<*^7V^7KZLVxG(kV3C@Jj3 zqVL;UF;>HY=&+?dM9z<@AU<~w^&HEmxjd%olVMT( z=+6|F-5-JF-oU!(GcL>5(mwaR)cS!1zQu>tzPK3UabT_^Bz~jXi{*Wf+B$ZM@=G`y zG(;!>6x@Yj()RHc3QX{NcVEm-#=CN0B zw~0gDf)lgO)A!c9nEUW)XBQQ_w5D3}>XwxzT%PwBO{Wg{@ekt`M#^pp>uw3@T*EJV zv~%vZOnXzt$K`s|vb#2WO0ikxnZTm_jv!v3o?mK4DQH(NCG&zCxaK)K%B5WJ)1v$( zw`SOayqDepc7Td0B_a!zG2ga2FS#OLE8wQ=NRK^152pPP6M7EG&N;!;s2t#ndCMz$ zKidjCuXK262eR`V-h_eec31a$ol^Np_MZ|p2W0Jw30@G{2foM5@AJ1-z{OAiDOHDg zd03zdrsOBtJ1gM0=HzbLl}!X!{Q=BkSE7%{+_f?0IMI3vA0 zIw?amFsq6@$ONq{NN+iy0X{%L(SD2(#xubL=7I(^EVSFS@HX-^^8CCjBG3#g$MAaO zaSf*I>FW_?E#Y~>6eqkEJ--iqVr_)5endie1TQ-xP4$M%QxS7rRH7^#Mbx~@rf`@b zrGk7;);}=8XTCS&G(~7`b<~V3?%a@%Glf3u8m2pdnAeUS2k6cUuE@HHv76>1#Ceu-r8fu@|}5@p=x$g%zP4E zDmi8t4pDxUY;sa^@>YoQY1F4o@Co1!8oFynxNBS5=q0DDCkuZJ5m?=KP2VF)t{@Wb z5bE4Pxo8iX$l8Blr+($8vgT=AYOHla?7V!UK?TSZa~=UOI|o6TZBdKa2xq|4r3JG`_b=}TVv)Hd&lZNTJwcC3B| zj13p=fG;+}7cIS)lf8d@@D@;Km>xBj_$B-8@yn}|^b}G}4RO^4AUZi4Mh`x8H0`t* z)Oydl)C87tT%VYQ>QQdbt&X45hV8GpatM-rHsvc3FUQR0>xLr4%hPqPQ#I9dBGjpR zT}a_P2*fi4n`3f@Gjc^3I+l0R)Qv@`E5Kik&~(;|T+$|OlFrnxxb5geYJ`9vpFhJc z)N9I}@6BD;1-3{zkI~cT%Cgo&|GY5JcERJS4_I|D64?j4UxNti$``E6XOHAVzAs!E zx6limsPQve@H1AwYSLBk6=e)5e~6f7Bh-kWzfO6!~Mq@*dao(F7GZ(Ip2C99&xMq&`9y>;QY6R4sTLQQzuSHw=Rwq!nbj6;%u`_jd$f=i`pOV=;r^cLX4 z=7Njbf*-F3es%;GxFyPxca_#v3LWt1|Ec~4=49125SN5iJBB-G$h7vj#1o7;J%CoC zS6#2IifV)H-c~+)w7g(D-c(FV63y5|3dZ^>j5SVXppePA8{g0b?IVgR2CC#pjX^hqgIIB6c)MxlfCiU z`q2zY@HPO?zhm($3cy#9+nL}OHU*^?A2<*VBRsZ-q5$n|F6$-_89u|TZ|kfVdcwT6 zj_I6`#b2m`G4o8B;N<&;g$t2ihCn&OMV_nA;q7=%tZ~xeC?lv-C~l#WYQ2?T_vt2O^)q#Bwoe;k*loKuRgE@z!a*D*o~X zpqPV|zuw@qz1l@Rx|C4!%rY%yhy4(c4r8WWm#T5*rSTer%?>q8cYtkDqVwAK&bB;R z>_t#0@h96W1j9te$2!ATJcFQ@ww71D_KD| z1N*J{#SdTSp1OPUtKZN>F&+%KqB&qgd#JDXYNXD7jD6dhl0Un;$aw%AS^+~x)GTD9 zA44w=5U}2hVe@fGEpr$sE9jtN1<#N~N_37$dGqLg3uP?fFx(@?v$dGJQV(CdT|K!W zdZmMJyzH!UrdeA0>AT~e`@g-YyxusM{Z?Zw>+o?;#6{4MX+O=vE-1a5zW`v5x-FD> z^_aguIuriZClIDA^mh9r@iQH{%O^aY&9do$xLeJ<#8Z#DOH+s5`5)|5vFR-I?F`%* zFcnH`oX6Ilk^Y}+Te=Zjc+JZVr)C^5^>0hc0 z*>-V?{c2nFN1j#V!O!%?pw21e%^P)OS4rpCVraMQ&8WgBUhmb2O7N~K%{WJNECOw= z1o*MiUtNu`J#5u}o73Ya5Wy8W`+cRBZ=n3XCHz^TIk}YBV6B4NmI6{}?W2_3(6O?r z+bwF#9>z@{_4N>0$5{EDHxr$c47fC&Gq?+A2-FjDm+F12f^!?wVaa;WQ{VqUcEQ-d zQ(12pZj4ZjC5qqI?IsZNd#XScUiULqh{NFOKZEB`&q{!=x(e7AKT`$qOQA2=+54-> zhYodDUtMYFzh!n@QV`{ve(dhm16UhkZ~jabq&qL-TB;Ydg;Z@xlWep+fG05q^?2BK ztOv$$Ap{@SfPX4*3G}B?m++3ieRHX2_eCT63;8D=Ev@wSp+bEh4cp70UG_`0Mn3cH zM17>b`zR*5nTS`QquM!`ej4^GbtPo56?+PW4AxM{K*VsU*byjXpbc-oM0$1xrhHXmHmbi=G%tXE8t`zon*{p;NBmnbK89T)AheC$)^iQYYhzhpo z6PD;zI&5}&nCA*}0E8O>bOi$`WZG5aKlye)Tr0j~|Neoq08PUv`^X0Og#~w#_XRyuyN*z+w{Sl=`&BJ8~diw z@ENV1nFdoIwI7MQdMI+Z<3}6t$0ug2?j0laOrC0(HpNYE_SLj(_)a-{OpqYpxbce$ zX~sHu=CRv%lqt{d?Ce}0&k8=f3FJxW%zFHooqiH;9eUGdbG`6R-VX&K$-Y@y&sQs>*@fJIh5U?#!iI(7o`t(V7ML3s z?}LlwXBR617OOHAYZ?~odKT+{EV4E(JqDMaoLy=PSZc{wde*S?yl3ep|Hl&BXdVz- zZa=&HLtgBC#&VYn*x9h$y>a>TkL9ngmIU|k2W~7u;lp49|1lIkJmPaF7Ijzsn7Og2 zm&yO0@uPHtub26QKd}G^;5qm}0KQ%)iSR`$6th7iSkm~b&h<4rZJ#6=_v*!LIN}eN zI@M=zbIXZzo#a#fM-z71)CWtM3>;56;N1Nr*<|o!`TAIfQ}KiAuIp1w<{a{t!-G}U~< zw*KleAFR`4nL%%w*7hdA*cLro)^f=?*I2M z4EF8NQ`u?w`;h_aPs5Lkr-a7Bv+R_gyPdk9|GwTXeYx{ZzDbeoi(l*QiHEmC>+K(_Tt}hx_F2=q z`jz$ex+-YBU0ePHwB9~F$j&~N!7r0tS#NjhdHJxG`m}pxz1^t3ZhqReISX2Ef4Dfy z?JW{l{JGvfJ=$_fwMn(p!yFoRD1#`YuflLuuX$g+p0UNC$WZz3Dzl?=0N0;D8CmG$=Do<32f? z5xRXOUr<_VTQc)vU*8=^v$EKD{_V#i!Ug5;9Y;(0kN9lQh_f=?R&#dSTn$ z7KNg*`;Q;q-;r^TL_#yKNyok3_*8m9sO*!+=(o!JjU08IRo!$^K#ke-~4fIMVAoM)pA^B_Sy_Cf%QRpGb191@71g= zZJVeS^BkLOmaXhldbC9^@^!gTt^H2D&h37^6>`l=)6e&t=j>>GXg1^Y?6H|naa;3y zr5Uyks5E(3JT-N1(`e1}%dZLgI}cPHypdXV@36~^Yptfyx9=Uq$eO`2|4VSi2T%@c zUlAVqjrWoIQOzTV{3D_w^H1bE&QKcZ(o#T{aSCI>2UeSdi(#kdpU}0LVg__%;c$J zRuu3>hV=LR!&?eZB|dAn+w{Ev?+mTCA3c3m>wDp$5@@~s**o*|Yl>`E*4r0a@7b=b zx9>n;SY{6rUKbPGi_Q%Cur`d{TIGh2Ka-KtQNQ3Gpj9<`wz|qZ2g_rIUp-57?+Sl% zzLcr&UQC*-i-{ z=Z<~6vngw}(ZgUlWx~fz&nogBV(y-Bp;;H7*Q(N5ay|tGA&Fh@ikqG9;^I?J4_nRV ztP)0?$v&jHttxX(8!<%kIuS1({6(@wF+?0N0Cunh>WaMt*T4z*>6)*Smi7Rk#<3yE z3a+uGYYC&~;HsM&*IXi8`+=mwwgsPC)214_txgO9yUW28+lTHm%S5a}a{`RS1cPmi zQ9G4c*Ttw>>&EFw$tbpfp@HCrF&1)b5;36t-LSjo@l)?EnFs35=^cjla^xB=tlP%Q zQ162Faw6pbV_E&!=qNP*n&vYTg}rK3v)vd3DS>n%P$sWb#0dg!oenR9@43(+% zXeRD)V)FACf!cE$EKdqaPF2Flj0-PxCG@uswSnRndpFQxnh!D=;3hA@*G?yec4(lV zt`%$CnDo+3ql;Vb&FOv1*cG&d#p4J;T_V$sAqC(Ip`{Jb98F7k@p38d%%?ejH3`HG zPt2)o|MPiigy1dyB_`u4bb*fsZm|WL!}A#8ixME{{_EEQORK)TdM2}1-Hh1aMzUQ0 z=A_$pZu2#gU2V&E(b-#J95netqrVQdxjHfpyJ^Tp;6sw<;ZKi-7B17)P5U>e-?j*| zd?meBK)DJWk_F+4hq2Q|VUGeXHz*8ge?d0u!p0JvY<>N*CFUI9{Oyq)vmaQ$WhwJu zAjkP?YVVVmwq@0MCxFFsnmsz>vrASdCtS@R`6E~=FW)|-9I&&N{@wI*WgYH72E)AbG`td)7y5gkH9Ou*7NiL1Mewygqg zwoSlPs2o^V>5La6$p6*&q1eXb4cmn^WO&{4Uz*Q>RN?fYup9akLwRnGYA*=zoM?3= zCqXRUXE@*DtMHqXKKB}NV`UP)OD(&c%3j^T=9sH$h~dG?#%12BEx|^+ojg%5{f1wJ zd-r`YcAY=KnLg&CFCZQ;C&%6m4+~rRx^3h9%% zHQxB1>~VGJ4B#Ik@8@69{BTKXK`1+&*hy4mQ=4jBe z3i`Q_eQG7;IUa0dQob?JR(6z?N!sD6iK2}R z_nkn1no5906=5&eB!waN|4pVOhXh_V{wE&!r zc7k3!R3(2TxNeYj)2+bFtPV9vz_uL(`KR&WZxYEv9KQ`*G))h)NtMKv#mEg^9AHx1 zB|$$LZ#?f=25Ge0s_!%te8J$mb&WT<>^K^ssraB z21&ChDo@nRz{b)mN*Hnarn06YU@s^H<^%T1vl@=7t#U}?Xr%2a8hWn03(>s3KpO`Ae-Sc`*0{vU}|+hOVUjs zM1+7q#jypETt2>#N%@QiNeN^dZU9ZfZ%R8H8Vj0ijG5$O!|eT>n&P-`BeZi7Q`xsh ziE&Om*B{f@bSa1_d7vnXj1<8;N%%|!g*kQJm=Z+D%7XMH@<&N1e4s;bcKE zVr-?PN414Vb}m(BUPGTj-9x=;o_{lN-e?Wc>>39TB$7|81<3YW|nDaDmvkJ-`H89T-nkWXfS*T6|c9@ABr>9nn zI8VFAnN*;DlqHzFPni+KI1S)E2%^ub)1H4wds&@kz|}zEMPFIoey;|ba0O8Ot>_tf zw2gUUfoEa~^pY5Z+F$^}7*sDHsYx|yywxZWun}kB4_QDL3i18k&w$*4ZFoHqXf)?jz>xS*({ao zm-k5n2Nv>CVcAz5sQP#?QQc{+R8G;s9Im|REl27@u{#aAck+FVeuK88-GxPSl6k$U zIvyFZQaHXEsFj>AzzvN|VVEdjr&s4)G=m;~bbDSAu2YdY*_$*uEf9R&)nF}jrb6lA zf>7+Pt%tzCv$MB5#i|#4PH|98L83gJrn0k<9R(iSa=fI#3bDJ%dg$Ek)&*AgzLNkg zf{6KdE0J2ykjF@@?cn2@T;5y8hXm{{Y0!6j{ItC5 zqeXw zu%L}nvHhG%dzzTn(RLnB&yz4 zmh!zUT+}E-SJ?cSNpw73sWWIM`_>4(TzdfXy*Kk367*g0avotmstKKML}aVEl(bYk zQLbLDqI7vCczd7qlzq6$uF$ zhaP0^U@i(GN~qKyLF~SY*zKWyAYNtj)Cq3-gN7l$F{x}2q z+hK^SA4wd3ROtaYP#>-T^azX3MnUH;uE>3d{88pY)6)ro>p*V?b+K%1Z?N}W*`!@2RsChx+ zl=fpeHTxyb>kZx!kxlw{=;R+e^qW1lSECC?1S%l@JmpLIH zR#y8|?0>O$pJ7ccdfxX}C;>tWii#99iUKNVR0Nc=qI6IUBFzFQ2?1#Vln#d8B}(rg z(xgalf<%#SiK0jmMUiSr=sn&GJ+o)eoVn-R^URz%<=Jm}#Ra)2`K<5%XAKJUBc?g4 zg50}iv$=_KNt%HTaG?TL^YzDKfRMcHDi$W!&W#51SWys1te)Fdy9h-txF6+Ks(M|#_yJ`P;#nla#DhTA+CdX*>Ao&4?8 zkxM4BrFg4tHpdgKhZQH0ufM9n;)mR(7dvH8RkZ_DhV6pv97PXf(Y+VKr!7-wAQ7S&rSJJgla#d&U9bBn+~@f> z4UTQ;94^Xjc=tL)bHE5ymMsR>H(s;x?hJX`%2eeFgJUMJZ(qqkjFqD=52m->7*ST! zJAO}XD`3wk_A;dmc7Bk5mkyW>x9lD=pW{2!frpDx8csc*$!+Q0=7Dw?5J;zQd_RQp zaL>VkZ!>ePR#o=Xd}fc(Zn=Fr`NVgy{u58;hi^3rFc$xzn|-rahpi5rk}ow*$8loL zJ%qh|^L~G7=RErQTAa>-s!ZvdkFczs?-9q?Zu>Y5P=0(3j2D9gQ{nvZcwCyq0-IZTk&4%n3T5<3?5 zx#sT3^JL7JjP3sI0N+sB`8>E7-zd{^Lav{O_`V|R;rEBKFn>C4;t-#53tudbR|tQC zgO1q#9(~#YteRvLP`;?nqu#`W+zdqF^UzL4{?#N>qV1R#8sskJW>^f_jUvkU$G=Db zXMAG;WsqYCA#Y+YUCVBo9frS9A%4$9Zl{437E@}i=vTA{I(Ok_-!ToWu2{B;XC`GC zdo?`GCQT0%!12q2w)9N6Q_}a4uJ3w3bdTAaimGw7Q&AtG!i3`g;c15HC?hKo(MuWZ zWgvF2Ah$)lgdPMUpg-VI9Ttf5^sbF+fH4)(fkO@A(LDLc9Q@29J>p3eM`_gDYk)^R z3`9}63!y_$Sacr+S*(KJK|#EtqlR(ll43JR&rHj@;|2W zdk{ES6*ElenjoEr&fu@GfWwI#_Bj3;0Qrc@UoOC7CBP}s;iRxS+n%ATQfk2U{^Sq# zO?5BCSUuOu?%9cs8*8PNtHosQ=o->X9a`L^S`p^g6PX|wa zc4SXG>WpJlznH6kd!ftRadHa$!QP<*>{NS=*MK3n{{|sQ9QM6h_|*9me}WK{e}R@$ zsOE1F62QLra8@&9=t^bBUZA|BoO*>VT!+*) zXzngk6Fj4KNPV+0u}odaJn)c)F!^qohN$C{Lz>&%K9_0kxWRQ;YiG#raxL*_wZqzb z5{Tv6k{N-Ab@t}nE!Ww9|H)xpnVQe#x(A!Mj+{Eww!7lgkr!%5P9J?stT-+IA@GRa zv9Y^(6?%%yCr8dGFMO^z1O9l52Jk$4DhaBWx$q2CAyTEjrp&2XeQn8{%CkRqRmmEh zmLHLAP}}@X_ME=vp17(rCuEKq8XA+{lo;sSA2l*1=e)@`K2&_v_>$X*VfF>5Z%0io zhosFJTZF00nUeM&o$n@P1j(Jprzjhqx4qxebNZ^|v+4_uO>dkpSU%cQbJ6A2ri6Z% zx1^d&BHckkMs8y{HShU{p?@a+Ye=R#xxbV?9ll>PmMi#ANJZ7ZjiPdW z3KjPQuKXUUG=C!1LkOuXenTp`pGXD&SEM?+j#S4wHSeq=)gABIb);JQ%Shz`A=P6D zsVsgW6%j(J#A@+YL)fNc>$@cw~Rf0c>hKalF5W7J{LT*`I* zD%NZs{#n8L#IQZbT)x&f8+{0=at|CSAfWCU%4ArU$!qKpL--yT|gb& zYliXCe7q~JjJEme71grd_UA9lG@(%ko8x_*4)aloag$`t!n2&8hc+m^Q4_dEfW0a| zR0&>Z7u#tk^XlQ>Yz(2(?Yej`WI&@1Vl9656`p)U;bk>CM@yXdUVU4!#jVavt;8rx zcQ9B3*yQB%ohy$8HD%S8DN7gu>qS504%ITfQMPafi@Jt!#W1SEv>U)z-Pei zmS3cS_hrORDeR_Cv`C|d%iL^H0FjG@8%hsv%~|x{#M{oHP;^K5IiY2X5sv!f#APJf zfqQQ?9oTUyoJXgY<1yMBnzxs+4Hf<|4|cFTK%DnF^8a*0pm zqv(`Y`R$n1oA&v@jY;c}*gU0RK+8`|*Xr=$MIpE}*|g*HI0w7>(8yj;95^S{#!l$F zWwsUsh?<*CD?TnmaCJK(Nb!diIaY(Odmr0&xqSzJtlv$AgQjBnw1Yp+mPWa4J-+=z zyFWX82&LI}Y+KQ^(seFyVyeRraLQSMzr- z_?Fuj%bZ?Xu9CblyD~#Mm6fBH4z2VlVDh=CB7V;wGvRT(>eWJve(v(`#P*=wQfKTP zIC>BAql^zf86(dId>KH@@2A&iKW8d5Ck?*J&)9P3;b*ylgItF4?)xW0I9zEy-%i;z zKLt-m+`OUmS^Qw#+9+2XMdNE?DOwaM*Ebir~w3+WF)HM+TtEM}eGhSk4Z@hy4 z!xw|YQ=Cg>>IcBvWBs3J3j>(ZQ5ddwj!VMw-m4BJi<83@0$|Jj&ywu+YvuB)6NMom z-6Lek{jH@sAHEFS;9hd&+|chi=qW zJ3-HQ*Gl-XRDF6Ds54w#;2dAHkcnU*ccBo5xdlwA@yx}6{T9|t=-nVJdbEbvmgyU8 zMb_BuCY-=8DgcO=@*m;xW{G>(X?Rarde;VfPjzz%zr|er ziCV&W{0<5(by%K5R6v5oR$jLX7$MwM%XfT&ggHztt; zCBSnQ>&8{n#n%* zZV|>XvEiSE$c}tqZ6fgk7WAn@+X$0Q7C@z1qA?cau!uB=#{iqi4rlzPpgzDc zT@h|>M4NI!o4ca5W`pYHfoV<{UO4!TLU3Q=RpEr#sQ@lvNzSuLPD^+W_AvEQQp)f- z7kBT4LfW9Q0C~whwavmKq2|_9bD}OB6W#+Vc4oh4WveXT(H%{M?90*Ju7af^ zQ<{5fNo5c8DanDh7Fj35vNQ^^nAzTK8pt|<{Q69O_5(sb!`-eLcc-=$16;eacR7FH zHD_;}VG13uZIU)?nX4U-mQ7ywuQQ3njAfOJG(R1qu%0?%A_Cl*M9PQ!i$Q_>D|IhO z5Wy@!{v|*z#DaST?&;Sgums6T3t;X&qG)0+CEV6t1aPb`%IJxO0V!?4T(hdhbz5R# zxY!J%Sf)#C`e9{} z)8+z*CH)1uxjC>0#FNV^_e#sA7?nD5rPBtbc~?QrPKOCy*K;_mzrxb~v;@^Y0^Yy7MrnVfQaS1o#s zfF`97V?_ax71FB-(8jCCp$or*zt~B);#DVH*qCFs+iPD$vRXm%xCmGa)cPyhO57_6 zRc!2jZ5qVJ6)2CG!eax^Yk@um=-Vm8_XNg6PpjnBDhBZ(KD(O2AI`-DjtV@S0+I_>NE{0)N0UfhG!EH|&2rw&d#Op2 zU3FhmDpv%#i-0G$1o9}8HTfDS& ztGAt3=A+U#k3zqmqAeJtXX2L=Naa$X;n4McVi>a(6?`pGZB?agnUu2v`t;Qm^*`j3 zfcpqz(yR+36u_kmuHM4r9SR^}fm9(1fnHmfd+~8>!C<(O)pY(bWMp}z==;l^90Seph}%U#i?+^Pbvx@3Rt_xl zF1zE2)977hcPP8CQ^p$9=|oJ&)MbV;W-GX_=B8@OK7xS!y8O$%3AT|Uplxpr%)+kC z2m&&|bKS>o)^2%Kv$t{2rQWJ*uarjkqOe@DSU*KWJ{oW?s1%>KivGHw5|bJfxM=M) zuX5;g*g55#m%<%W&9J8yHkxZHSw*i+6~P=Dv7gd68&Ik1`eMe``U5un{xY z%S3jRLKm$33+%h5Tl+KjgNu!ip9%123|-^QSo`*@Js(WA>&|X7?vKDrl{ij&cjxvq?rihqf7oxV`(jG)BQd0F zQOvVpRYjd;&7z@O(>_hB0?GGW*)suN!tKMV*Ya)e6~l($l*wdffD=V$Bp}uH`!kT0 zHgelR=K|U&9UqU}-LmN|L&2U>Q1q$LZ4{M0YS=Jp}wJ7kjb z9@b5L9dgwM?cP(ugx{EJM0}Nd1ECq_t1@{t$fA3QBr4iyR zR*Yl&_{{Lr0MxTK71kYDSa2qvX$x;68?Tl*yse;TD1lVO=ro68s`i1FtdH=w_qFUF z-hKF5;JJrk@TBe*Huvdfcz&{oET{bwZtr>=i(d0BALZZntwXHQbZF6UxO&B~t#es+HxRdj*w|1o^eSY#xpLX;IU?pLj|{ai73Sms#)kJT_(x)ybGX+^y6zaivvP@Mo<< z?2P*jsRkaiPwx|Q8Eo=U4w$=eoMqD!gSxNwuHV|S{|xCjr1}BhzPdoGP164HX8)@7 zWK&@r_+q(#&$Ym+;{7>4k!rB<2G0(*;uND*H)Tr(?U>ovM7M46c-5Wj$~ENtZWxPW^Aw-x;jsm|@^5Jutd z%VN4Y4{hIYdA3MxQtxh*=5I(PgZ@^p-yr68>h|FsBb>L(r2Yk|PQsomj;P2yZ_e*( zziS)tXrIuLe?=;GgsWHFt zgImGEk4M7&0D#a?xEv&;hoJ@t2P<3&lYIE>a`?ek1Ej5(tr zOuDVBS4T=P4|XTTU(T(wN|}nQ8%}W>ts`G^(e3FYXN5>CpU(_9aNH&*L1Nzec2e;1 zs|EJgJx2=ew;s1WTln16_I?vw^lV8q`hi`=i}x;H@4d8oU|*AcPVsxvhujAa4_0DC zsZ`*9$I%Y|`IgMD*h^Ae;Xik@qyKsDWun1%UW?c246hA=_rDr@DPQ?ti@p96MDw^9 z`|Hx(e=YX<4blFW>?{|uvuN8QnqBcfT;^3wefIAz-Tf-P8h#=gJ?hi%ORqm-um9QD zE30!~QE7SjJ}CC$KGaQ6eJ1@~*+ zIoeW(X8O9LrT>>=ukv59*Ip?0T6Z+Lf8%I{KOIf)|26h1fE?`}Q4NZ{HvSQN{SkXj zMOvTA7J1v=Dpx-hMTz|5>fN7Ry=%0P{O>xNt=@X<fEh-h0gCE;NpgRkxGe#BZu4H=iH(tVm;zuIaPG)8%L9 zimZ2D1;iBm^WuS%=zX|;HUIg%;wX0U$qjyd`8tmUw$h5R=3fj3myI$N^6iZzrv|`j zqk@*xB`RM$jXv#ji*9=}>5`}Tf|tTk@;nWiL8pcjQ%@q*aTbWj1f(T2gAVMbZXRL` z8Z`JJIHZQ$4fG?WWdW*KZ^@y^Lx_V~JGr>MImB+>LdX}bweBC^s!tq{cXP_gz1gy4${yEGdPh;maxN5 zq4%c)0ya&Jm?O$x-aOfqEo#Epv7z>*wL-@5Cb8gM$iSD;dR`(s?A*MOfe?51<(@T0h>UenlRJHR`&C{#Y@CX@=(fCNKbpSH*C$&1 z-3y_n^fuV1v0*)~rT(u<_axjs`P9{}3@AQx;1s5zH20f|B&B}` z?HzWf1aQwQh9o@t=%5+HaYIbXtugLmldq)*?^9gO(XX0ZuE>iWV_1Lo7D`k|Q^2-c zB|CU3r&z8)f7o^0d@KvU+dh z(cp;M)fBX*hn@Cnzu(`hw|qXnKEVU7zj-UKzW6ApnheUaEL-XQo&4m{*&o_YWg*h~VWX2@17S zbv`2|+}!&U+~3!@&&+V_2({f!1|{8XzmWZROMx<2=SMhD%k73-{%Me9kV5sO9z9`< z_hwQZr&PV3NFrteP(eNDq4oPHR2?HEzlrs89&#rh>;TALs6Uf3Dzcx7=F$Y=d0u;Q zV0!{_LIC}Wint+34<%&;$8WeG?RY;ZDZ43$?qQ&O;N&JKDWhYQG(&qs+}ZE) z+=qB>95Ke!VEG@>T@GaW@9%Y)GQjN017@0ecDcc2w|dHN4ex<8&X6yR5wLL+I!F%D z^%iI)SodNR0!j`0P;71Ad`5M`CFJ>9sJ=RTBB$NA$U>)O&N`t%64;t!xtcoBXGA-LY^cNKLQpW<7kQz zSwq(SM9q3wR!4W~;{gXkYzKwF4{o*}g}_r-=T#bfY6f_Kb*{ySDY@}G32^dSaOly) z)=GV00zA8f?515Xt*w|8P5Vr#aH+kg^+$LGV>yE6omfZMh@jl-%p(ydtzJw)cu;T* zj1pu9B@f~#ik0^WFL_u1JTO7}1bxXviO(sZDk12KiS-!mCX*8tBnPVE&{_DKGcmCy z5|^isa0kKBVt~&`a0ra%3bI|D!$@~4J@U6RP@VUAt${IdoDd+U;(%kt@0@!ebx)9^U}8FEdV zQPL$`U=W---5qHHb!pwPOa_`^LZyr*vn+3FL0wvN$^E7BP++_esyVm<0K`vFgP+q8lkEMhyD!HC<&obv~X)$zJxPX7}Uf%ABNyC1hbm~ z)?;r>W#6i8P0TR4k-C)81&Q5eUi1?uGgr4MaX4i@mSq{E4T)WLrdjT~*imv(^QtMD zrm-fY=$RRmSu2$Ax#{eMch!(65_e`)@94~P%^%^)vp@|~&=YjD+|}%_a$rFiDVGHf zhLQH^fX@Lkn#fa1xvQ0YSEv52R`@#D-KXC@dpu_y?6A&sJV*!pfxZ4*Oa4r8&RuOZ z2<&!+@J#P@)*S+wHg@i&cC}X5l00q$=kr>3^FC9DA!kE$w%qNoi7hgXs^B5B2NqS% z5Uk(P@&`-vBM~8ASSe;(Qm32m=xTs@1jmt5B+(6Qors;agl5BIQ0I~NdA!aFgIxv0 z?=dz&K*v*Rvu2@H7gw}j zI0@1n*HGdNRIvep+H#R%_lRE+ph*~M6;eD4r*Bx)z+NzI8LfOA!~*0zNbv~wujHk$ zILYM;;BqqYq;+AVr)@oiSIcE-^M&@|w$J52NiSP>VK(47VdK4|;@fBQtq-w#z~%v^ zcybAJ$2#v}scg5lTi#mIre30Nl)u0P z4yA3SPDWrp!EIb4N?YRIR7EC~wr$$)>I|iA0C{jdZ7b7)(za`b@}lM9y5&vD@Lg@T z(}G0~p6Omm={NR*<&=sweA2AAa~Xgiv9a@HsSHfo+|;dXxL%nmdJ2x^qEQWt;_wuB z?3@dcL-f9m>%EJG#OP&}T#Fheqh5QoYMQfp+6&;=#@`}jgUu;SQ@UFsnup$52(XhGf zq_lyDjk`^?_CUhdFvppqUs$j0+Ayt}!dg~2Y*SMiL97-9YcCYcL$CjFYr~ZXlxnE2 zB)9R$YbT;daOjo@;<6}k{R?U9B-rtw?0Fa7Q@8wOS8WsGTI+npjHOSIsDGG(f5cX> znqE7!QrjwF3%usqwZL)0%XYU7`1Q-flaacq>bzt925BTbWozhcbILfiNvjn#XK*hV z4)X8!UOWH5TsH!W;IPY-dFvL6kzYM8SvWDxR{|kA6XB1uCjCjIvnn94!If7rW`(?9QZ-kS*WP<%fQ*sO}-{k7ePCSQLNd6SRQIbLdz2o-ZP*OtZ%q?7Oj zg7xK#q@e1zFkDsD-e{A8C^l+4+{vov?NM@oail6^P5ZTm#PTZu(5 zN?p3fItO}`VoomftqJr4#MgoHmARKH3kHYMEBlpl_#y?kWN874oB32n4FXH?y9Uvy zMHMtEw0zv!b6%xNCbAcGPpFdtp>}7qN4XD?#dVA%f^&k)> zk0yT*03WQNx4p}JB=lZ2jn2fOA2tpcQLbNwp4hQMKUNOxNFz$?sV#@udJwX_hN5T0 z6KaE-VXP6wmFGtic%o%MiPGA%c>awZws1BWpyeJS#$e}t8}jwl7Em!cT8dY7u<;$Y zRy)=+nv1rNAa+<(dAXz7DabwoT5s#{p+-kp2l}+Z_??FJh|U~}=rS2P@t?u(&>sT# z%z|rJf)6P1$gp6J%7Uje2FTG?^hW@l^7ZEQk-O&#P+bJ%D=hkG6!DUZT8yGgut(^0 zcXzD@rUCe{ERzI1mD9jNxX^fN1Tfd%VXo;7xv{|+WtWji@b>E1*M)B>`@WgfbZnt7D`85@_A?xoHw4TEk$OymJyUhst;1j>9sukDv zm%|wF#{P7spmU?h_hq{=uGclS^UdRI_UkXc(^0R#TjcO%ci=nTIYmAjz-Ydz(eqv^ znOJ%LbnPb7p};E>2foR?k6TlbruxmQ#y^pqIvMyd&U%W6^=%?=irLM-&3r6f?)wkR z(dZ&@;^wqjw3b4qaT0`uft}m(hO<_)M|;rv+tZYG&?$`*RZwE*;M8t9XeM!U&UFyN z!YDRH^@j?$=slYqL(Erm)^l&h_Q8M`H+kmHMth7r@ZS!C+Nbf0)^@E>Ec zY8Ov4j9^Sdsa>01-1lu%lVF6@H_~@nDU{k3u_-29?+jCCUJOoDX_A!h6AAdIa;Ib7 z^s(^AxhPu~nH3znE?GOUS$A3;9lmD{XwK4zr-fDe5Ud zjpZ>+~&v(~S2PG@MO zou9kfV|rt^)!pRKL$)7UJz|2davKE*3&ExEo#z!OJp>;CF^<<2c0;Y#V_c)r#xXB)J#j`HxFf4uo3wQdha5Zbq`lgZQplLXMgrnmd;a_jc#JG16TJU znSU31iC@@Q)_e0B`|wVw58ig2f%l$NoOuC#>+=+*PY!qCFzV8T-#gl7EnbO<*W-FR zJg)?Is>pM1ZmyQS_S#Qz`W0DHWCQBRx!)YE=a5WXMaL=2Z?l5MD zdBu}%qCrKDiNrvTR$Mb$4>_7#-_=8pB-R~GL(#tKEnDBNwKrJNp>Ay5(U`4@j*SbW z4;-6*z?CSEcqAJr%@|E3rxqb{gHzj%5GCgh$-D;Vu7hn#F3;r08eCq0Jj&NzYDzX< z>pr8YeEp3vx$*j2^AKg%K5|~8>wsgMvfH5BSfksA8$2rRA44P`x(`Qds(6efkRN)C zWrV1BPUPi1^!#$aO~vb5&DcXPW)lzSJ=G@JR9`mI{A8b8Mqk%DCaOmX0lBUSaKCL9 zK)ejqRQuE)y7+jv+{>^tT>Trpc-8kAy$nakT-hS;u71GvWyJkgyP(tZf3PF&f8oaD zAMBVNcseKObbjcW!U#ffB%$=CemU*z|IT&EKiKhacw_PpcKqYU{$Gd#*X>_5bT)y_sEVHbpFQdsHpQdU`N5b>+G2Ew_-=_6YK0) zr2RKw$IYA8*)i@PgdK1H4cPIt*595TWwicI*^&AW!;a~if4}T#cVe9#RW$#K?CAJE z$c~XG*4gpC#$T5mZ)p6D*wI|$AB-KjcCdT3e~oQvKx13K&-ebE9Yb$O|NXKf!3$zX z;s1~wow5H{c6{x?C3yJXvg5PE3FoA%#nVo)d3EGBq#VxL$NVf_g)v%-l}ZFxk~#A zXc?K}9{rZ@aTm__@2XI?9ys%0&Gr4^mXVlu&dnPSUAk^RD-Y#+>+CoXiM_(Ta8f@AA3@;W=p!erSZ z+&$8=S4+pX&s?#5$rZj?cZd5G;YW{>ZDs4P7Ov-e5Idg#Qqc1CxLsM>FLoSwdViZP zl<$4``(?)yoEAO{A0T$rhFP2Yt}r2Xl$m(jbmPYl9<1%65T0WT#E!AZHTTI7JY0_9 z2R}E&u|wem!ieFsAvTlyXzdBK6()f5s|ByR!sE|A0)e~LE+Y&>6$R5NK?egbBX!@N zdDV7pc^T>?c!fW@R!u_gkgfK+EWsQF^?l*UaO0;I zo2zQo4|F0T0AAkzt2u7iv~A1Q9nhkk;O1S>nNZ;^yG6xziiqsnu^orR?m_O6;y)mR z+`pgyFb<)3RCMP-(Ve(mBKvoXN=u3zklJ=|=Z?cN+Yj&Bxl3x#9_hUjzlDwa_9z{a zk-}k*%I=bv+jUH7kMdF6UZtb^4@lvm1EK$4!bYzPyp(hQX2MSLgsRj@^}VXv`!s&d z*=ZsA^}T?s)#^gx!@>UcM)6y-0Xp>l4sy zougr>vvGurNfb0#_y5!|#@KKt*>k5;xU-zObFOjy#enfkI*geK%-jK%bJIKzrmJg& zX*h>#T#M3jy`}A*aLOm;^o>CKli^P4w_G*jJhhU2b*goBMokQUm>XxD@&C7%KmT&Q zm_-Q4GYBm)j4CsZt}?mx@M7YBI9u0jp4wub)@G63aXI7Zl{?*}+&5PF{nmwpHpQRp z%0}!f#~rG^QR-$~8mqz&rcreve}wR{= zwHx7uQpbsQ&8YR%G0J<>`pHn?zkD)ui8F)d45f}2ew_^Ef>K9z+3$DlQht9j^cc@y z+O@NRQpaRsER;G*#M}~V5!v_0u3i2gCqw^89cS-Koi5NZ?)06ie>Zjf0`fuT zXf^XQb(}KgOkciS^5&O0dVHss_{8P@G{^FppQ&ToFLNAKlNN_kN765IR9H7hk(r;V zW3e_M4EX1TG9Dqf=g&gF$qB#)@~_1fz$O>@hMp%4`8T}01zVl7 zcscER`1qny)3sPrvE`D=J*)&jcOIqAIo-tsk0GQ7G>4~5X=~0-y}5lH1MBN`S7N{O zn^-1MMMZD4Wu+y;tyY#d!6?T>%w%Sk`U3hYYj+6Fe{{CW*flWk;vuR)h)$15-p&*c z=+eL3gsD~9u}vB;=>GBBJ#o%=Q~M8#w?CURQUUV$hoo1p;6hzN0w-Lk?%1?qlUG9D z$CVPd)e@|;R6@g0kZ0i@1m3{3gOCoYo{@T|DCJoz; zuU5XFd!Ki^P8p>JJlPKNKB4>ZTDN^1_Cqh%`7E7rII>m*thu|3F&R5T*63`Q58W8t zZjzbtF^h?=?#94G5|q{s(YALe7jbJN{74zzi~ z2#5n?S7!q-2l9Ze=YTUT{#qfw`URDSF|RFJI&LfjH+648F1L`kNc#n!DFEXf0(^6G z|Aqs4d3==cpeVuTy|GWfJUGnb!_)*n0X{L3;4u>)nMAb=EOq{^=TnUvX(2ANF&rls zZE-@^bWN^u&F6zA1;+Az zW6TGQ??wI^-=p~J=OXvJlPBEBSOxGYh5W4)X~MARqNCW8fYYqF(;H(B*+tU&^d61x z5`m9F4dE~b%>*b+MhKxgfaL;@`#HTA=t@4~uEsrt(P#^w(R5L_b}^6w5#!DaR75tF zUn7J=Z!z!(R69~I2gtz844g;n1djmsBYMz96XY$Hr-fATt9)%4;9N*4kG0f^VY+oZ z&78Ls842z24F{XgqZ1{7JH0m3F|bHDiY5U(qmY@UA^O#*nK+oQarA6OlzBXQp8{w^ zk3M8#HO}xCW+5XrP|*thuZBYH$!IqTHyGW0q@CMN9xwuKo`HumRc{gG&>UOBSroFJ zf?8agT|NZ{W5DiNT+>)oNfBa4Ob6V^$-Tj9<~lHjxgr4lutgk5Ks5HcvQu8p5utDc4k@fMOyjV zo7lWvP`O06wigEcOsv+I`zT~6%iO+191#GCrdFTv;L;+IK>?LmAv4XPVtMhe=+-mM zoFfFjjeOvZCDJAd(2#EZiQzcYO_`$Ky4o8Z;ZA-jvE5?FV43~r2flWQhFnr8i zs~F~yt4}p-XH+o<^gfS%mXBk(ItIM+e@Z9~p?ffIaK?q*6{tt7t=J!O-W zgSkgq+&Nx-b`>v`!pX(LTbr)0`%#Rntl{8iUQ#F@xVlIp3xEO8wNqB~bO7Kh1$y!M zE}B|>!Kb@byZy*cB)~JrkKHDbpBM_ zo!ZBJH3{T6?o9F|CuoC8RK7p0{!#MW!X0M}(#>7@e#B&%Yvj&xqS$Vu_^Sel(B%B^ z`uxb`e07!z96&@JFNp1j;U=7Gps7q;;34fhM~+26ci|4y#5|Yul9_bY90K?4i(yvS zu@~_8((HCDl0<;53EovrE(|-z^K1k_UJKWk$lqyVb;&cwt;f50nS>C|iX25fUq&%R ziW7U$D|}%ymbnDE=$QaMm>c>T#ciP?_sl*pOPFL8mfo4^-BptgRZ99A{(PR~=gUPe zuZFt?Ll?_158LfBSt^FL_nU%K^xEw7_JWr50@bZ4nIgcc3EMg&t_PY|gJ+48!R7M3bT!<oQdft@` zT}Mq{Br2|`m}33=&KK)zR4}^}N3=i@tTS{PRcIe*JDE2#<4+3an>OH3zToy%1ZYFx zPx6!Yc;J^2r8zzKVXXT^-K({A_nI{9XPT=Y_9Ul?)a(hrGd0X-qkFZ8R^|$gzzsP! zZwY*wdt1CKs2Rt_dAD5pL9O2tzz;C;D8;+w=FBzc5hRLiO|80)gHvHeFxpLX-AV#9 z7Z_0KZUiP#+-K!z1lN)&7xJ(OAVRCUuIWBynL%1VlX}x!6a$?}wM?nDim0}UNVx*k z&K@aEM}RIDu9ogi`GHT#rq%qQq|UO3o$mnnot<{}jC)V58iTrmo&bD11@Pfrwc}qj zxk@?xf7X?5MhSDnL~Z^YQgzifQ>)j@DhZ4SQ`q`58uinb^{N-Z-et-J^**a2munyB zR*jmntYeTLuvDweC7RYOIoJbqzD*58#fH}_nA*P5ocXL7OMtiQ-a<|3;pCcndQHwe zAFML@{=T55VCqM&I69VF)wQrE=^_4<*6pBk*E8`LJMf!F-LVT+o1~$yMFmqZJ?l%3 zxdeSmm44P0yhII)tFD`>$Q?Tdx@kO$I~KCOEr@+|rsoNa{=i(LZq4%1$*xDdpC1AI zk6%ZmNKV)`OMp{(kJ%;KH5pg$Ot~OluRMO+WmgBsD|FcH#nwKQh!c?IxWJqCV2BGu z+y%v+q^@eFUgtTOVuA<^E6|Fb%0%m~t{1qI{nq?nn*cG45fU_!&RELkzEbbBr!JQt z0Xm406iu>%jQFmG<=8!~5?KaK5I)zWt6xX7c|@dWl-`*Yzej!7a2K9J&~9gOB3|=9 zgta$v-1TgNBa{|wbFe3P0B$Xc+>?s!0X#ailvdCWE2?UTb@jTa>}> z?v#&E?_I@sDS|Q$4GEoghwU5>Xr^fRq`+g}z&^v)c&-bb1N4UYzeggUhn9cTcwxP5 z!0t6ium$IgxGNXS;7|%)Wa;M(R_NE~-nZthw_pPANb$YQn5u8s7gO1EK@ZAi_N6A^ zZY%fTOSk#Ti%MCQE4fpG&XF>po2fO@*l{~=^$O>p>T8!LTS2MRQgAP5&gjEVI-gDi zx$$Xtw&AO7Ja(m=*oo!4>=Rd=eBDZBfLBjLmg;a^0KAgmr?MxWkL%U$z2GJ@tBs9M znb?=-=+>yzyHL=yAP&TrqWg7PN|fk6K2=GSSJTbKJ^OmyjL@0zVbYdIZtxQ3Oqagv zQ(F!+Gw-7{HE!ImOd_ zx2KU~wl7|@R&>_#M0K{A`$N9>qZcZNl&W}rGdU07gB6eR>Wmt=mEze;OK5Q1D$=Mk z$&2jQS~Sgjl@CRF$Le`8h)i|;6e2UYPj7I_fbUE$>V^t9tIG&8wfVq`j`v-^#>%rw zqdBUKLAb7F4;zi0rl@|Si`Rm zw86j0fID!FcXJUZOs`(qjxV(K7$fL;GEz z-h(r($W+DfK886oUg*v{A(-~bxZzWr9^)AWu7We?pmN)9BjvGGK;Me*=dGIf!8I@P z)1k{)fsB2p!A`8>`bk#Ciwp%Yu+w^ij(V24zFkOLV-sst)CLAz>X*W&UHY1=FvaxG zM(IFtFCvo)x1%CvEZP|8(I2>Pu-VVbnywhqE75Ow!`;8-Gx|l*ixj z42GGx&2Jyvq&$(Q2#!4To?v%=;AvrXbH+hihV)6n53X_p?r8<{7`JIvV%Z8J#j7E( za!Eyor&Kwky$L=G?0$XgRN6LK7>Siyx6t3#G8>tpPC*+-u+osRulDi`b~QNpDTABM zeN6Aa?H-()o|!WNlRxCWW4WMCdOc-kKk6dCqq2RR530$1jVLt*T&1ZVKU6B|3oo~i za&P)NUUX75Gut?CvEj{PySe(|Oylq&4h;L+T-@l`b2l<|pQES)h^qHaJ2J=&kzp8v_Ytm8FcR;YtJ?*T6!*MRbyrLA*7u%xU&gU4)X33SF`#z^g<1l0&rT;SszP2 zE^1xJIElw2(dveUsPPIQ!znA~WQ2EY?mZnh)j3eOx^veKoD4NtxBJi$DXgAuQvZy> zd5iNp=UE*Qd3H}`qFx=g@$!L6>j6&UextH?XH5m7n5<2m`mUZ=D0t1^hQ7~_=%#_c z{otCkIk6H3gFZf1!uvpczX(TVudG&MQF47ZYe%mGCW&(5!`&9**rU#{87yCp@N@k$ zB9P|_DTVOot58wAo1|{XA$rAq_SHVtyrRBgTdh|k6D-;yBH847%QT{b{>@!Gg=4`F zx5SHsQ3R#}0lUJUj~Bw5b0nG|3<;W;InV`zhb$))U-ED}Nih{rhBS zo$28hD~x?Juj0aP`Mt}Nxb08>c=Yyp85Z+9-i|4Cdy}D$)bF{%oVZte-}@d+zkD~k zr>yQR-3TcngEnq5aXR5w>c}8x8Glt%=54GP+DIZHF#b2R9m^ml!wbh*W{~RG9BK6{ zb&QX*RO8ft@V+cclk=-s8dVC)|8r5@IP%HSOSZpK$Dm8tikNfqLUHuqnbN!s{Rg)9 z-_|@CtN(;xvfoP`-ENCe^0gV*Y;mUn&Bc4X2HzsZ4wmyf?WK;#**wX3mWOlXQCE(% zn?tf3OHQvuTQ2OSj()VY7I#lHg}ui0%7~gz(iBRC2)n6%66t;C>-x9U@!i_D3le*& zBWRBIe{MY_6}0S`ibrhyE z1I=+Kbqo;OF-I_UtQp)jM=*8d8`&{OFmOX)w}!ScFYk>9Ru!i z?wBK(IvTX!<=QbvFm=2`XU@|hqjom_WdugmobPp=npDk8Ux=N#z>JK#oEj6vuGCy; zrA}ROb@qW5orMU!Y=ond?D3JMs8i1MS7@iAqnqf^l7Xi# z0e8Y261!B7;9zDJ}If|CD%lAaNf+EY>)Q90Se@^Xh2{#9Ix6a)S&rbsd5KN8a$l3E~|8lSoT zCowg#mAkWtrp^~EZ~*52m`>f1mR?Bf|3N+VQ@QtV05$2V_rDfUb9DkiLoL<|+hf!k z!{~bB*#C^A_TJ3`F?Gly55&|lOfl%GQ`V3Fsi#&wy5114*OYLhIq}9!$th~nsn%43 zjts-DOryRWf)^J z&#or^K7dm{BwCpZUHCpoz(s?keT3;?uXt|M*)VbCSk9o|NC6xOnUMgUXjZp*vyg-C z9}oT+N|kq^^Z^K^X1h@82X4Zi-G$Q6e}mE+JDadZJ?gPLP#WtQumh!`{~St>fwvW- zK`1q&v$m4aMIQ#Cl<)60VRLq&)NgMSw&x4@zI}L20k**TW!` zI_^Q~nH?w%+k;Z)OAtN*zk-J(B{&GB_kTg@Nm?jV;VBw+5K5)?;>@`7Ae8bhzOmke z(qq3vDUP%Yr7jN8=p86643r8u#GlEd!p@5GJ7Yr2pEd|WDUn{#XeEgO8;}hb2cc9kGw_V(A^y>MI-Uw7lCuiRIWg8L)`SP4^fU|N zxth4#4wRCZtfOf8j6oN9Vq1q zaNLE`vkJW+lvYoJP`boJ6GiU~Lg^SOgJ%ayOK4fwkE?@F+8+tQf6>34QJ{z)CQOUOYVaUz2}2&GOq5K85hWJw^DrslA0()!7YdtqGgAe34_ z&+I~}3j7z8p8Ex*8ImmqAe6d*P&)MsN^!rSR22RTN)16MbpWCCfgNDF3#BCbvr3WH zxD=X&zQA*x*Y2S$JiHk#8R~m(g{>cO+pqDU_Q=EJw`t0E*~1Fg4%fUv(#7Ts=};L8 zr^WE{!OE-<42*#c3%E$T)$^4^*%Fwt3^17BSZ^b?2n)?@8g2CfV+=%UhZ!K!tioeYZaII4C&nYikX^`)ErVt*#5e4x#LoR&PCS-6v|NiY-U1Rss1CPVP zkXQ8JlQY)*W>-1n@xyn6KDU-PpPH@oH+M0R3n+SI z+BH2bm!TvCAUAJ<;Ad?;+Ay&Cq~!%{U?FhZuYYx4lUIYo3vbaR>Kk2EI@0$=eSDy=iZ@t##XN`h5oChpgLnT=37Br^uFpwx5E&>cC8`ccB=-L$pc_ zw!hpCt*dnh*4&@KfXX?WYH6T$6yFlh*3J#^6gswYL&`E8AFBcDnU2hOC3+G1zCq8s z>Ml?+>i{=?F$OX)$GSwhH<92U`Vj^=8<3=x0=wKML~xrhRB$kavDLCG}E*Aqkf2v)9f0%}bJXtrI>;+EJE*J#t=Z1VP z@JW*dJ`yd-c*F-h;t~&PbTGgb?d>WRK#76pg}`qQnt}lJZP0$1V!!E1qrVQWav|Qa zGn)@GO|plv8vqt$JF;n*_DJBuG(%iI;)5L`-vE7M7g?{XMU|nutEz^-f5^xdm<8*@D7-aRvxeP@yjR!#n5?GhbSqG#oMqN$9d7^xm zX^=CFe(a(9V-XRvOdElaF{0&15g=h5MaEmvWJaW+@e!Rn+B#u}AtS?d^bwRmXcjly z3=&}8Kz$_!P}=B^%TfaaR|9y6ASlCY*Dc5rv>6UX?w!zQ9?nUwUr{PWV z;=Bk93auESzJAU`U2$hM{KR%M`e4r_Z8e^k;S;!3bS`EP8R! zdxb(zoM_1x7HAXw&S`A*2 zq((WJSOv9=q(Rx^bEV?{5LY=^;`LFs6G(uG%uJN@ms4k5%At|#VrGwp7((Cyye>^7 zIy^5#ZO{`d0_Y8TYLXK}#ghJjWWqU{)Ekr1}3qyTk5kqHILYx;&oJri(z19fjO z8AQ!f#{fn<@IPpyOPPSIIRu|OCTb06WJ3M+IV)l$6=YWSdE%A`09LQv77Dba$`f0*^Y{HU2$R^5c zRZ%!OdWFNik}1bk3RvStEg*peu@vxb@P=cK#1D$N+s*hC>XVMXK;B46naNeB_#lCo7aP02q1}m2I@P|3m0E~t_oQUmWT5c=wb}zSU-&NS< z2<`Y1Rq+(Mgh$XHD49#S5oiRn2ZHqtfyhW$;K-p+inO_+DYVRNwTKBKZcU~5H52fX z7nSrWYt%I2GV%u}Ct0Cq+qnCzd?ALNM$czi+zo-9HGIb4njW+H>b z&+0TQ6PfE1HMRk?n_M@dh)5!?h$33Ki1(H=M$c&jM{TUg6#9!Hn3EEOrRfyTO8_s~ zflP*9mH={%ODG(HCP zt9D2TL?2HDNmi}sTv!S1O*HIEiaS3<`%``357a#P8{tabG6!?2nB7?>lqf&&iWlIG zsz06Jwpf|%`2*k~)oa(#!w)wU-fSocZ|$2+*g|hxZ#PAa%el=ep(Xhjq$q7?v z1f&>0dHVw;9$qy0LotKYqNU#)8PqIh4RH3^GfStT;&YZ_o-Up%NeylxtJL4AY1{Uv zQ4?Uho{FSVXrMD`{fSR^`($%n4q!l5O-uNy-^{PO$=Z0ux_}+|#_F-tx{av#Gw*Iz zsv+_5mMCOcvK-1Df)(rj*?sfuWVba~qjtcURXbMBW&hl4(+TR(U`4$T&!RT=Xt^{8 zH@1T}E{TR#@A!e-iL8{HXhUpj>OA5LIQ&}L108JP{R|j?tv8E6n)g?tOj_qJ#WhP| zm)h|6zou2%lo{~><=U-I5v~34HW0n|{u-LrFE&0*N{0r6DVD4{A_ZG9OxS{gUZTs; z+Ox5_?yn|lsWUKp5NPQlUTUO#!}n;T5#Be^*#eMUW}i#}wry76)0eh&T80a(cGoWz z1I$3(7K*;En0RG{I2K!06$`Xy4{a!gszS7I&$HQj5xpX|?k zK;YTZv=hy;M@ta1#+pqTD6~lWep3Ex0?Rc*1=$fgU=V_-&HT(7^{wfZnUiZ&lWt@t z66f(^MYnc+%YyH1?;SOV1&#{sc(@q*$lVJg7G5-Jd{4GY+d~@xBX;I^6ZE6v-V<_w zk=@RHO#QEjY(5Bq6~zsYhB#q9YmWq67(p!4Sw)S7Jspd9I~H~UOOI!GaA7?5X(zpj zH3kWwNu4bWFbZv66F=aX>3D(&&G(upOq&oObW%izp-wQtbu7nWnpDz&w`F2TD1f`1 z=xz-R_PrveT_nwgFfRw*>5S>Iax>VrIR8V@fCpv&v~G$jkIa%DT+ki7z6#JtGKdvD zC94#_=Lh!RDpcM=CCLDxbG=o(Y^QqxAO0!5fT5q6jTF%?)~}`gE?sm5jvwpC=d6&A z;YdNeuZgP?Jv)65X?R7vW`&mi-sSRUcw6g*_g9x`BJs2oM^_MU9aE4R8z|u)inDf@ zBMr5v{8-W3=(%l0pC1V2Z-Z4!z z5j&ve+WULns#2!+71#mSE`P-vNlbd9uKJ-=_uFX9(?YV{h%wRfdc?;?;_FpWNID6@ zWs^NyGVMH_o1sD1Pf{z*$9E;tO`zYtl74tf21rFF zy70aKvITCY&Rsad6iB3%91M}q+fiI^0#b~L&1?s8U&I?)`34b0n5=KZ!QuAFMSti7 z(JJ!xl=ji-^XUuZVJ0m##N6YeYhj-vTo9+2$gU%`$|>)QDwGRm)8qM-gg~Ga-)RnAV~;0F_*-K0a|t8m4s$5JI-*9%ZrpWYZ~x z9IkVhe5=H1$23F=esKyogiN%-1Ge7Zt}dw9PZ^#eAerA;JvX%a_VhbstvD_o{$<yI%a^Fu!FzLMELM8skFCVDo7s{2UfRjR<&84JpRI@9a)WMzAp(Q zNV2s=P}-X*P}MJ&62?WV=$ZySFP;W2*v?)){r+0U5BcR~_t!>DPHPac>F`a5t3nH5 z2CJ{(pBTzkFXC3HRE0V+oQ}z0k>fND*Jpp(4ex(^LxrwdnkBt`GU(%mqHI@X;pu^v z{7s0{_=G|x2)S5Nh7S(VB?W)#pRFtZv3Xym$wxmL1Tb(D7U@S17ZSi7no9at%u-e`xtl%G;u)_IS29<9$Bsm?a?|Ip# z9{O_%Kel1gYSXgKlIPl*m$Ybogt)<_@JUW6!T*GCu}zUCUWsnO>0E9oyVHCstV`hG z9K~VghS4Lgn9r|B0$+Y+pDJ@FKnUNj*B<)HY&h_WYdHLn-C2S8vO2q>9$16XPxRXM z_D`535(+y^0EdEAC6(y8ol1QHq45`|lo?IG!lsSy&d~;3Z42d(#WQ}E9UWU_Jp4%G z?4{Z>>`Mb#C-84V1nJm&p9#iJZ9vn5qf3rSB~7eYx25Kh69?sIt=GFWjjtrK$Uhq4$LXOtg|wK4?yYddxe_#0P#%Y-(QI=f<(lOA=1 zd9#b;hgG$fS7}RKSI<`BA5trnQuUe_#0t<~W;HqPP{2PmfB1OhLVR)u9+IdUf+$)& zBZ?=)%Pok}YszvRHyzS|i2Edl(Mq05N;cQzXfN#yIQ!y!jNzYeE1tB5H8MY8WyH42 zs;4$SO9aB#WmKLfR z)ukVTq(sA9%puwA^B-dF4=Q0&ZO+r3@Yigh9urG*>Kr#@sbFVU;m2R&664n*g5SJl z#L67uGBZtT^Y=}>7(#pQD{6V~Lm;Oh4N_kqdBQ!}~v50%;X)ZVkU^GZB~e$F&_OJ(8dT8hH&o3Jfw3O`d@CjQujt#C&+t%R8-n5+g`8r+_Glc$M9 z6s=oTUt_^4Nhf6cjh4Ir+Jp^`*?7(2pHc&fcM95yPL3dM=ZJ4uK78$ItVWFNw;iXBVSpMF`#LfepvUUhzS9DAO2r$PZ`&#H#=h%C*NL!qUI`dEn8Kt}$YpJ48hE0=5Ah31)r^sx$38X%TN3O9 zJn#CtGnNbA?Lgd@d(R$;C!(pAsdn@?CirbaR)ED<(PS>!T_`P3 zMM>P?P0|i?h$|03o!EiWk+VT%rQs(*D80Qpl+dyRrBvCgce#dlp){`kDq3m>O3~-@ zWUpE8KxsQ#s=EH#y&Wi}!^m{VYC6OZr_QNj8&&_%3Y6+f0ZkC(dzPzhU@V)ukua@T3yv;h9r%=%9rl2N`@eZQ&(RV0QAKcE_`~~ zT(1k&Pxfg)YS7D?+7&tFxZBy9{C`8n0jl?dRPFx^ z$MIJ@!T9z+u;cXqqxg2ujtg}|K|U_K5dl`XdwkqvlJLI^a*iAzSN^0v!li_o=!Vz?Do&q zm`kE|(YK$t}RpZ{J-Mn_6-0mgO zQq|HOHTK)PBzkN|jcJy5^IF+UcwQdAf(K*J9X0meQ{%ED&_%{Vi4S02TXtbrjYl4x z1J#&&@r~QwCDDVwtFh(#T{WiKLm%y^aU#7`L^gjmdm9_8CCwSEKYvCysK$gqDNh4V z?BK2%>tJ*C)VL3R5C4J12vp-IgADf~{NQ3cY8*v>CLo*t&3ZdG0ZHT_C~;1$tBE(* zfojagg81GkuDGMdL?)|{HeN$ejYZzG-ovZ#rGjdlPh`4>Q{mO$QR7UeXWgB2CwOR% zXtXk4*QQ}y-z{x>Ja+(j-QW4ubgyCa*$YD;Aa|GxnIbBAz?bQ+7Dj!Oq&rleeoyp? z#%Y%IE9!}DtPdcTz5_<_yMXLiCEK@b7%bkP&~l=mw||LUlK63e@Kg%Y?S59UdgA1V<)--{hrM7<7{~`)sXbe_FU;A5oZvPC+!~U^ZyXu3IPE*!RA)ZK}g$3*At_YEL<=VL`!=0zVGT0*mpTyf=&3N zgh!DMx+JfrsGx>XINa_98}y~&T7#Au!2Z#1a^|_KRO$QBgVW_b(fA7DBJS1K@5}rA zGy+$JmoZJlnM7d*5yaAo z(S?tg)mG_4_YgleOodLqIIG~93DBjA&aEWE3cu7Yr4CQO)bwntiWCfooOlbb_1@ef zpZR7s`GV<(;bxWj`|q14y%7B3H6uUmAFkz@eX1a2z~Uc2L~xOqdIxLk-zBirC;VVq zjq!V1`I2e#0FlF^*5j!7DFDy5MRxRrXmb;d)_X+uvHF-YT%K}p{pjk=b^l!7Z<@Gu zd+5BOkX#S{+oh9Z_aU(_Hu`>k#Y6??c;A9}#jjxjc&^aLoWgf79{A?uxy7&Mm=%d! zp%xl;z*`TfdHrQBh@>Si%ujqbWYEesmmoM{ydbwVOC>DJVA<)?h_!ED2;zml3i7P7 zRJ^@NzK5anUT7Uu++uyWzS;Pw{=x-S2%j*(*bTB4_A1@v9;C0%%f5&C5g4^|nr1a$z4!#9JzjI^#uZ~%SzIk%^_foB&C=)qw7q=A71 zzpi|?B^YqN&S{hzQciHHQ3HmgZ1;Tm`}+uyx}R;sBqi?*U8SeKG)FH~y#w@Rf)^ z$OCv;`r6}Ei=zBbhImnjYy7$moR@QGf^gVt7~r8LR(?JtLIw~gICa@69kFBn5FBvX zklsU@emTe01w&7v#p&0%+GqpD-GKxRH94~GBZ3de%`CVeQEmu>rEi8LFpsoaCL(5u z2%ALI8*-4(QM`|S5M>zN*A2flXgV2%SR^?t(>iil(%^9Dr9s5BB=cP|6Ui>(paEbG zaabls7_J5{3^Sz7B4!DQrct!(E;pMOJe1&Z4wZ3E5C@djJu3#8yChwXiG;>WlGqSvFJM=X^^j} zjJ|f^`(Ghq8JIzMoNFX{L*)TzDp z3;{(_1SKa@ZZ6vs7mu$VZsW~0sAAMPz!Zevsek*6+?I3V4H+ksUpj26KJT?mu(V4TJJ^E?aGF06sOr;kfKJMsIW5B~5ji7^-M3rwgly6OxZ@TYbbTO%%?j}J1 zpLhkc^PnO)F)^$m5el|T1g97y{9Eaw;dnYKZBf{UBJC$@Hcq+{8MqBHdQ+Flqt;@< zJjG>>%0zKw_UQ7ayj(=s&-{s@1dboIhrT^fI+N^2in4|AT}RywGpBD zqiR;XYC$ER5>sY45=#+f7Fk0~iMX##m(ei;kYrRZZ{8c?1EhN1f{K-kHE^Y`>NB}Y zlcmaVH6Mg&7FpY3vSLzVZI?@yjkImvF~D+=5X_jhj~|j_h3yt$c_7bHiDZesnK(Sc z%wvNEZCbc5itLy@JnMxxT;b~qPhqElv{yJJFp*WjzmjMuv~U+`_^N;OIGIIS{+3)y z<8>!m7VaAliO5-=GKxy=3KDNSf&QWmSbni0Pt#)2q`v!bRV1s4-OxS8H ze82}3B3nw)ED3KCA)K%)wb(!zTR+|g4|RBz9I`^f^)&?OvnyUUFLns_UZHJt7-g;X zFlo5?OsgFd4AJ)?Abn;_(Guks-=Htar+4y0Jc^oMdZ52b0pKu^vf!Zgv-Ez()$%RaWXc~ zBAQg`h_oTQRk4pt+KO(O0Z7{UHABp83mnYXJyEhIRtlwgtLD`wYwc+!&{zrJqyX?u z!s99{K=MGxPXc1>y(@#gOcosr;T=|6Y}^fXW%`9+PaO2-$*IJ;gv&(d6Tp=OW@v8a zZ#Rr}&!T;yP}+Uj4dbZTWlM#gcUn6pd(LK5haP$q`?YCxYs7IdwiY13({nicw$k(pe;wa~?Rp$Lp5M9W# zl6jV^gEgRNdtkyGTyvJq;VyG5tBwJitE{?kWBsRZRA1pJO}Y`8$aa>I6^dAf+acz8h{gRXibCh0dReWo23|Uv z6w?ngCIG$2_YF0g)wTu~SEglu7LP8v2a1M|6cIDl7Q02hcLd;x0a#KU)Rba@2be@)+fgn_ z+iWRt2GzLTlb1ttdBA>bnDEfq7UAX&DdG)#OBP63dnWnN% zH&g_r_!`D?K5m&kcw+-|dmub5-tux^Y~k#rVLSp50^i`kbB`{XJV2ETeF=icT29M|Y0VPf&?@^ zLDN8MJaEa@bp%|bQK3<~_Ga<1fjK}G(_8hYr)7!V$WwcmrZ$Tcw*NF?^;TroU=}eq zc;^!t(N(wZlJu70>03~ZGl^O&tRo(CWdm$bD4mm;tM=UnU)nZ}c1!l#YN5swK_3ju zZ?-{%rGi)u>`66I40F}yVr{d&V;LTO+1k~EJ(>5vU zQ_M+~ijrf*HL^R8^}T~r(YFVtV7?`+3HELSem&=0irikhtKlTP)`QgV<9bPFYx#4F z_WQ3>_WO^-Ukm@fRV*>w&;6r%mVfK+8-!QMInMT_0?2wuvMJn}${ZnnOsJ26CpX%c zpnN;1_hj27;v+$Y4WK)tx=;P^6IBi^)iW0l1U6h#=|6Okc%EaTBv@!#Z~p@|fARfc zM+6H>3T6G%OT^_O@TG+ft5H=N>i&vFJpy9zL*@A#mRGfooXq#U-|<3kzPw~pLc+96 zGGnMb-|V?XUs)09^9hO4+p^S0A-6ZJ?FwlLZlz4QM@f~>o)ybNRJhsF`mg-?l4#y? z8!vkqYc#jE$~}2^Pj(6Qh^d1Qgy11dv+A>F4>^d49=RJWB3R3QSw3R&;B&!f31yO) zSc$^przdXgZ|gaA{6g`8t*BP({;-e47gx^*OW=8=(l)gUA@YrE$GNVnW`^Dh?!9;h z+j+7$07?I?B$*}T*|N69Twd*MkA)8DJ6>yfRZu`c$tj9Qm(n`=p0r^qu5*NM=IA@* z$>Ooq2Hi{D?#2h^U;E#MBYVEJ^YrS65{L)H=z^X-Yl@#;^t}*@uO9jkKPJ)(i>sB7 zdl!byIxd`_Pjzs~q>0F4fqUj())lubuWq|mMEY0?DP1l)&)A_5`h~`7Y#=D9jTC91 zlJ;xT7rZ3OYir7;%;no&p^kV@>o-Ej-Z2v~>lg4P%yz?|xgWdvo$Lkjj<`k)zY)3r;4ZCWLfx^{LP--qBi+vO zB0tAt|^x+?cw*V>bKKmHFI!SZ8f2*wYO#Sh6dwx1_Pvd)&nX*HN>>e$2V3I2j}V`|S7 zVtlm%Lw1ec>+qjCv z(=iHBL3%_#o{kWDiAn7sE*=f3x6zmBC2pL0cGdixNanMnE{owNmUo%m4&B+1`lzV5 z`tsqf8Yd+Sv}@^5OS%ZJV9NeTY?MX#-HEzG2Q2igX~J~;vCDN`>N|NY9ZLLSXt}bc zn)h+#+(W}%-R?3Dv8!Pp$0zXz8nr)b25#_Sr9x8-{!n8hJ|Ui=D@9Ji$8X8@Yji|o znL=pw*UvPl89}(i>Z6Xjy78iasWGk5Rb8QI(PGt%vmKwrF|#F*&9N@Ue6f6WdSAX* zg`Ew|w0`5*n3ONS)L1GkHCiSwENWcQnd|(;Z|w$#v$~n;XMAt9WJUrfr?vOg_>(0f zd64sDtQQ<^6(Zt(I&|nr_=5_|K!TZXgmvuMV;xqS&JDW3r-%i#Dl@V%p^bZ6U%L-aA^L6S|@osW~!mFnAu%j30u$&`=SL+>o{fw`xrd~gJ7`Us( zy%oQeMHhl<+-UUNWu!9W);pAvyzx-{NL9f_Tk9K*#v?T&)#ZVTTQu0#IzQv7Js+{OFuuN!Z#){H(~2)yq(BY$UeWVCs$!~C~h#`Yw-PBY*7U2lo6u5s4h&qq=a@R9 zfbnZK>T~LJOkeqWMLx&vg%5*M=9^KBGQZJau$oi$u^W~UI=cT7x*z)g72VN)8n^zJ zblN^j*#Fs7`(NwsvG<}RK?f(+Gzx{vnS-OK+L-G8Op{|>q* z|2ydZ-%qu7&^`U{(f#jJ?fgHZ`#(>$<3M!J`-Sd*rP{d(_clNMh3i|zscF1knjC(wPj+WyDrj{JLcC;kI;*ZC8= zFNPj`$97;zf9=SuSRnoiEzh#e(&5a0yHi5HHOdFJi*Q00FJWta88y@kDisiVU0M*I zqZfIoQV2Fy(hFG3)Oy@|rTLA#rXkSyxL=rse%^fTIlqjn;&?AJ*stWpv1yz0TT=J-spSn-=-U8DDnf;NV* zYpXt!(_U7E4JW?N`4)f4@u$c6on5y5zBc2xFyrmFoeNpV;{3en*Hzy`%b6Z_vGL&- zI<{n>>o$j*df+d%4*V*-&uRyB2q*d`yqg3IZ{hAYSjT92A#W3Hx%iatOKR^tcM9)M z!RZv>htbUY!aLj+88a8a!n@Hx_83@rtMz$rT#fK}V>H107%aR;9%hU_q{3Ih#Cvo2 zIhc4GIO~&Xx9$f^jlTdBZ@pUxemY+;)dX|J_6me52E(gC2&(=h^?hR&b3 zAp~vbKifQn2S0A=1KNC`t3PP-fqOPD5)_!gx?+ys48kww;5R$*OVfDvv0%;JAe>7^ zsZ8B-D$|`R8G?Ec3{lkZ1fl+zrJpzm^$`EljQ+=Efb*7qND%5x{p5Lo3`YMLoNBRT zpn9SAuztYA+K$r~;;;r7;E5&n24nbR(CM|Ecbp#2yet-EVMrecI=u=G4?2Ag&dv~L zg9Mxs@4GMpxWxPJJb>qX@I<02X!g*Epv zn*GWjW{-DV&T*8qq)Fa0dnEH+2s4TO;c?LHGaZ&q9~yFnfr|KI!``Ebwg zdv^Wa67+jWDCqa0ZlRHAr#-)ixuM>hMiqm8zv>=n#HN$r4*I>V8;XY)61eO4F5~DK z8P%ab{C?1-bX{>QA$ln$psXeU^!pp2-$w=2XggOTV{FA(?(g~iV9Y&k;2P-nozbA* z>ri(AU*!+LAB+V7e**-3!Vcgg!L3B?xJS}%ajtH|^zrMLRCSzp8ULs=nS(htD9Anu zl?O7ue%R*kjDMB~2m}>>I>S~i3va|G3MxMSAw?xhH8|*(;zO9rVE~u=?}~5gK%qGC z$UX>@jq<6oIE)fcSf;@iGW*ef5A6~XPy9xM=gp!97 z5|gt*(l22GV#m>Wzmxt^H|&n+v*(Im%9ZHx>M9NZMgPVqjU~o)NA$IwZ!rQxZdg4~ z^tIDfeiwawSI}amt7kA=2u2^cEBb-zbW7$1xZ(m&K8QDS;ikJvDoo#lgbWNpb#eoY z6KFZ+q7^cfqD#QUqZeq4f{lQl;H<1o3}x8QNQ!Nl;=wJ!V3`OMHaPMBz?{4cC`vNV zk`QDOwqP!*1EsCd;3)IY;Sx;#la-hf%q-HL$gJ?DQt?M6@=c{WIPpzjR`RIQ=z}&G zId>v~&Zw})(9);%(F?;gHw;pR*S%lL*xe?oE5wqB;yZsrs_j zXUY06+V$})k3xSaX3GHr;V2(jAScsdfdsE|1rCtjj~@i*&(!%=0$$ZhSI;Tqt}xOZZ7_DfGFm5nPT-;!PAFW*J}P)5u2hPtJ5*$yD`XYLq( zqXcm9cqK(Q)K+T8_z$o)QikiOF|9_c$*|?djyKlN_#y2nZKWf*cXo|m2@C!ys8&lY z?dJoENNu3;PyI*%WAq!f*a8{bs?g>@b@-ATlH9v%{MwI~RUUg5_zW`Q1_oPT8W}8> z8C@5N4hkx)ct%rtMwVsFzAMGQ_0`!j)KhtxTcLx==bKtFoe0WwE8kTh7wIVoOET~a^C<6Y<_vXOzGGRYH@$l z>G`A6+atqqazIiQqOe!1FJs`w0MD0tZ%_1-n@_y96tl@a2I%g%`0hk&AkD+^NN|pA ze(_RH*CK6YdqNL6=NZQ3`L-j?k_j6%6`7OTmWS>8IhZ^2#<~rx)d!nQ!vh9ymT8{o zU($6hW=A471EBcuu6Sx-n*e^$j^3&fps|D7fxqgExYOH;oBS;+6jx6S9BUGMFE!Y% z|K^m*E6Kfnoo;YNr6{EFxslr7as1$=6U)PuORG;aVF!63KR}X`gN1HNKS28tjs20RM&DOA6J!e83aaJ`q9S z1~(RRICVIm#sG_*U{X4m*<#N_|DhL1f#5z*u;PO|mvUO{ZvtO>Twjrghj?C%$h;~? zy?Q*L4Cd^xR4`|MbTBDv5%StFAHg|^HWq{&wDB{%#nu7`!b+d7Q|Lc6*_ZQH5SRfw zFSI;0q%w?&nQjwvPY~j>TEbj2=>b&;20Zw^Yd-BG^&7 z+6Q&=?y6xncf}IGoL!RMr2*GWq^n{9xV3TJ-LAHmfe{F<*%UABbm>KVe*qsefBuwje7b+F(nZN z2tD&rK=v3+E;vpagUP!HLtuRunWMr_0C;th@ZA&rNd|a=8R0c%%j^XXG=0&_o%#vs zpiI9kYpNQS8WJ{ezLY$B)f(w3gcKwNxb7zJ{p7TZkh?7m9Hg7AP8r*pkkO964h$AxTC1ZjbcHRV_i}p>T3yR3LTwfP-TPc#H z!L%%b5TpW;OhZ>Gmi~oF4!Gl0a@vGiu^8jjehOfluslC!5A{GvT?Gm>RV`{`7GqvM z4^IUj1&nBx-jEIl9|gRr{|2qf9d>O79|fc$=cR4|^u*kbVnOR@8)t_d(!uDG_eeCRu(|6b;?g2FH z%uD3@1ov*4=I)bMnJ=9JvcjW;FYmk#2!Dr$Ze#L(D8BSUj|)zIV+B9-NdKA>?G!ya zpP>F~>L;O%tn2kW<=wmEz!|2eP}9ww#{rVCZAsUqZMJ1WPHT!Cr?^)Uv| zf{z0v3)+Ptakp^pRH~vO8?H+LJbbcR3OBaM*k-d>RREKD0`2L+NHCedzMISkUSf+` z&-QV7GdpG#%2n?jYJK7X>XXQ4Fqxl1TKoO{Vi>;&+4pz~>9+KfuV|tk zMQRL&KGSHgyGLvd2z)m-^u-Z6X(r_R zwECc`)*ia+S!v1tOjUH2o6E$@X_qw@;a(K$$}^_i+mbH-(u`NENB%(fYTI|6X*bvQHmx7ms#$%=HEaJavwx}~^HgJ^(b3rOGlG*umINj~Tj`c;CtU7TvR7ZS@+JvN^dpUj@R9vR0zK#meW;%$Z{WM{trTF&&q`cMzrK`Hv& zL5VQGN^5tcqnBPtNIdTQ;751v>Y1*?l~>QbP(8PsY7d+brbO2%qt8*bTbA@IhBa9w zCJw!rf2C5WNfA;$-IP8?iOHNJU(u@%_8eprB4?`%=EC?RB@eP5wbc`sHp@Aps&nP4 zI9$i*E7JqrFGEkngmhJ)o&C>K!N&wN=c2`rMbjNEvWTXOPe?xn;Tk(B9CmJeQ1OpD zx&?VP#x|`1y5oa~51LAG$O(p|U&x|O$o#SYg!JSW1-|oH$}Do{Zz1>fp1!3mvH#dx zUkR`I0EcteG+4a8d?Fj?=^0j^xV}#U(iMPB)eNVeMt#6$>He%Br*(Wrw~WyC1f|E+Fwt z=Q!kq7Z&07MC(HX{=8{t#1Y<({lrZ=F)CuX-rw`_l_LK?Hm&7tAi~V8AsP`3`$Ohw z>6}FII)++@D0K!p_vRj@1Ux(Pg=bWU0U0*_DS%&W{?f2y_Jc5ulNT=V;<<@gPnV-U zxb^Udve8AIeuxuwIC}7+)8rE|4tLsPwjv*pv#pUlTt`@qVl`zNT;$Gwg-cW;!{3G} z!w6q+L0p?PkAiyb04|jjwNu6*+x+(@hOO48%=M&ezZW?VT?=t-%P;HO`VSv6^%JY+&pURX|JPl`|k zAK4yt+z0Yh8$7xXCw|374+Lf2glH*zW%P5ACB8yvR-MV4?l59CQa8>})*R*skM14h zsrKWMy%X0mO|>*mm-nQNF68K6kAHZ7m#P@jV{}SJ9h$EE_(KXf)$Xt|Bi9L8$qG7x6zISk%N$9qvgvtxLUPFOXI+)_Qc1rBG^e6A7b~D>r;Yl z`}v2+!Jf{f*%5LeKtTbsysLFB{qNCz)xRtXvX%|e3r~GmoS*jH=9Nxg8GB&wDRzH0 z)<-+RmJ{eFJ%>m4nL3h0wJn#ey%0wK$N624bSv9{6X!3k!05TzatfV=9vB`T-3#Vm z+w^tCURqIKI*RbIb!FSJnHN4<+C>H!UHmy9*?e?#pCtIO33R_MwxP-|gHeCbrIio4 z9*ruXG6#?DD)vD(H>NeLOX&q#vV)#e#FDOs*k00_$II!qcv~HVasyOXuX~kHEstt7 zUImZtG4-Z`k`p}>7dN6ZWuKoXAKg1Q-=`%V9^Ic(ImUO8kM0xw<|>W}@>E){fXOoO$VkdnjGt_w1XhNTf0n6eWH>ytmL(Ih?|<8 z<=d?pX|P4R3%E~B+q%?$m0Q(MlF7f23 zr|l?f@?ZDD|HB*ncSrs$h6(@Oo&SGE4*uEsPgS87dSBleOVvqt-ddgMt19}`gr%V8 zQ?D+b4|k#EGwiP}S?G!oxRb12Q~EVI@Uqoxe@)ra`xK>MzPnuZ;~#P~^9t;HQGKH?{i%EP)f)@lN&9E2d%)4BdZ4*ti; z!NsiqGIFqXhKwA<5)XI&v`0IC!z1JXAR`AexrfNX=Z(XifA$e_;BdI}pU6B!4n(=h z$N?SkaOdBl_MdnDiGS_z?El*NUsOM`!w3D{`NtmF;aUF!Igna7L=M>g?)(FecK$Tv zoxhJ5dFS5@?)+1ZcK+RecmBTr-ubuE>wr7|qW`q>$NGyM?)?4O3;(_IuO{#O*XjPX z^Pi(d2KXNC{Li+4JAaBpJABq0xbvqt+W9xrwjA#Kea@++(mm9H(cF{rK6BHX?jDVz z*UI7KzgO{J^g*8y>0gt7&fao{QcZZYz2j&>;(d3_r*X&=D{}yoaZ*n;KSMx-IlmCzR^jkn@{F?3> z#o^>1{mzO!`LA6h7J~N}9UAwszmtDm8F2EC1t^_7*~o&!z@&%8?|tx3o6j3# z)lVa?gfT#;TeqM~m>7k7zj*%kyG<%yI|dOLZ|{x#bwG zTDEVJ*g3t|D|iW~_idyHz@=+Izi;Ims{Bt|4?eC_MB6t0e5HnI9(Frv2L`-9pK7WG z+O#)kXm+tg`a}S5At}| zlK>v}c0G+o&9~FxihIwWPqrR)Epq}3HlerU! zDEwOTvJrZ*O%3cUS?@^#`;xf#Sa0G13iCH3rf-CxXO)a) z7^JR_`CBPqrupS`Gn}!LafOBXjw*1M-CwXd*iMl7yBLWnorMbguyd?95(TK_RGE2y zK5wG|Y&0x?7%{CycsW-Ft3HB|AecM1aJFM1sH)J-GW-EMeSr}JHV!t52`$&cLnD}x zU^HBl8Used5%3{G7$_K6#zCSFS%qkG6hXkdSTZaeWEI52ziWlBJBELc4c{sYKNt_J zKN%75O7RlpQ78)ywTQy^W{VUsEZ)qRgk$Q^w%o>A!dRjU1j!kKIZ! zaSWFIuW#T!BG#K7u-UU7lZcsGWVQ!!g$Vlf0LCgY%I!9KM-hNk%zJB?K6#09$T>E- zi`M;?TNd6ex5~T02!0a{iFa z`}pBtlzfDM3FZL8@b=>@jt>y72NJ-AJ&ZCx33ywZTd<$I!J0QdSo%9Etu~>I$H|@S zDsT!iV)NQaPj~Lj>OFK7-27!`za!sEmq=9dy4_oVmjLc@n1QZ>$+(PXsDM53Lc{C| z{9@+6T?GW>HA$dzpX@5w+c$fn2W;;jx(bS`Jf^C=epPt{AG!)4)j0QR9+qUJHp8|i zL!WRm#hv7bpsN6j{zmIZ*IhgXiu0q27ksVRmWVjeRS*rj3bseq)0w#q7ua0#HdKo0a45IlA%Dy``NB$wIMg#4+&C@p*ewAo_a5*V>+ zSdC~HPpmh>6@uOZ{Ti~jU`DF>{Z4j3mCYX!z?@Ls7myvr4f(xd-Ni!BAxd8=M$hx@ z_0%X`!W!9IkUAAZ*vgrz#4_EiO+NA#e5vKwty?z<3zvj(2H6iA0hm{ejdP{7R)O_- zf#F@Xk}~dXiS6u86vBrB1Id;8&4nf+!FN&D926LACU&e3!|4K%n*tSEN||*nW}t!LEZJEQ5mef6lR{cUdF(G2RIdM ze?mAse2f9X0fK{K$2^^h%BAXolGX8 z1w?VWPt=Yzv}WtvfE8#mFzPbs^4u^f&^Z87SNe$&aK`N}f6;C~{q7f*R7vUpJx>i` ztGfc7?YhtGK1mmIz#MuTv;@#WD2t~gs8i}}puD}&pvl0f{ZAYC*iu6I+hFGK?cp|& zJ3tl>N+zkUyuPkly&Xo)2su$-pG5k=3{no*BPTx+&;_))8@s-}e4uCE$4$3<&_A?q z_CXXdW#}YQG~UKLTI!mAJrA_;0CvATKQjRK2WH|kfY9LZo!5wO4BbBt-v1$FpVYS^ zfs_NJu-5hQ! z)&q_Q*?%k^-yR>|+Z%_n2S$DmjNzSQB&!bELDhj|VDum-^BZ2X)s&7Q2EfSOFPqU! zffrD+8QxofU;pkfN?W-`2Q>1UwPC8te$qIy;Pm8dPa8hL6$j zum-?(8TMx4wuWhy^25XN3_=Bcu-QfG4EMMOvh(0k{~VFEgOSN>|29CH=xW51jR(s< z^2>gaZ5XY;d(ye$C|fK!q07wi1=uZ)-a-;2@!ALx37t1>@29*6H=F zdjM)82Y3h}&|>&m35C;hv|r!N(k1``eLm;(0E<9-V?#hc%)VJ{QC=3{TpMrbbF-44 zu$}!u>EuiK7SXY1*7h^g)2qXos(F1l_!-*@QnZ2MrWZRCzY~K_zmj2k7Q=X1<}8}u_4g3h4^}g={3b4dH9e@?PBgi^1@v{EvUO*r3d`pF5V8$yZhqTwkE|{5O_#;Pg~Z!lz@ow zLNZ7nIHQ+U?=a)T;7OGqy-81*AG#d+5R5vHg--?*PHvgN(&s8Rl^QS8W+p8%6;qL% z7eC^0jqBo{pDnJi-UB-vjb^%&+Nxjm=ge-o-;^gY`!E0m_7+IV`X_|XZy1j(GGtEi z8O^a(JvCH_QV=z3_X>O7nbrV`C2Pu>m+D<%Cl$erc4-Qk8|sse?+qBY&80K z`#bA#-6|SztSVi&`HHbC z7n9*D>-b~{Uia!4>5jRv0F~YLHEq#v@dh2Js1=(7Q;r$qh&92cJeozv24@PU&Vl-Y z)SCX4GgAXRwwWvaKSTviq?{uP2hqs44VONE2)p8yr{LkmOg%3gnCMcJNN*B@cGX6Y z-ETatEdAvx$M|yU$(ARlZ+TJXL0)|PwG>Z~NQ1CiVG=|#SUCJd7^4&BdlKkb&M4$0 zy1nf_o{SZl;}_sCJ%2Xa*eCZwzLsf8F2z&7D=M7k86_&*cI{VGd0oCdP_5UsK87pD z_Aw+o*Zv9#?naBNC%dB8s48ZrrSaUhA6)S@ZHDQ>*W0zybFOz)X9RS+&2D6co|0n4 z(xZd=LVMim`$?koc*URj+yfv9ajz>n56LaKWP#^B%bXr|28I4hLcA8@{Z-gic7lIU z4mOb^A}X6e~c9*6HF9?l^*f_8Wu`RjaiiqKB3y zu_aTR%0EY#jw)~dIVa5>%9We(55LfigP!3uV~Z zj|vLdniLBVxuIYc#A_*P8=9@629NHc;7o<-|4uv|;U{tcO1EKRqIYH7GP}-TEW|P> zuGRO@#t1g+FUNr{g$zU4Cfdo+49^~9L2hNN-PlvS9D2e$y8&HiW@K<&%@CPq=zene zQklzm=2A^){e!x`o*lWIPZx(rZ`4T%IpPgmcARLuZ#5(*p852>VJcN@JIfOY4?it} z@>|`CN3}_wH;QlnsVAG(*>e2VS+-K;X4-EGt?>KB8%kY~&b`Hv`ha zKgdZKdKqcs=NVY0^qbA0sptgu?7opM3+8iRfj)vO*@UY2-b31_uEwmH0+X8c#tLkn zgGU+d8a0)N8Bllr_A~s?VpMs2KpST;%Agg&07(Si7##o!J)?muG=Y3z8NXVJc=PG!w>-H@rM9vHU@7Ypzp7 z2}Whkr@}WMk^FsNuF-!G2CUHc@TiqCU!|P0-rd&oK7$K2L|8!1~RkQfj zZ6*!B^Iv?}6C)ItnNB$ITDKWl<(WwYv8x&1hnwFjJh<47xRVIE zfA*uosNuQlzdQeliP-d+4>od`DTPm5RMj7O$52<6sUpWMEu!lJ`J~77t-b&5{OJYw zrrGLVO(-CqoAM|mzcDmQrOS1YC z>7kS=1Z@D_E(!PSW^JFL1E2l}p+2pYA`G^KN3yaeB(AaXPL7J=v#7iR2BF9{Nm8+) ziT3&Mp9>|QEPV#~_FFjmIJ)oS9HKM(t9aI>^yE~&glCJUv)&KwraW$49r^8owN7)y z-PrcnL*(FzDm}K0)-m|$smmkp=2l8SHmdXOg;@2ooNqlnR@IwrIMJWlCH$bsGjT=`5*1ZTC|r07`%-*1#Bj(Lsyp5)QcJyv|hN>{Uu z-BRjmRHC)zzmNmQ8Fl7=BL{l9pp^t&o+)Qzd59dKD=QjoY!8tGm+B6=L*!sd#if>v z9FVG}X4Y|JN=!wVR9_#7%z@QawZtVymXVUM3?4@{S2((}&1`bE9MO?(`vY;4km=f&#m& zs_OP3hL0V>;kj}j@Afe>IWp`1%&KBYuD{+hnwWlrEvydp0U?Wj5MMv zRe8Q!*tt)CmETQk;{D;&=QU)ugW z#N^tmJ-hiJwtnVvlk0#BU*jtw&?Eu4eK6-bu^oBYbMiyg_gI+u>}D^xLm*SLBZ5>6BT{^j73)$Evn59o(+W zpD05P=Nj}?7W@N5oKgD+i0E6dV{!Sm(Zyf&MYQFdO7KuQbsh4e%J_MYh04=iqFepf zv@DzKR|?<>g^Aa|6?G6 zG5fy`L~xUVh}sdXdFZO%lBEhaA)3YMoztlsnt5SuAxg`_nR#ZJT$3MQFCpS$l=pc{D7a*s%WZi{ z-|s)WqsI&Td`#nU`zd}6y`tXH>;}d{26OCG{w2j~z25~7U-s#roFwLV+59+9FQ)d@ z@^S4r?>y|xv9aZQEcDhK%91ya;ZAW3Qa&4}l9cy`L+B9nHY`QL;}LM^36_TRaVmBe z2m(AJa!*72*^5)WVTP%U(;RT_dvgHP1493dMp?3QgZ8q%H&)2VSGay7R0B0nxkLPP z^2~JT0}taStw+tQgDlkI#YTR#YCfFP5bA5PEfjR>Ev(Z7s;l|fTEqz~tIx|&S{*UU zI}wT}Gm0r$l29<^0NBYS7D|%hV6Du%!dQiz$se}~)N48T_%2a1w+!Ew-QX^g>}da@ z808$@4}Qm2H7$Tx`;!3^G@hLh$0Sk4$ph_fecdA(zO#CtVzF8L)k+D|gA`XU9l}<^ z*9S%t-4yQ$5GB&RW=4!~LjO02a=@;+kJI47Fj1$AZF`BrIZ1bjz|xWPy4uI&U;zww zQFG0@FnjLpkC{e1g}VK=dI~)ADi*FRTEjNMs^|Ht!*+l@C_T?w_;`oB5+tdB+2Yd; zw#!Tip6Ksu0@HRJUmB?+WH8XXMgrV~tma?n)1hut++1tPr(kK%?A+h9dhs`R)Xm=| zSnIU0sx*EQ**CDe(|@0iA+GcCkE*}`yU(8L2$dFE61|`-OJC%8X*Wq4^XSJ>C86is z{9z>_M{rBZ46GywyPYr!kUp#=U~ViLvh-<7*owVgk#G;^;B{MyxhvZaG48eP3oR1#x|MyCXYR!YwF}YZ z1tIFiQ00rQ;TLAYHv23-1XjG23l>VCvtqa29=4gyf^a;7JRJ1qQ*#Bi4Wzg#e?k-_ z?#SCf@QDPy4dOmopG&^b7oWgBF`3ujf} z{9wq3QS^>U=nxJ+A{Dyj3$rq3V1ES5=J)oe0Un`TH4#9fkn3YHAZ00#JDY0TB#;sx zNJtN)>O-C#W*3~cDV!f6VNJp$^P}#*5W+B3 z&S7`q8{WL}(Ba^_6m8DoGAQLTD-3w;y(k%^>lDL?M1)RatT5#60-D@iD8?Tt95#`R z3B|TFDA#@j(1UWNr3Ow)xi1R4vWWpZGID+sID9vwS zHYo5PvTU?gY)A+^-C4>S{_t@Wxxr9x6bC9C*rb347Por{14~Y~q`A24IJdkpH@k43$2c(sL4pO68b~2n=yAL>%?f!&u;^8b#0WBNi!%k8 zfsuj4A1r`)kom0}uT?>#1ApY!LZoh$|2*Mum*E{XaOb=^yCCp{0CpK1w=~g;oDc?! z#EWl{_jPaj2-#Hf18pfT3qM0XptGUaokP0RhlL=-VJ~ ztXvp2qhMA`bS%*jt%$VbWqNi?KpEw_4fY{WuJ>3{Q_U$iDd2HVr^7y3@jT8dH?3CWC3Uo`Gxl;i*&YL}?11dz!ZBQ8) z73OLYfF=d`o(oc9kwI|u+QjAunR)(E$}~|a?BBOA+XsRKLkLj`Ba@lyj_^@hpgGXI z7Zf~T&B11ahKLPW@L-hv@KyGsw=dvRIHDt^)fmjH3j+!s%8Suh8sN8xO&}GDdsfbt+lu|x&Y$voLSpq5(8Z4Qwu zW%h*~kP9-mo6IMB9vsUZRLs|h^UIz9OJfDBL)Q4fBFFhob2;HnC8kXrOF(|*=t z1e3N%`xYSGuvXkKofkFmOMdPKU?^jjCk>=QoIu;d2N%HPR~Dfx%Tz19zm^g4%6WHDpDAAX((Ozu=|r3YgUvkpXh^sY<;Nubj-_BDz6Q8Rsr6%7xB zPPV&BFemkb&7uSl%wywnqb91uGT_By`OS~)5M6hXNAq%uVxH6zWu>%ve9e8Oy@p3O7yqHw3CaYYz~Vx*W`gLHPsX z`$yXC0bic?u;0u+K`qs6#bD`yRkYR$*}@v!!*mg_0!t5%vK=@eGr~3=dxK@vj9SID z#@dEqIqV z?4U9-+@G~>l0T$BnPT=1=>a@TiOT8h-w?0ty8#5Lj6Ah`NlrfSk7V#dRz=Ob`T?!) zUHYL2!-i&~i-SJ3uZPTRHZ^gUq|odr{Tdo>pqPKqju)`%vWB!L&t}0=d2HZx3a26M zad8X}Af;gSArfEuj^DLZ6hO$<5j9h}RT;xP!y_8PBk!W3btX~+CgOfS@q~1QWFl_V zL=ST^r`cg0wv=eAYNwXmxq;GQ0pI%iAb4A^su7mMZkq^awA+m95E5ixv3x% zkwRu7;>NtFRCh#n!MpB|-CH z5@JZi{Kqf()fD7#(7Hox7POdn{1iZxVb;3$d03jTu1V+@K{)g+MHM3L7^a8`UFO`t zQ}c~2rHuG2r)!es##G%;<8QiY$8PwJo{)XBDNZGHx!4LH3c4StdN-kzIU8fGhe3$% zk>=xHdVW%>!giF#;b0I#z&&z|vh2L;zRf_Kb{WAj-nwwRu6O&AE`<;|4&k1;ta&8? z5nG64<~+dQmT%u-4hVz4S^S87%KVsgL_z>is@MbIROMCr4btGnMskeK}$fGyP)b;DM2_yA$@hk@@v$a)Up zNy8lWgyVq`O!~L+EuQ7UIi3*SE0IGvB|fT==kDGS84yu8&Gks&*yM&{*TTCDHr7%@-o5hgpj?g1@@0s2h>m=R#+n2l}v=MR71) zl?!=8m98{x&-=yYcnhkq$`*?liygao&0PBR!bEHv_=PYr^Tiuh9?y637}Ol2D@F%7 z7fx8JnV}w~{dqqO0d;Eq<0!CU*nQ(u+l3Hep?V$)4!QChQhg?BVFvVjGV5e~Wf z>iIpig5ip4#hKS`TM7zUUE&DvdxKn6K6A$bmId(+Q4hUt_po*7~5 zyPC$o=Gv7g;nl4Uxio%kpz`e6#D`K*Hae1jneA2jIbSJunR{tAkxALS=%BNrwE@j7 zjLA;*6J|Uv@%kY$ygd*3n~5;>%V%_3+qzX7&s z5V0%5_qHI2rF_RDs`wJSo2bFGSOiks7W?&Pn=DXGAX&DQV9B?{)O+uczF<=(*vtH|p+sK6zD#YSW+K(xn z=k%{crnNl1ciU`?%B5u)_WBj+aZnbrP9RP{BwXc&#a|#|wgg#6gXr@MV)SM2$)o0G zN(ZVIC9&?BdIi^uuW(`3?w_TzU2EYATlUKnFjTuzbs6WzfATg{(SkRfPK=%3%`i2N zmM1g!(@FKUkJ7cBM19!dayRA_6OyDXV11w)CV}F{s=s4GLdWuxSX}a*Rf{dN2Is#( z1V}uNob&zyulVwMw|Gs|UC6*KzO6g4tiq4ssrPorhi`cWj;vQ*J~&gq_h-R6Syc;s z_B5Swp2zQL)GGPWua?I$06_ zfdBlhk`1-J^JZdSUgNBd9QayV2Va(-T%;Sma{_k5!5{y`j88sAN3N1x(rF&Y$hVb8Iq=U=GkJ2M0kF& z*bo$MKKimElELVNPzICaJ?zW5iz>XwukbOQyNbPpO%F8U@@H{Ha-O0|v5DBgL3l~x zbiyqc0%yf&xoxH6T~WT#e9*S5v;yHML^#^q=#(Pz!J92tQ>N1s)SR$Esh=-zLf>|q zVQ}`i6bvPT$(urFJm=*G|6T8EBHmgV&CN+_6plhq6rs+gIEcQF;0S+Uf#K(11l{oq zL4L@a=Wc*N#G^~@FN1387btAwO?ZFkbV;d{^hRPzHDH`zk>a#4xk#Z47AeR<3ak?t zq@ZL^wz&15f)q)*tO@le0;qCKhb6u(o~0RA6z*(cLMNGUi%{O7l0O#aRG!ZJaG#Q` z)CVGlX-ikUt}2O`hu0fu(7ugOzG8fun%VSb@Y#6?=c*VfL^ne*$!G(@^$;5TjD>v(&fG+fSn|N_;Qqp&Co#lqET{a{qnMhg5mzTTRmAGO!k8*0zxr&B+>g4t%bvsLA&Z#C^# zX3r9f@N(5U%vdbse*uO#ahi=o=>HNJ;xhlW^XFu*Tok^-FClS8T1w!C^jVo3XJuv2 z$z2x{k-dK5k}%@xdFAUDZ%JKNzae%{?&;a2$()&>7p)hPa#><|_OpRG&2+LSNbR{TKKZaFqo1YLfO zy_4>DHz(kJeuzeK*n{$y4{Ku|)yF+%u#2%K<&!r69C zu^AtN6Kz_LASXWX zn*GS+gbD~zB=b`;X=^lYkG%18N%$mB5mEI!hM#5hLAWA!?clZ=b?`cSxU1CukTDH} zZQ34r)6w_EvlORH#_wU5=PvVxdtH-)rNHl=(&b|ueM8k>vZ6F>!kebEeNf6`soIy` zZWGa!HGd3ukc#CdAc?6H&>;Txb;^|#eDPWd)1~Eg>QxPN-u!7g6PcIru04EZRYCZ* z`gOj2nyTtP{w5e!Z+afrdSK-J12qMG0mrn-Ro-2HL7-sM8U0i<6pDki>zyKwqD7N= zAkyVB(`wb9#F)I8#asxmgyv492%*tHZQ(Nq9@n;n$0Ch*7P5<<(vVQ6H^zfTxst`U ze&5^J2-Y+9(nQu=^RjQc+wH-+ZUwulerfXQwN!6b!d}-MTZdOqWjTmbTdu$RxA$Q& zO-H2*;w@0mO{3OT$}GoZ1(q%f_>?&+$)SsN>Xuoz)cMnE;IeJpJi2Qr; zdE%ae?^1pICqax?=&zR(U%FYEFGiDqNva_1ueqvAzoTcjH$SaI5e5*ec}WUoo!{P^ zm|rt9&&+>%dk&+8+_&bt;2qqx8@5$eE>-Xyamc15#&ygxKMOst0T|4^z{gtdq(iB5 z(CMIMA;pU1;;nwdy&&5rui14&69P&XlC|wpv;jMUH#CkJBr0M+qN3TSXvr?a#f_ju z9V0}|eFF9x;UqZbE)VgzrVWglI|ww}53qQZz!{|Wo#|APo=ziMblTrSXD?9nY->aPZr(NV$Nr3DG_81WBKBG=Pwrorn~00TW2fV!lrU zr0KYV_3k1K*DopngdIheO^Cto(0Da~vI&Ss2n~2t_Be7)Jmt^>T@w#D^uUq-V-7uS zq+eUNqv2kNEIV#p9QPa(>II?|6*$)D7c9$+Tbg+PaSs~)Ko%)xiaEUdI37eR{M7>d zX#%#Q0^HRCLA1iX+k4YD+`l^vL@TnZ10_IT1k&e?DsX`%f{1qANsri$4Wf08*ouP1 zfM|s&EF0oC?GpSL;_(Opw0Ajbi2*%Ap=lLV+m0dWF(HJqko1v|?I@D}2aZrd5w9(i z&@nZf44B7A!TDTQaMKAO4x*v_VeW^@iPj_Kgi#p2l5ut1GOXHS4pdGcVx|Y-Vu#8J zj4I%!9UvqY9z#}6Fs1X8jT0I*Xrefkr7H8|C&+zJ)KP3rHcq5?Cklp>jT3`zzJY;> zsu8b+UT$s$UF0AeCknD*j4Kh7prSZ}IR-RNm{8|~#t9AhkXQ(4oM6I3nh%W=&DjnF zalh&8mx7>iB0Vy#BXWHqG7TQJB@p#~9C2DBsz2VzJvM4=?=X`=hynqNRw_Ck9Aui` zdfDhjp9=GUAjDPLW|QO#ZKY!B1PO~l%Wbh3d@K_i4ti3Dc?ALVKd~T0gn^xm@3i4{ zW+3gs)GEPb_5?Yv5j&5x#8y=B-DPSmL0N=SOqTxiNZe4KiS%jDUaS*JU~j zfoo(v529^0&$85o{9=bl-Aek({unQ&AjEGS3Gx>F)*;Af{9?EZV-`K)EwE`Z zAa9Wli#g;iv{|yAF4Nz)1XLg%!koY(h{yeij9wSYO;&g_o-tcBal$AOicOqIN!)Bp zOg{&ALc&K73K%WJad`IgZgNorA3;G$K)43I*rrIqEm{!Tvi()w5{8pw7O4VjEw&+V zKn+EKUu`M{Dl=Pj7%fV~dqW+Z!LAbEq;0jq1fyPb~W(#-QH9`yjLF};uc>x(9#R|CqA{QxW5V?S0fK2W@!f-Agoy}aw=q!!lvklFK_GHwd=Y=v(v%A#>Wd04M7c1Z2C5cTo5|Q$c0!Z zCl{)K(69|67p9Fdw7^F$lvyF3vy@TUHK~2lBiBtek5ph#@wulkc0 z0{sSlGdH=*gpz{vY1MI38qN>kv<%y^Cqw~deP+k*(nOHCh-ynYkT>LyE))dfWPn$? z79yfxW&;B<7cv)sOsl%Bg|I}Bxp0I@=VN57TSz9oc(zpP==S^dHtIZ(dmjChD)|d~ zcC?bfdv&`S-G+}zjadr?+6x;c(W>rSF3kW-IyNZ5_Gk~asyw8aoH1mrp@;x2PyC3CzVqDTr7D-~@dO z*1#embf|D&p4MvUo18WGyWU-JQC?$xj2FmUE8ZHmOyJGEb7-T8Z23hm>n-qyeJ>agT!$-u-1&f)>EAQL)Mxma~4!>tiyLG(~Js@~t zM;lMi#k}H)TxIFyWCSwn@EbPBAYLHk_ZZmT@M>^ArdxZ39-#3;?swPy_(|)&`sw%g zvOjIr=;gT%kB_9|1Mk{%DevC_CiCVI1vd4)oi{H6xh$VQ<=bfRBE)sd+;hvdbh zxx(Ht`g?P2>yFT7_DDhPLXfJH*24-M3hU$j6HTGGV z5gNMk9xX#4-s3B7r))bND?V*GgM#(Xej6Zt6GD}pt^XSQZs?aUJRLQgC|I*UQ?gyw zk|;q%@SWHctA*GisRr6c`TE1?zW?~~jfoPD6kFdmT;K6p-%hpz+Z%hc>!g86$p#xU z1n`_NNhG&7%IC=K4Oh`-D9fphj5jQ*m4f%}Ygfq#Xe{*0$?&FmMTCyK5;JZAAtM$ot9dJEM9`E3kx zn$*i7i#W-#NDyBPKrb0bB-4@PGi8Q0D3)-EHHo-r5W!81{lWhWkj^JQdbIp!?%kI7 zl>F~{D1PdxwrjI4q0JJ(aSFl9A5;bAH~8WG{0RxDE@FD2`uKuMP{x)ftQqfU*5^3M z3wSq;mWFvs+C|xiuE~TOULK^85(Cy$A;9w<+US%R!8TAyQD9c0{L~_k3zGYXxDnO1 zHMg?vxP9u*cjLw%f0XJvXTKLn^zC|J99^6zV2O}T*{*4xUQ+sF*iQO%i8JkXnWyX$ zC9&60q47hrc@+pl7!_N?8PBlBYs@R+7_kN7^2aSQ*%*0;kwOi`zy_*fp`fq#m}k5s-23^1C%T!>m9kQyRXu8sy|w2 zGSb1-ZEhD?wu zk={hQfPfNuC@LTzB5Dwjs)Qzp5O3hO_qTgy&)vJ{+?l;IXZBCX%=09Z=XrhKALCOu zJek*$Y!L%8ZS5?rEbtEVdX4*kRYR%NQr8*j;k>VM)0g*dv`6+@{AKU0sdqhdjD2W{ zYh}hwMQX6^R=OuvF7!ZP7Ra(RD%faPUXgVq?VmmuHDRu1z`@`yCLXr>#>OF zb4B0wNu;D7pWRf2Zcu~wwtsx>dwSz<2=(9~eoIPJ4><#l5;${dE+uI{#^pqFh^{W` z^^|Z@b>Q4bed%Utc4yS~0^KUuIrj)s-WYFZfyys73DrJu z7D?^u2{RIZ^{DV$?P?CuD$VNYkNOyJBaY=vBnufOSi^nBAh$$)|CIQ7{Nt{E75txt zhRB}*Z;}YFgHJR@eh0nBM_qKL&ez-cnm<36j-*5ng(kgBra_In#xJ8aCC4ylWnFuf z-A?M3L02EFL<$?^(3KQ`k|ZtKqDki$UrXdX>qVq~(HJj0dHL20%gZ++&sy0y6`!2| zng5WC$_mbL7dY{Ixj-FtDyOgW&RE^>6R+kO@h5)iL&%TiRu*MirPmE(KG1pQDMjqc>-^t%Eq@*|@ebfKY8F5eI25t2&5ls{h z6wHNf@id+BamrB=eCD85>*2ZYhdM*Q^}X;H3z0-a`7MV|u7wIOM7C!ABVvIW$^*`o zi4q7GmO?(K=?pgN_L#6K>P+=()b*+;-r{nzUg4`xbLZ^JOG{c@pH=s&xIH&%;dD)i zd%*DJre*iN)!Nf`B#rvtor<8^awF1|Gta2+kCO0f&Zk;y!=3Vlq9W<1x_Q|!UtjGQ zA?P}GZV!;3^JMp!^z#`@qWk;3y@ZucO32SeX&XyL#-APc3_n};C}uvYM0blTs$<&XJ6)Z)k9=0L3@ofuS_mI5~&jo ztgbHo(eyd|S$@lpJLf^KUj8Kr3%_*I{}O51EVz#NSlxGb_A;4YX#gfop50d)oK`2S z11U4*3W56lom7j>xRX3#Tv34}u=v9q3A`hOef64R#a&SJmPiDN!B!(G1O0h~o}Fj^ znnt)p+x!dKi*dqD##>5V>BDxBsdzP`G3|@IHt(d?g3#8F|GLp+CKgd~0Pv2_vL1wpO zB`@t%xN0KHaD6{>38Hz3<*iKN;)j~R#gr4q)t;!|g7KX?O)Ie-!V6x=V+e<;76m9O zYI8s}gj&-aXt93ZzTT8iaD&dS>bYM3v&g3#sdD29r$I2usHekt=5c6>@GUX2!1OMU zQf?8bdL(+BK;}0|n1hTi3dq~TQw@#}us_x{M(~_b*Lv#&X|MS#%EhLqcY8FCUdvgX zv&A%B0nZ$1rzxfgVbI(HLsVGtk})6C1B%v!FLxjv#Wo9ccqu@ z5UgpW6tF*-pXAMYO)z}0WFHG!>54nItD9pchVtlkPtcmeo0kkl@E!jwW&T(s87Eg8 z>8vE`>?|yC`XYg;`lAjHB#)@5euy`uaZzFLAF4$r#j#!bZ_BggEE7}uPpj#b3{y#S zx`pAGo1H@SVQ57tnXSJZu4xg)e`B?eE@QDrp+?yHzBnQ8$4%$kXIpLF`dUTN@7V^2 z5Yb+hT>>lIeUjEl))ZqceL7I2^3PjIW{!Dcyt`1P(T=3|NwhTXBE;f!3(OR!!M}u~ zKR>j7<=K}}8HG`7ec%dQFboR5is(_A>`3x*FHD#2CrWgtCiylv*gPdc^jKKnp6QA& zzc0ciUk~?iofPJ*qYZj|r-Tu_w}ts7BLt$%;w@r)jtl8(NZXxev?oG1JtZShx}`kI z@IKc-&9@4sY~j-G>Rhjhk>X`}Dk=O#gcS*PO7S#|$!epnV#7V*)=?dz*+2z@th8N8 zmmN(hHMA*CeE$6gxACexzG4cRQ!gnzIM>7PLaRd@4pP{NDKY(c=39BnklHM$)>u~= zv)XyC?YpC8klnrSJm1q*14P~bF-Q@?bbPRC>Wq1ys>Z2^M{lq3)^D(3qCZd^%IfPt zeh=hZfwb;76qkPtQV0#bO#|`entYsJu-XH{#IGOaEZR87s@cLvD}=2h{QKUs1bAy- z|HmMO%1iH(D-Ra}S8by1zdjpce4rGs_g08K=^?xOheH?eEaX~`ck5-2`#1Myd zh(io9M&OV01ZPN)giteVu-^! zMD!7cIIKft9%6{YI>eVF3~^Y8$Q(Ju5Jz>0iKasgaa4zx`tapfQ{VlcKJ7tYcE0Si z(}z$Zfqj4uzuTu1vhnaQ8;Cpf_q*tjt>RND-%Y;medxQm_U!5XpMGEeJo9|L*$#wk zWD0H0aJBy)Zx8t;BzUmo8M;4D`%B+W>vZ*nQnyWf|9lPo`w%4wl)0$>FqohC6& zlt?ik!o7*~j!iI408vClHemJBuzy;K=s6-2M;)UWmYJi2RjeZdqQ)TG!C}$CshP{} zt+wmX!JXd0v-*Gw`X4(E!DHM)jJ(3^M~;K%A1fa`(n5lAjF{t$$0boGPcX{Jpw7r4 z&!6QJKE)?2C(0`$DR4?y=!~?0+zG)mBElk)$B)ZOiHl+s#Doe5rLvx$3(#pJ{jUIKu-+L8*S7RBZY8wA5e=`V8E48=Wlg373T@ z{vist9|h<3n`OJfr^IzUdtDZD0>mp83liWE$vyULL7O1xJn}u0Hi4^+{&{eR;!#>&o-_3xU#4(`+rIo`v0Z9Vw~h-IJ;!i9cJd z>x`s1j(kS~x+cs%Emfe0*Ok70+IQtgQ`w`v)zotP2I&_jEpe=sAFnIRG*fzI*4m;I z2c5JR;n`-bQKKts?I8N0j90h5J+99FvwN%tMzq9uDFNiS@M6XMP@KW#y!#oHVi3nGrgs~*# zK_zrMkhYX??x8&RIZ_8xASD9Bn2*rW?PSZQ5F`!rk(d8FYO$HKXjrQ9q5rLK%jOeG zLaFM4-n+o5&D>q;Q0c{;{&yjJn@>ULGAzCRa5(Q)9>ShwsP&srQf7Fj0$GqJ? z9S4TyI9ln@yhn}$#)Ud^9BIuo$Km#WI}W;k>p0~7j~oY`7?u&yVXi%g{ioxwuQ_rY z2Mf{KSWeda|D)qr;i5T?v;TA)>NLl3|LMOvjoA%ff-ABQ0jP)yigR}H==ahaQE?as(<}&;;{i)JyRjX@Bn84AQZs`b z$b9|R0E>tsL#1>(81%S1GS{8HKKXXJwQar{8kCZB=s1!x`joAL+x@y{N3*Evpb8?B zu8u9={-%2Ag#%|_95{4=s8I5X6)%&(J4&&up(G8*QT-GvCD{w~8r`{{HFN*h zaCFOu6Y$@hWWkl|e(c{D;{2ZlG40BL_?FdvhWw2Xs%oyIF5mRLpDqmAn}}}jJ3va% zlhLpbI;=mr62iYa4D+`Uk5`_yMYf&d8b-V%(S}Cv{uUsLL7IP60c7)v#a)X|4FxF%!+U1(sw39O>;LgyK)wU|3)6LP@vb8J}-Wd z>*Zj=wW~kf86Lc_9O5^H!1CL#rYw5WvErCqHB|V&($+)V(a2NHp9Q#yLd@wue05qK zK5WjOum55E!8<>dEhhQiCUAr4V5PeV;nKa(57 zL~!l<&kcZucR=H~ZumhPlkfaRP#KE$JerO!I>nbc9UNZjziR5VA@5g|;qOP==tzS- z*>TR}^6xJ3x3&Z5Qt%ml{*6{{_jl0Sg)mNpOWkdNmmGGV9=S~i>9PttNz&}551x%e zR&xQcl!$fqAgW@}b{AZ+8TY#eyc-c%Mr78I3`Y0`wd`EHix2dyp$(2RT;{qm+(c)+ z;I*9=B6kk5837u?g%-}EOt?Wa^j@53xESIO2mBCTW~RXY zhtTF1*DqKiz!*F&qd}D;V-cnS60~EQvv`SJ=SpqBnk6dEdJrsazF|q?rSvYxqtY&9O#jAY!+=}1Qhoa z5%*XLC~}Ui)&-XBV&x@ixeFTW&@90uG)@vwI`J1IgSH|df=iwiPJqNm&u-@jV#X!>UdA#l%cZ1bpy^%Q%5_~2lz>l| zOd3-=Rf1liFvaTtiS%(|pwwJA^9l=79tD;|M&DWT+oprxmtgi8YA`O5KCTn}n?9&8jQXzn5_u#7}Mi1f<`9bCX~d?2l-;e|_}^)xO~82xn7 znR3nuyw3m@=)?Oc&;zzH(Hk`JVV+*#lfIUgUhpD)i#_@aO?*&?)5n*RYLBDaQW!4f zLG>}PH+Y|kydbN9huJ+4>9C=eEI;nc~aD83mSAVqU-kTT3oK?NT-)V4PDa-Ly-Ij2=A3L|AhEDwjpwbxjcu zV^v`;y6i-UhyB)j#JKFdg0;_h>29yAdA1o>eH7WK1qF-zzIfg_dm|!#t|XrF9$L1* z>_{^pa;P$TR4qerH4!dNGa$pzm%o|2xj}7- zNKNEsJ!-lSO6hq#oyqV{Dujlf78gTWu`sGSZF~d+HN&6_=CKe{P||YQL%dJ2N;wP@ z29~pJ1VwG7!L1icHtKU%AK92`A=ot`gI0l*_Y8x+agGcwe7|9Xjj4Wmu4F7QW9L@D z4X_R{tvgf{^*)nViQVp^^HB!E0s=~R(SwSBDv>G2u6%9wS?+Cz4KLVW2~)s1py2W& zFy-MfWLBeirB=5mN(Z&U&e(LW2(#q5%nQ<0xud^#@mnKqQxW)u4|GBVAEZFG(m+)# zjHYpcJs3a&ANY(mOM->P=|{EUOMl5jt0HCOPt0Ma4q|Etx>DJH#Osd-$!0f@EJ; z8Zt<-s-&3b=QhGy^j$ab`QlD)`g|bP>}Qm&aEe?*V`JD~ugc`_FLYYnKkfmIim8;e zJX6&3$F~b9bOl9?sq62ViYAzJ6|$*E10_qg*{gxsz+%%&Yd{MhC}RpVl`_+8NF6?K z2mxGM_S~=V#_31(gJ7rQ0r@}P8xEm8oPH*FADMH2x3jkf1`tHmi5S#vIMi+Wvi^6x9`CwVgS39fRAo&WBPz9g@N`Q=_iQ!wYJ};MnlqfhQAt3xlymS>6&uC zWAEURd#&yJpW3Od?b2HRA%ius|0jRco(FCoX%f9#Xbblq?w;6CbEc z1|r>x)(W%de_;V!=m;L(Pvapz=yh(8m#g<iyI%or z%Jwdr_BtB&BwIDohD+*R;z}tOPyZ%St*S{)I0tFq7MUotRK>y%rdy<|<(KG#K=tsSug&h?|p9(T<* zcNw~P%S@|bhX74n&e9^v(EYk`j_p~zuh}?)aV*P57Kl@2PEu+t%3#`_?OWWxcm)pBE2@37KBedv!-fVVWI6!f)aDDb%-Xrp(n+DsuW(VwZfEYJ` zuyb3P2B5YRn>ZMHZkR)0gxzRlTOTfC1Y|OHP-aKwCaTum`BrZT)Ly4{ltn!J6`kee~FA^&deD&St}?@wP< zB;L4DIKDpnS|aNMg>E?a`}Bc1WYXgcgXSc&$7JRK^EPkEK}i$MkmRg(H0|~$8jKd6 z0Lq+@U_#YvZqUgkPm=D`&jm&cTqn(tjFUUTpdmS@nKiGp*M`6-z1NuB*z9iU%g;l# z$EStnKJ940rUTHjOhEIM>Es?8-G}r@!k6u?220t`xxHVU4xX>EH{7}~H&imQ6%5=M z&KiSHtZ%;3J`XhZei_ZqpbRsu=6z8Y1cm-^x$*}1`FwaQ5&O%ifA{{!?Uup=aoh%b zJmhtikU&QmA5ew+NG}MKMSD^~-9@Nq$cfLMw~=ZhxHM}&Mw676dtMCsCqRRC-oOdq z-0#lq*|D!K-?U@D=|2Bf2uspKdl?u3`UyT`6vjRxstuPWPrZ2d&TMRtZ4mJd&6XrC zJ(AyJ!ZNumro44V17F=@b*awN0>Br6&%0f);UB)rd#{{PzzrrBE|>OW0}Pe6^A^{e z{yMa!T7O61|L9^ox*Z6OG5|RE7g8?V`LQ>UegY`PFGv{O$rHfpFM5{R1yOsEY~CO< za_w{z%0P}pqb1f(_qCmBw=~e&O{iaViM8X5S%ObC3mJxjpJ+QVzIs0PhYmWegT7=p zlY8FwnIP!-&t<}KDqiWed0ZZLHeqF(?(Ivev#$Xl9Mjj>h!(?bDm~p)F@fB9-23Xw zXSY{?B?x)Z*ma0+-ogN|Gjz2B0n2FS&q4Fa6Z2vGTMP%!&OhB&Uc(xh!mt0`wiEhg z|92a58RRJU%lYXq>ix+%MfjE|9Q@PMLk{qNT(m()UzQ2suh|Lyx)bsaOuYcKNnpJ( z2~6kIL~0nKs>VFB0iopRjOE923zY2$iX8oTm%BGf79c)P*Etc*#k~BdYo;f1YE6E* zfOA9CWFz!ejBPoVXURp}_PM<0+oE358hdJr@7B(A4_$Y77ZcDU=<<;YW99w6w)x6l z02pOzdrviRy{!yz=(<{jzW6)aH)x`@KxG!c0Kk4uGPL24C;iH}s&OXOaD`o3^5a!m zFl__HB#*H@fT86oz!wynPA$2s0*K;$)VTmq*yaFz*{@B>*boAf;VL3{G4xjd7aNT zK>1_7$K?@}ZI!C@fM_LH_$uut$`IXuZm-n&Q^7uKg~C6>=KVN?+PiDAV{l_rVBG`f_u!}Kk9MaCkl98^39c`UXqnowppJK-jjXvPsj0>UJeVUZ}Wsdb$3RV4 zh8Jl-iPc@*_sn^cb|?QH>bb0Rr=5DgAa8=)9l1KQ6VWQ=34QSmyC^6mc2uX)Tu}XZ z^SIzt>5C9S5pBgO!GXFLativOq>$?~C(eGo$3_)Z8kHuQfW&))k{}6_K|gt&&meD# zx$S%FLKnpJ$@wcP$c}s|;$Y0X*u8jrF%f$ehav@8Tk+TF%cA4OGA3GQEiM1Wal8QN zE(dC+@Y@n^7Hc2^T$uAMf=6m1sD~7#lh;^dC8#H*g(;Kd&d$n`)ZVZ3tTjgKVBxyFHZNuaY1p}@z9nmAOWDN_j zte>rwadCL%l0+s`E;F5P_bwCs_#I2SDK8T~d-J{Y9`@mgeQa8T@?B}7(D}boB>+-7 zzr-X#6S0*ouYi0Z>Ut9EOwi(@P8EwJscdLn5P5)F6EUck@&v@Svu`6(9&j`8O3~p( zl+*d8U#vXjhM#w@?GtZxuYafgC(q{9oqkyU^mADOS`SPo-eKuGQm_~CsrO{dM=2dX zkGC#5H>!we?3Jo!Ez+kd7G}`TfT@|=iGttRuqdDJMwxed`dT`LO~j|g3FV64#%Sy>3!M&d5kpJd$3)_TuMQ13S3y}n$KggfwP$c^o z=Z0Jo{Vnp*xVPNkd>=@T;kvf^mWUJN+R4Jwv@5+Cn`v|f1riS)YiBy<8cY=wKb}|% z$CjpQgNnlvq75<1FO`UDR3wJ&d4IG=@y0diaV0gR)o?f$$EPVlL~I2k%*X!<|7$Fw zvqg2zMszIYt5p%#YirGWlB;o_Q=nY~ssws}ujrYdk=2tUIKle!2)OrRSL8LhV_jEl z)|u?#V(X^Zy{%yzBPBf9Da~u@Zk984VD0#2Qn2wx(p%=-8UcI6Vz(JAd4jVj>GdnA0E#; z=kBuD%{@@Ca5VDtr`n(XSo6(7W4H%Pv`(elcNaE{>4)Y7^ty-*i?pT`YHnOkJ@{S9 zc5k1gQI_#0X>lDBTxlXa{?gdD@%@vvEfdDw#h7E<^8~qcotGHp4BrHt2KP*_#OaqO zj=d(OrK(Oc?wsuuu!Mr!+{C1fCUva@=TIetVwjeqD!HK~1H3p%!RcjRQz-&

P-L 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 Dict[str, Any]: async def _auto_register_skill_dirs(skill_dirs: List[str]) -> int: """Register bot skill directories into OpenSpace's SkillRegistry + DB. - Called automatically by ``execute_task`` when ``skill_dirs`` is provided. - Already-registered directories are skipped (idempotent within a session). + Called automatically by ``execute_task`` on every invocation. Directories + are re-scanned each time so that skills created by the host bot since the last call are discovered immediately. """ global _registered_skill_dirs - new_dirs = [ - Path(d) for d in skill_dirs - if d not in _registered_skill_dirs and Path(d).is_dir() - ] - if not new_dirs: + valid_dirs = [Path(d) for d in skill_dirs if Path(d).is_dir()] + if not valid_dirs: return 0 openspace = await _get_openspace() @@ -277,19 +274,21 @@ async def _auto_register_skill_dirs(skill_dirs: List[str]) -> int: logger.warning("_auto_register_skill_dirs: SkillRegistry not initialized") return 0 - added = registry.discover_from_dirs(new_dirs) + added = registry.discover_from_dirs(valid_dirs) db_created = 0 if added: store = _get_store() db_created = await store.sync_from_registry(added) + is_first = any(d not in _registered_skill_dirs for d in skill_dirs) for d in skill_dirs: _registered_skill_dirs.add(d) if added: + action = "Auto-registered" if is_first else "Re-scanned & found" logger.info( - f"Auto-registered {len(added)} skill(s) from {len(new_dirs)} dir(s), " + f"{action} {len(added)} skill(s) from {len(valid_dirs)} dir(s), " f"{db_created} new DB record(s)" ) return len(added) @@ -495,8 +494,9 @@ async def execute_task( workspace_dir: Working directory. Defaults to OPENSPACE_WORKSPACE env. max_iterations: Max agent iterations (default: 20). skill_dirs: Bot's skill directories to auto-register so OpenSpace - can select and track them. Already-registered dirs are - silently skipped. + can select and track them. Directories are re-scanned + on every call to discover skills created since the last + invocation. search_scope: Skill search scope before execution. "all" (default) — local + cloud; falls back to local if no API key is configured. @@ -505,7 +505,15 @@ async def execute_task( try: openspace = await _get_openspace() - # Auto-register bot skill directories + # Re-scan host skill directories (from env) to pick up skills + # created by the host bot since the last call. + host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "") + if host_skill_dirs_raw: + env_dirs = [d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()] + if env_dirs: + await _auto_register_skill_dirs(env_dirs) + + # Auto-register bot skill directories (from call parameter) if skill_dirs: await _auto_register_skill_dirs(skill_dirs) From 38b51c86056ca5fe028eae4f9807d0a3d31543a7 Mon Sep 17 00:00:00 2001 From: xlrrrr Date: Thu, 26 Mar 2026 11:21:48 +0800 Subject: [PATCH 05/45] feat: re-scan skill dirs on each call --- openspace/mcp_server.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/openspace/mcp_server.py b/openspace/mcp_server.py index d6fa0d7..b1edf55 100644 --- a/openspace/mcp_server.py +++ b/openspace/mcp_server.py @@ -579,11 +579,18 @@ async def search_skills( if not q: return _json_ok({"results": [], "count": 0}) - # Resolve local skills + store + # Re-scan host skill directories so newly created skills are searchable. local_skills = None store = None if source in ("all", "local"): openspace = await _get_openspace() + + host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "") + if host_skill_dirs_raw: + env_dirs = [d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()] + if env_dirs: + await _auto_register_skill_dirs(env_dirs) + registry = openspace._skill_registry if registry: local_skills = registry.list_skills() From 9333eaed42206c596be83d5428681306a4cefa97 Mon Sep 17 00:00:00 2001 From: warren618 Date: Thu, 26 Mar 2026 12:06:11 +0800 Subject: [PATCH 06/45] fix(evolver): use word-boundary matching in _parse_confirmation to prevent false positives --- openspace/skill_engine/evolver.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openspace/skill_engine/evolver.py b/openspace/skill_engine/evolver.py index aab70a1..4dca928 100644 --- a/openspace/skill_engine/evolver.py +++ b/openspace/skill_engine/evolver.py @@ -652,10 +652,15 @@ class SkillEvolver: return bool(data.get("proceed", False)) except (json.JSONDecodeError, ValueError): pass - # Fallback: look for keywords - if any(w in response for w in ("\"proceed\": true", "proceed: true", "yes", "confirm")): + # Fallback: look for keywords (use word boundaries to avoid + # false positives from substrings like "know" matching "no") + _wb = re.search # shorthand + if any(w in response for w in ("\"proceed\": true", "proceed: true")) \ + or _wb(r"\byes\b", response) or _wb(r"\bconfirm\b", response): return True - if any(w in response for w in ("\"proceed\": false", "proceed: false", "no", "reject", "skip")): + if any(w in response for w in ("\"proceed\": false", "proceed: false")) \ + or _wb(r"\bno\b", response) or _wb(r"\breject\b", response) \ + or _wb(r"\bskip\b", response): return False # Default: skip — ambiguous response should not trigger costly evolution logger.debug("LLM confirmation response was ambiguous, defaulting to skip") From 687fb05e8912a96d26c44cfea6ee87ca32a5fd89 Mon Sep 17 00:00:00 2001 From: xlrrrr Date: Thu, 26 Mar 2026 22:43:18 +0800 Subject: [PATCH 07/45] docs: add lightweight clone instructions --- README.md | 9 +++++++++ README_CN.md | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/README.md b/README.md index c9bbb22..7d9ea13 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,15 @@ pip install -e . openspace-mcp --help # verify installation ``` +> [!TIP] +> **Slow clone?** The `assets/` folder (~50 MB of images) makes the default clone large. Use this lightweight alternative to skip it: +> ```bash +> git clone --filter=blob:none --sparse https://github.com/HKUDS/OpenSpace.git +> cd OpenSpace +> git sparse-checkout set '/*' '!assets/' +> pip install -e . +> ``` + **Choose your path:** - **[Path A](#-path-a-for-your-agent)** — Plug OpenSpace into your agent - **[Path B](#-path-b-as-your-co-worker)** — Use OpenSpace directly as your AI co-worker diff --git a/README_CN.md b/README_CN.md index 046e9ad..eca8642 100644 --- a/README_CN.md +++ b/README_CN.md @@ -144,6 +144,15 @@ pip install -e . openspace-mcp --help # 验证安装 ``` +> [!TIP] +> **Clone 太慢?** `assets/` 目录包含约 50 MB 的图片文件,导致仓库较大。使用以下轻量方式跳过它: +> ```bash +> git clone --filter=blob:none --sparse https://github.com/HKUDS/OpenSpace.git +> cd OpenSpace +> git sparse-checkout set '/*' '!assets/' +> pip install -e . +> ``` + **选择你的路径:** - **[路径 A](#-路径-a为你的-agent-接入)** — 将 OpenSpace 接入你的 Agent - **[路径 B](#-路径-b作为你的-ai-协作者)** — 直接使用 OpenSpace 作为你的 AI 协作者 From e0ce168904e4b7d0f43b828c2c0a642f8402a69e Mon Sep 17 00:00:00 2001 From: xlr Date: Fri, 27 Mar 2026 13:08:32 +0800 Subject: [PATCH 08/45] fix: prevent stdio deadlock on Windows --- openspace/mcp_server.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/openspace/mcp_server.py b/openspace/mcp_server.py index b1edf55..7e3428f 100644 --- a/openspace/mcp_server.py +++ b/openspace/mcp_server.py @@ -75,12 +75,27 @@ class _MCPSafeStdout: def seekable(self): return False -_real_stdout = sys.stdout -sys.stdout = _MCPSafeStdout(_real_stdout, sys.stderr) + def __getattr__(self, name): + return getattr(self._stderr, name) _LOG_DIR = Path(__file__).resolve().parent.parent / "logs" _LOG_DIR.mkdir(parents=True, exist_ok=True) +_real_stdout = sys.stdout + +# Windows pipe buffers are small. When using stdio MCP transport, +# the parent process only reads stdout for MCP messages and does NOT +# drain stderr. Heavy log/print output during execute_task fills the stderr +# pipe buffer, blocking this process on write() → deadlock → timeout. +# Redirect stderr to a log file on Windows to prevent this. +if os.name == "nt": + _stderr_file = open( + _LOG_DIR / "mcp_stderr.log", "a", encoding="utf-8", buffering=1 + ) + sys.stderr = _stderr_file + +sys.stdout = _MCPSafeStdout(_real_stdout, sys.stderr) + logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", From 1257f4cfeef791cf58fb743bfffe772aa35bbf75 Mon Sep 17 00:00:00 2001 From: warren618 Date: Fri, 27 Mar 2026 13:34:20 +0800 Subject: [PATCH 09/45] fix(evolver): use stem-style matching for confirm/reject/skip keywords Per reviewer feedback: keep strict \byes\b / \bno\b word boundaries to prevent false positives, but widen confirm/reject/skip to stem-style \bconfirm\w*\b etc. so common LLM variants like "confirmed", "rejected", "skipping" still parse correctly instead of falling through to the default False path. --- openspace/skill_engine/evolver.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/openspace/skill_engine/evolver.py b/openspace/skill_engine/evolver.py index 4dca928..ad9afdc 100644 --- a/openspace/skill_engine/evolver.py +++ b/openspace/skill_engine/evolver.py @@ -652,15 +652,21 @@ class SkillEvolver: return bool(data.get("proceed", False)) except (json.JSONDecodeError, ValueError): pass - # Fallback: look for keywords (use word boundaries to avoid - # false positives from substrings like "know" matching "no") + # Fallback: look for keywords. + # - yes/no use strict word boundaries to avoid false positives + # (e.g. "know" matching "no"). + # - confirm/reject/skip use stem-style matching so that common + # LLM variants like "confirmed", "rejected", "skipping" still + # parse correctly. _wb = re.search # shorthand if any(w in response for w in ("\"proceed\": true", "proceed: true")) \ - or _wb(r"\byes\b", response) or _wb(r"\bconfirm\b", response): + or _wb(r"\byes\b", response) \ + or _wb(r"\bconfirm\w*\b", response): return True if any(w in response for w in ("\"proceed\": false", "proceed: false")) \ - or _wb(r"\bno\b", response) or _wb(r"\breject\b", response) \ - or _wb(r"\bskip\b", response): + or _wb(r"\bno\b", response) \ + or _wb(r"\breject\w*\b", response) \ + or _wb(r"\bskip\w*\b", response): return False # Default: skip — ambiguous response should not trigger costly evolution logger.debug("LLM confirmation response was ambiguous, defaulting to skip") From 978e8fbc92c7339cce711f6576d495db6aebc05b Mon Sep 17 00:00:00 2001 From: xlrrrr Date: Sat, 28 Mar 2026 10:35:03 +0800 Subject: [PATCH 10/45] docs: update openclaw setup instructions in host_skills README --- openspace/host_skills/README.md | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/openspace/host_skills/README.md b/openspace/host_skills/README.md index af38afc..6d23815 100644 --- a/openspace/host_skills/README.md +++ b/openspace/host_skills/README.md @@ -48,12 +48,14 @@ cp -r host_skills/delegate-task/ /path/to/nanobot/nanobot/skills/ ## Setup for openclaw -openclaw ships with a built-in `skills/openspace/` skill — **no need to copy host_skills**. +### 1. Copy host skills -> [!NOTE] -> openclaw's built-in skill merges skill-discovery + delegate-task into a single SKILL.md with scenario sub-pages. It uses `mcporter call openspace.` syntax. The underlying MCP tools are identical. +```bash +cp -r host_skills/skill-discovery/ /path/to/openclaw/skills/ +cp -r host_skills/delegate-task/ /path/to/openclaw/skills/ +``` -### 1. Register MCP server +### 2. Register MCP server openclaw uses [mcporter](https://github.com/steipete/mcporter) as its MCP runtime: @@ -61,9 +63,21 @@ openclaw uses [mcporter](https://github.com/steipete/mcporter) as its MCP runtim mcporter config add openspace --command "openspace-mcp" ``` -### 2. Configure env vars +### 3. Configure env vars -Set in `~/.openclaw/openclaw.json`: +You can pass env vars directly when registering the MCP server in step 2, combining both steps into one command: + +```bash +mcporter config add openspace --command "openspace-mcp" \ + --env OPENSPACE_HOST_SKILL_DIRS=/path/to/openclaw/skills \ + --env OPENSPACE_WORKSPACE=/path/to/OpenSpace \ + --env OPENSPACE_API_KEY=sk-xxx +``` + +> [!TIP] +> If you already ran step 2 without `--env`, you can re-run the command above to update the registration. + +Alternatively, if you prefer to keep env vars separate from the mcporter registration, set them in `~/.openclaw/openclaw.json` (openclaw will pass them to the MCP server at launch time): ```json { @@ -81,8 +95,6 @@ Set in `~/.openclaw/openclaw.json`: } ``` -Or set as system env vars (e.g. `~/.openclaw/.env`). - --- ## Environment Variables (Agent-Specific) From 67125c378d579fe8d53a68972856d1453d80c7d9 Mon Sep 17 00:00:00 2001 From: xlrrrr Date: Sat, 28 Mar 2026 10:53:53 +0800 Subject: [PATCH 11/45] docs: update openclaw setup instructions in host_skills README --- openspace/host_skills/README.md | 33 ++------------------------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/openspace/host_skills/README.md b/openspace/host_skills/README.md index 6d23815..0a555ed 100644 --- a/openspace/host_skills/README.md +++ b/openspace/host_skills/README.md @@ -55,17 +55,9 @@ cp -r host_skills/skill-discovery/ /path/to/openclaw/skills/ cp -r host_skills/delegate-task/ /path/to/openclaw/skills/ ``` -### 2. Register MCP server +### 2. Register MCP server with env vars -openclaw uses [mcporter](https://github.com/steipete/mcporter) as its MCP runtime: - -```bash -mcporter config add openspace --command "openspace-mcp" -``` - -### 3. Configure env vars - -You can pass env vars directly when registering the MCP server in step 2, combining both steps into one command: +openclaw uses [mcporter](https://github.com/steipete/mcporter) as its MCP runtime. Register the server and pass env vars in one command: ```bash mcporter config add openspace --command "openspace-mcp" \ @@ -74,27 +66,6 @@ mcporter config add openspace --command "openspace-mcp" \ --env OPENSPACE_API_KEY=sk-xxx ``` -> [!TIP] -> If you already ran step 2 without `--env`, you can re-run the command above to update the registration. - -Alternatively, if you prefer to keep env vars separate from the mcporter registration, set them in `~/.openclaw/openclaw.json` (openclaw will pass them to the MCP server at launch time): - -```json -{ - "skills": { - "entries": { - "openspace": { - "env": { - "OPENSPACE_HOST_SKILL_DIRS": "/path/to/openclaw/skills", - "OPENSPACE_WORKSPACE": "/path/to/OpenSpace", - "OPENSPACE_API_KEY": "sk-xxx" - } - } - } - } -} -``` - --- ## Environment Variables (Agent-Specific) From c8fb895febb33ac5095fc6e45c6224a5867179ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?d=20=F0=9F=94=B9?= <258577966+voidborne-d@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:06:42 +0000 Subject: [PATCH 12/45] fix: register_skill_dir returns existing SkillMeta for already-registered skills Fixes #29. When a skill is already registered, register_skill_dir() returned None, which caused fix_skill() to incorrectly report a failure. Now returns the existing SkillMeta instead of None when the skill_id is already present in the registry, making register_skill_dir() truly idempotent as its callers (fix_skill, _auto_register_skill_dirs) expect. --- openspace/skill_engine/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspace/skill_engine/registry.py b/openspace/skill_engine/registry.py index bb35dd8..a217458 100644 --- a/openspace/skill_engine/registry.py +++ b/openspace/skill_engine/registry.py @@ -321,7 +321,7 @@ class SkillRegistry: meta = self._parse_skill(skill_dir.name, skill_dir, skill_file, content) if meta.skill_id in self._skills: logger.debug(f"register_skill_dir: {meta.skill_id} already exists") - return None + return self._skills[meta.skill_id] self._skills[meta.skill_id] = meta self._content_cache[meta.skill_id] = content logger.info(f"Hot-registered skill: {meta.skill_id}") From 11bdf128d9b53a4107aec1d3098fa04ab808a700 Mon Sep 17 00:00:00 2001 From: chaohuang-ai Date: Sun, 29 Mar 2026 10:44:38 +0800 Subject: [PATCH 13/45] Update README.md --- README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7d9ea13..b407689 100644 --- a/README.md +++ b/README.md @@ -544,7 +544,19 @@ OpenSpace builds upon the following open-source projects. We sincerely thank the

-**🌟 Star us if OpenSpace helps your agent!** +## ⭐ Star History + +If you find OpenSpace helpful, please consider giving us a star! ⭐ + + **🧬 Make You Agent Self-Evolve · 🌐 A Community That Grows Together · 💰 Fewer Tokens, Smarter Agents** From 4f61cb2fa1e0d34acbcb2d8d20c7f8c2c53e883a Mon Sep 17 00:00:00 2001 From: Marc von Renteln Date: Sun, 29 Mar 2026 11:16:10 +0200 Subject: [PATCH 14/45] fix: pin litellm to <1.82.7 to avoid PYSEC-2026-2 supply-chain attack Versions 1.82.7 and 1.82.8 of litellm were published on March 24, 2026 and contained malicious code that exfiltrated credentials (SSH keys, cloud credentials, .env files, API keys) to an attacker-controlled domain. Pin the dependency to >=1.70.0,<1.82.7 in both pyproject.toml and requirements.txt as a stopgap until litellm can be replaced with direct provider SDK calls. See: https://github.com/HKUDS/OpenSpace/issues/31 Ref: PYSEC-2026-2, BerriAI/litellm#24521 --- pyproject.toml | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 571ef8a..06c199c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ authors = [ ] dependencies = [ - "litellm>=1.70.0", + "litellm>=1.70.0,<1.82.7", # pinned to avoid PYSEC-2026-2 supply-chain compromise (1.82.7/1.82.8 were malicious) "python-dotenv>=1.0.0", "openai>=1.0.0", "jsonschema>=4.25.0", diff --git a/requirements.txt b/requirements.txt index 3f8c7c4..6371308 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # OpenSpace core dependencies -litellm>=1.70.0 +litellm>=1.70.0,<1.82.7 # pinned to avoid PYSEC-2026-2 supply-chain compromise (1.82.7/1.82.8 were malicious) python-dotenv>=1.0.0 openai>=1.0.0 jsonschema>=4.25.0 From aa16419e4696ab339343b771c5c59bd9d22ada79 Mon Sep 17 00:00:00 2001 From: Dennis-yxchen Date: Tue, 31 Mar 2026 14:58:01 +0800 Subject: [PATCH 15/45] docs: update register_skill_dir docstring to reflect idempotent return --- openspace/skill_engine/registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openspace/skill_engine/registry.py b/openspace/skill_engine/registry.py index a217458..114f6d8 100644 --- a/openspace/skill_engine/registry.py +++ b/openspace/skill_engine/registry.py @@ -299,8 +299,8 @@ class SkillRegistry: skill_dir: Path to a directory containing ``SKILL.md``. Returns: - :class:`SkillMeta` if newly registered, ``None`` if already - present, the directory is invalid, or the skill fails safety checks. + :class:`SkillMeta` if newly registered or already present, + ``None`` if the directory is invalid or the skill fails safety checks. """ skill_file = skill_dir / "SKILL.md" if not skill_file.exists(): From f4451aa0ac540663d679a1d4a849e89333d78ce0 Mon Sep 17 00:00:00 2001 From: who96 <825265100@qq.com> Date: Thu, 26 Mar 2026 08:52:20 +0800 Subject: [PATCH 16/45] mcp: keep local skill search lightweight --- openspace/mcp_server.py | 65 +++++++++++++++++++++++++++++++++++- tests/test_issue3_startup.py | 49 +++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue3_startup.py diff --git a/openspace/mcp_server.py b/openspace/mcp_server.py index 7e3428f..d3b8e9e 100644 --- a/openspace/mcp_server.py +++ b/openspace/mcp_server.py @@ -119,6 +119,7 @@ mcp = FastMCP("OpenSpace", **_fastmcp_kwargs) _openspace_instance = None _openspace_lock = asyncio.Lock() _standalone_store = None +_local_skill_registry = None # Internal state: tracks bot skill directories already registered this session. _registered_skill_dirs: set = set() @@ -198,6 +199,64 @@ def _get_store(): return _standalone_store +def _get_local_skill_registry(): + """Build a lightweight SkillRegistry for local-only skill search. + + This avoids initializing the full OpenSpace engine when callers only + want to inspect local skills. It mirrors the skill directory discovery + order used by the full engine, but skips LLM / provider startup. + """ + global _local_skill_registry + if _local_skill_registry is not None: + return _local_skill_registry + + from openspace.config import get_config + from openspace.skill_engine import SkillRegistry + + skill_paths: List[Path] = [] + + host_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "") + if host_dirs_raw: + for d in host_dirs_raw.split(","): + d = d.strip() + if not d: + continue + p = Path(d) + if p.exists(): + skill_paths.append(p) + else: + logger.warning("Host skill dir does not exist: %s", d) + + try: + skill_cfg = get_config().skills + except Exception as e: + logger.warning("Failed to load local skill config: %s", e) + skill_cfg = None + + if skill_cfg and skill_cfg.skill_dirs: + for d in skill_cfg.skill_dirs: + p = Path(d) + if p in skill_paths: + continue + if p.exists(): + skill_paths.append(p) + else: + logger.warning("Configured skill dir does not exist: %s", d) + + builtin_skills = Path(__file__).resolve().parent / "skills" + if builtin_skills.exists(): + skill_paths.append(builtin_skills) + + if not skill_paths: + logger.debug("No local skill directories found") + return None + + registry = SkillRegistry(skill_dirs=skill_paths) + registry.discover() + _local_skill_registry = registry + return registry + + def _get_cloud_client(): """Get a OpenSpaceClient instance (raises CloudError if not configured).""" from openspace.cloud.auth import get_openspace_auth @@ -597,7 +656,11 @@ async def search_skills( # Re-scan host skill directories so newly created skills are searchable. local_skills = None store = None - if source in ("all", "local"): + if source == "local": + registry = _get_local_skill_registry() + if registry: + local_skills = registry.list_skills() + elif source == "all": openspace = await _get_openspace() host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "") diff --git a/tests/test_issue3_startup.py b/tests/test_issue3_startup.py new file mode 100644 index 0000000..386a412 --- /dev/null +++ b/tests/test_issue3_startup.py @@ -0,0 +1,49 @@ +import json + +import pytest + +from openspace import mcp_server + + +@pytest.mark.asyncio +async def test_local_search_skills_does_not_initialize_openspace(monkeypatch, tmp_path): + skill_root = tmp_path / "skills" + skill_dir = skill_root / "demo-local-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + """--- +name: Demo Local Skill +description: Local regression test skill +--- + +Find me when querying demo local skill. +""", + encoding="utf-8", + ) + + async def forbidden_get_openspace(): + pytest.fail("_get_openspace should not run for source='local'") + + monkeypatch.setenv("OPENSPACE_HOST_SKILL_DIRS", str(skill_root)) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.setattr(mcp_server, "_get_openspace", forbidden_get_openspace) + monkeypatch.setattr( + "openspace.cloud.embedding.generate_embedding", + lambda text, api_key=None: None, + ) + + response = await mcp_server.search_skills( + query="demo local skill", + source="local", + limit=5, + auto_import=False, + ) + + payload = json.loads(response) + assert payload["count"] >= 1 + assert any( + item["name"] == "Demo Local Skill" + for item in payload["results"] + ) From fb02862d4451921c7961b48bb5e4091296f53b5e Mon Sep 17 00:00:00 2001 From: who96 <825265100@qq.com> Date: Thu, 26 Mar 2026 09:18:17 +0800 Subject: [PATCH 17/45] Refresh local registry for local skill search --- openspace/mcp_server.py | 8 ++--- tests/test_issue3_startup.py | 69 ++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/openspace/mcp_server.py b/openspace/mcp_server.py index d3b8e9e..e81ef12 100644 --- a/openspace/mcp_server.py +++ b/openspace/mcp_server.py @@ -119,7 +119,6 @@ mcp = FastMCP("OpenSpace", **_fastmcp_kwargs) _openspace_instance = None _openspace_lock = asyncio.Lock() _standalone_store = None -_local_skill_registry = None # Internal state: tracks bot skill directories already registered this session. _registered_skill_dirs: set = set() @@ -205,11 +204,9 @@ def _get_local_skill_registry(): This avoids initializing the full OpenSpace engine when callers only want to inspect local skills. It mirrors the skill directory discovery order used by the full engine, but skips LLM / provider startup. + The registry is rebuilt per call so later local searches can see + newly added skills without requiring a process restart. """ - global _local_skill_registry - if _local_skill_registry is not None: - return _local_skill_registry - from openspace.config import get_config from openspace.skill_engine import SkillRegistry @@ -253,7 +250,6 @@ def _get_local_skill_registry(): registry = SkillRegistry(skill_dirs=skill_paths) registry.discover() - _local_skill_registry = registry return registry diff --git a/tests/test_issue3_startup.py b/tests/test_issue3_startup.py index 386a412..7e1e044 100644 --- a/tests/test_issue3_startup.py +++ b/tests/test_issue3_startup.py @@ -47,3 +47,72 @@ Find me when querying demo local skill. item["name"] == "Demo Local Skill" for item in payload["results"] ) + + +@pytest.mark.asyncio +async def test_local_search_skills_refreshes_registry_between_calls(monkeypatch, tmp_path): + skill_root = tmp_path / "skills" + first_skill_dir = skill_root / "first-local-skill" + first_skill_dir.mkdir(parents=True) + (first_skill_dir / "SKILL.md").write_text( + """--- +name: First Local Skill +description: First local skill for cache regression coverage +--- + +First local skill content. +""", + encoding="utf-8", + ) + + async def forbidden_get_openspace(): + pytest.fail("_get_openspace should not run for source='local'") + + monkeypatch.setenv("OPENSPACE_HOST_SKILL_DIRS", str(skill_root)) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.setattr(mcp_server, "_get_openspace", forbidden_get_openspace) + monkeypatch.setattr( + "openspace.cloud.embedding.generate_embedding", + lambda text, api_key=None: None, + ) + + first_response = await mcp_server.search_skills( + query="first local skill", + source="local", + limit=5, + auto_import=False, + ) + + first_payload = json.loads(first_response) + assert any( + item["name"] == "First Local Skill" + for item in first_payload["results"] + ) + + second_skill_dir = skill_root / "second-local-skill" + second_skill_dir.mkdir(parents=True) + (second_skill_dir / "SKILL.md").write_text( + """--- +name: Second Local Skill +description: Second local skill created after the first search +--- + +Second local skill content. +""", + encoding="utf-8", + ) + + second_response = await mcp_server.search_skills( + query="second local skill", + source="local", + limit=5, + auto_import=False, + ) + + second_payload = json.loads(second_response) + assert any( + item["name"] == "Second Local Skill" + for item in second_payload["results"] + ) From 1620261783ee8676bbef5a90f017aa755e95e505 Mon Sep 17 00:00:00 2001 From: Dennis-yxchen Date: Tue, 31 Mar 2026 15:25:08 +0800 Subject: [PATCH 18/45] test: add edge case coverage for local skill search --- tests/test_issue3_startup.py | 59 ++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_issue3_startup.py b/tests/test_issue3_startup.py index 7e1e044..9a7b894 100644 --- a/tests/test_issue3_startup.py +++ b/tests/test_issue3_startup.py @@ -116,3 +116,62 @@ Second local skill content. item["name"] == "Second Local Skill" for item in second_payload["results"] ) + + +@pytest.mark.asyncio +async def test_local_search_returns_empty_when_no_registry(monkeypatch): + """source='local' with no discoverable skills should return empty, not crash.""" + + async def forbidden_get_openspace(): + pytest.fail("_get_openspace should not run for source='local'") + + monkeypatch.setattr(mcp_server, "_get_openspace", forbidden_get_openspace) + monkeypatch.setattr(mcp_server, "_get_local_skill_registry", lambda: None) + monkeypatch.setattr( + "openspace.cloud.embedding.generate_embedding", + lambda text, api_key=None: None, + ) + + response = await mcp_server.search_skills( + query="anything", + source="local", + limit=5, + auto_import=False, + ) + + payload = json.loads(response) + assert payload["count"] == 0 + assert payload["results"] == [] + + +@pytest.mark.asyncio +async def test_source_all_still_calls_get_openspace(monkeypatch, tmp_path): + """source='all' must go through _get_openspace(), not the lightweight path.""" + called = {"openspace": False} + + class FakeRegistry: + def list_skills(self): + return [] + + class FakeOpenSpace: + _skill_registry = FakeRegistry() + + async def tracking_get_openspace(): + called["openspace"] = True + return FakeOpenSpace() + + monkeypatch.delenv("OPENSPACE_HOST_SKILL_DIRS", raising=False) + monkeypatch.setattr(mcp_server, "_get_openspace", tracking_get_openspace) + monkeypatch.setattr( + "openspace.cloud.embedding.generate_embedding", + lambda text, api_key=None: None, + ) + + await mcp_server.search_skills( + query="anything", + source="all", + limit=5, + auto_import=False, + ) + + assert called["openspace"], "source='all' should call _get_openspace()" From 63b01cfcef0ff7b4b00502773733d2c8c5e0051e Mon Sep 17 00:00:00 2001 From: xlrrrr Date: Tue, 31 Mar 2026 15:36:13 +0800 Subject: [PATCH 19/45] fix: CLI entry point now respects OPENSPACE_MODEL and OPENSPACE_LLM_* env vars --- openspace/__main__.py | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/openspace/__main__.py b/openspace/__main__.py index 28ce3c2..4a1d6e0 100644 --- a/openspace/__main__.py +++ b/openspace/__main__.py @@ -321,16 +321,41 @@ async def refresh_mcp_cache(config_path: Optional[str] = None): def _load_config(args) -> OpenSpaceConfig: """Load configuration""" + import os + from openspace.host_detection import build_llm_kwargs, build_grounding_config_path + cli_overrides = {} - if args.model: - cli_overrides['llm_model'] = args.model if args.max_iterations is not None: cli_overrides['grounding_max_iterations'] = args.max_iterations if args.timeout is not None: cli_overrides['llm_timeout'] = args.timeout if args.log_level: cli_overrides['log_level'] = args.log_level - + + # Resolve LLM model & credentials + # CLI --model > OPENSPACE_MODEL env > host-agent auto-detect > default + env_model = args.model or os.environ.get("OPENSPACE_MODEL", "") + model, llm_kwargs = build_llm_kwargs(env_model) + cli_overrides['llm_model'] = model + cli_overrides['llm_kwargs'] = llm_kwargs + + max_iter = int(os.environ.get("OPENSPACE_MAX_ITERATIONS", "20")) + enable_rec = os.environ.get("OPENSPACE_ENABLE_RECORDING", "true").lower() in ("true", "1", "yes") + backend_scope_raw = os.environ.get("OPENSPACE_BACKEND_SCOPE") + backend_scope = ( + [b.strip() for b in backend_scope_raw.split(",") if b.strip()] + if backend_scope_raw else None + ) + config_path = build_grounding_config_path() + + if 'grounding_max_iterations' not in cli_overrides: + cli_overrides['grounding_max_iterations'] = max_iter + cli_overrides['enable_recording'] = enable_rec + if backend_scope is not None: + cli_overrides['backend_scope'] = backend_scope + if config_path: + cli_overrides['grounding_config_path'] = config_path + try: # Load from config file if provided if args.config: @@ -338,18 +363,17 @@ def _load_config(args) -> OpenSpaceConfig: with open(args.config, 'r', encoding='utf-8') as f: config_dict = json.load(f) - # Apply CLI overrides + # Apply CLI / env overrides config_dict.update(cli_overrides) config = OpenSpaceConfig(**config_dict) print(f"✓ Loaded from config file: {args.config}") else: - # Use default config + CLI overrides config = OpenSpaceConfig(**cli_overrides) print("✓ Using default configuration") - if cli_overrides: - print(f"✓ CLI overrides: {', '.join(cli_overrides.keys())}") + if args.model: + print(f"✓ CLI overrides: llm_model") if args.log_level: Logger.set_level(args.log_level) From f845c5f7fbf8a10b822dc41833a4a517810d3259 Mon Sep 17 00:00:00 2001 From: Dennis-yxchen Date: Tue, 31 Mar 2026 16:23:33 +0800 Subject: [PATCH 20/45] fix(security): harden zip extraction and import_skill against path traversal - Add resolve() + is_relative_to() check in _extract_zip() to block nested traversal entries like nested/../../escape.txt - Sanitize server-provided skill name in import_skill() to prevent directory escape via malicious record metadata - Add 6 regression tests covering both attack vectors Closes #17 Co-authored-by: LeftX --- openspace/cloud/client.py | 11 +++- tests/test_zip_path_traversal.py | 89 ++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 tests/test_zip_path_traversal.py diff --git a/openspace/cloud/client.py b/openspace/cloud/client.py index fb0792f..7bcd3b1 100644 --- a/openspace/cloud/client.py +++ b/openspace/cloud/client.py @@ -340,7 +340,11 @@ class OpenSpaceClient: record_data = self.fetch_record(skill_id) skill_name = record_data.get("name", skill_id) - skill_dir = target_dir / skill_name + if "/" in skill_name or "\\" in skill_name or skill_name.startswith("."): + skill_name = skill_id + skill_dir = (target_dir / skill_name).resolve() + if not skill_dir.is_relative_to(target_dir.resolve()): + raise CloudError(f"Skill name {skill_name!r} escapes target directory") # Check if already exists locally if skill_dir.exists() and (skill_dir / SKILL_FILENAME).exists(): @@ -401,6 +405,7 @@ class OpenSpaceClient: def _extract_zip(zip_data: bytes, target_dir: Path) -> List[str]: """Extract zip bytes to target directory with path traversal protection.""" extracted: List[str] = [] + resolved_target = target_dir.resolve() try: with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: for info in zf.infolist(): @@ -409,7 +414,9 @@ class OpenSpaceClient: clean_name = Path(info.filename).as_posix() if clean_name.startswith("..") or clean_name.startswith("/"): continue - target_path = target_dir / clean_name + target_path = (target_dir / clean_name).resolve() + if not target_path.is_relative_to(resolved_target): + continue target_path.parent.mkdir(parents=True, exist_ok=True) target_path.write_bytes(zf.read(info)) extracted.append(clean_name) diff --git a/tests/test_zip_path_traversal.py b/tests/test_zip_path_traversal.py new file mode 100644 index 0000000..275176b --- /dev/null +++ b/tests/test_zip_path_traversal.py @@ -0,0 +1,89 @@ +"""Regression tests for zip extraction and import_skill path traversal.""" + +import io +import zipfile +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest + + +def _make_zip(entries: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, data in entries.items(): + zf.writestr(name, data) + return buf.getvalue() + + +def _get_extract_zip(): + import importlib + mod = importlib.import_module("openspace.cloud.client") + return mod.OpenSpaceClient._extract_zip + + +class TestExtractZip: + def test_normal_file_extracted(self, tmp_path): + extract = _get_extract_zip() + zip_data = _make_zip({"hello.txt": b"world"}) + result = extract(zip_data, tmp_path) + assert "hello.txt" in result + assert (tmp_path / "hello.txt").read_bytes() == b"world" + + def test_dotdot_prefix_blocked(self, tmp_path): + extract = _get_extract_zip() + zip_data = _make_zip({"../escape.txt": b"bad"}) + result = extract(zip_data, tmp_path) + assert result == [] + + def test_nested_traversal_blocked(self, tmp_path): + """The real bug: nested/../../escape.txt bypassed the old startswith check.""" + extract = _get_extract_zip() + zip_data = _make_zip({"nested/../../escape.txt": b"bad"}) + result = extract(zip_data, tmp_path) + assert result == [] + assert not (tmp_path.parent / "escape.txt").exists() + + def test_absolute_path_blocked(self, tmp_path): + extract = _get_extract_zip() + zip_data = _make_zip({"/etc/passwd": b"bad"}) + result = extract(zip_data, tmp_path) + assert result == [] + + +class TestImportSkillNameTraversal: + def test_malicious_record_name_sanitized(self, tmp_path): + from openspace.cloud.client import OpenSpaceClient + + client = OpenSpaceClient.__new__(OpenSpaceClient) + target_dir = tmp_path / "skills" + target_dir.mkdir() + + malicious_name = "../../escapedir" + skill_id = "safe_skill_id" + zip_data = _make_zip({"SKILL.md": b"---\nname: test\n---\ncontent"}) + + with patch.object(client, "fetch_record", return_value={"name": malicious_name}), \ + patch.object(client, "download_artifact", return_value=zip_data): + result = client.import_skill(skill_id, target_dir) + + assert result["status"] == "success" + resolved = Path(result["local_path"]).resolve() + assert resolved.is_relative_to(target_dir.resolve()) + assert not (tmp_path.parent / "escapedir").exists() + + def test_normal_record_name_works(self, tmp_path): + from openspace.cloud.client import OpenSpaceClient + + client = OpenSpaceClient.__new__(OpenSpaceClient) + target_dir = tmp_path / "skills" + target_dir.mkdir() + + zip_data = _make_zip({"SKILL.md": b"---\nname: test\n---\ncontent"}) + + with patch.object(client, "fetch_record", return_value={"name": "my-skill"}), \ + patch.object(client, "download_artifact", return_value=zip_data): + result = client.import_skill("some_id", target_dir) + + assert result["status"] == "success" + assert (target_dir / "my-skill" / "SKILL.md").exists() From 64a30760586d98f0621a2ec79ff87d769a2cf464 Mon Sep 17 00:00:00 2001 From: Dennis-yxchen Date: Tue, 31 Mar 2026 17:03:00 +0800 Subject: [PATCH 21/45] fix: pass max_iterations to grounding agent in no-skill execution path Without this, the no-skill path ignores the resolved max_iterations and uses whatever default the agent has, instead of the configured grounding_max_iterations value. Co-authored-by: wul48527-code --- openspace/tool_layer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openspace/tool_layer.py b/openspace/tool_layer.py index 1ea419f..4cf4b7b 100644 --- a/openspace/tool_layer.py +++ b/openspace/tool_layer.py @@ -507,6 +507,7 @@ class OpenSpace: f"Executing with GroundingAgent " f"(max {max_iterations} iterations, no skills)..." ) + execution_context["max_iterations"] = max_iterations result = await self._grounding_agent.process(execution_context) execution_time = asyncio.get_event_loop().time() - start_time From 29a3869d49c61fad94193fb78cfd9b8fec54fed2 Mon Sep 17 00:00:00 2001 From: xlrrrr Date: Tue, 31 Mar 2026 17:18:13 +0800 Subject: [PATCH 22/45] fix: improve MiniMax compatibility --- openspace/agents/grounding_agent.py | 15 +++- openspace/host_detection/nanobot.py | 2 +- openspace/host_detection/resolver.py | 33 ++++++++ openspace/llm/client.py | 111 ++++++++++++++++++++++++++- 4 files changed, 153 insertions(+), 8 deletions(-) diff --git a/openspace/agents/grounding_agent.py b/openspace/agents/grounding_agent.py index 4e36cba..4d983d2 100644 --- a/openspace/agents/grounding_agent.py +++ b/openspace/agents/grounding_agent.py @@ -120,6 +120,7 @@ class GroundingAgent(BaseAgent): logger.info(f"Skill registry attached ({count} skill(s) available for mid-iteration retrieval)") _MAX_SINGLE_CONTENT_CHARS = 30_000 + _ITERATION_GUIDANCE_PREFIX = "[INTERNAL ORCHESTRATION NOTE]" @classmethod def _cap_message_content(cls, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: @@ -414,13 +415,19 @@ class GroundingAgent(BaseAgent): # Remove previous iteration guidance to avoid accumulation messages = [ - msg for msg in messages - if not (msg.get("role") == "system" and "Iteration" in msg.get("content", "") and "complete" in msg.get("content", "")) + msg for msg in messages + if not ( + isinstance(msg.get("content"), str) + and msg.get("content", "").startswith(self._ITERATION_GUIDANCE_PREFIX) + ) ] + # MiniMax rejects system messages injected mid-conversation, + # so runtime guidance is sent as an internal user note. guidance_msg = { - "role": "system", - "content": f"Iteration {current_iteration} complete. " + "role": "user", + "content": f"{self._ITERATION_GUIDANCE_PREFIX}\n" + f"Iteration {current_iteration} complete. " f"Check if task is finished - if yes, output {GroundingAgentPrompts.TASK_COMPLETE}. " f"If not, continue with next action." } diff --git a/openspace/host_detection/nanobot.py b/openspace/host_detection/nanobot.py index c06c743..8f787b1 100644 --- a/openspace/host_detection/nanobot.py +++ b/openspace/host_detection/nanobot.py @@ -31,7 +31,7 @@ PROVIDER_REGISTRY: List[tuple] = [ ("zhipu", ("zhipu", "glm", "zai"), ""), ("dashscope", ("qwen", "dashscope"), ""), ("moonshot", ("moonshot", "kimi"), "https://api.moonshot.ai/v1"), - ("minimax", ("minimax",), "https://api.minimax.io/v1"), + ("minimax", ("minimax",), "https://api.minimaxi.com/v1"), ("groq", ("groq",), ""), ] diff --git a/openspace/host_detection/resolver.py b/openspace/host_detection/resolver.py index 3fb3611..d3d9cb0 100644 --- a/openspace/host_detection/resolver.py +++ b/openspace/host_detection/resolver.py @@ -54,12 +54,20 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: # and the model name doesn't already carry that prefix, prepend # it so that litellm uses the correct request format (OpenAI- # compatible for gateways vs native for direct providers). + # Skip when the user explicitly provided a model (via OPENSPACE_MODEL + # or --model) AND explicit OPENSPACE_LLM_* overrides — the user knows + # exactly which endpoint they want to hit. _GATEWAY_PROVIDERS = {"openrouter", "aihubmix", "siliconflow"} + _has_explicit_llm_override = bool( + os.environ.get("OPENSPACE_LLM_API_BASE") + or os.environ.get("OPENSPACE_LLM_API_KEY") + ) if ( forced_provider and forced_provider in _GATEWAY_PROVIDERS and resolved_model and not resolved_model.lower().startswith(f"{forced_provider}/") + and not (model and _has_explicit_llm_override) ): resolved_model = f"{forced_provider}/{resolved_model}" logger.info( @@ -102,6 +110,31 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: if not resolved_model: resolved_model = "openrouter/anthropic/claude-sonnet-4.5" + # Provider-specific adjustments for litellm routing + if resolved_model and "minimax" in resolved_model.lower(): + final_key = kwargs.get("api_key") + final_base = kwargs.get("api_base", "") + + if final_key: + os.environ.setdefault("MINIMAX_API_KEY", final_key) + if final_base: + os.environ.setdefault("MINIMAX_API_BASE", final_base) + + # api.minimaxi.com (domestic) is OpenAI-compatible but does not fully + # support litellm's minimax-specific request transformations (e.g. tool + # calling format). Switch to the generic openai/ prefix so litellm + # sends standard OpenAI-format requests that minimaxi.com accepts. + if ( + resolved_model.lower().startswith("minimax/") + and "minimaxi.com" in final_base + ): + original = resolved_model + resolved_model = "openai/" + resolved_model.split("/", 1)[1] + logger.info( + "Switched model prefix for minimaxi.com compat: %s -> %s", + original, resolved_model, + ) + if kwargs: safe = { k: (v[:8] + "..." if k == "api_key" and isinstance(v, str) and len(v) > 8 else v) diff --git a/openspace/llm/client.py b/openspace/llm/client.py index 19a1664..cc14c64 100644 --- a/openspace/llm/client.py +++ b/openspace/llm/client.py @@ -406,6 +406,95 @@ class LLMClient: self._logger = Logger.get_logger(__name__) self._last_call_time = 0.0 + @staticmethod + def _merge_consecutive_system_messages(messages: List[Dict]) -> List[Dict]: + """Merge consecutive system messages into one. + + Providers like MiniMax reject requests that contain multiple consecutive + messages with the same role (error 2013 "invalid chat setting"). + Merging is safe for all providers — it simply concatenates the content. + """ + if not messages: + return messages + merged: List[Dict] = [] + for msg in messages: + if ( + merged + and msg.get("role") == "system" + and merged[-1].get("role") == "system" + ): + merged[-1] = { + "role": "system", + "content": merged[-1].get("content", "") + "\n\n" + msg.get("content", ""), + } + else: + merged.append(msg.copy()) + return merged + + @staticmethod + def _is_minimax_model(model: str) -> bool: + return isinstance(model, str) and "minimax" in model.lower() + + @classmethod + def _rewrite_nonleading_system_messages_for_minimax( + cls, + messages: List[Dict], + ) -> List[Dict]: + """Rewrite non-leading system messages into internal user notes for MiniMax.""" + rewritten: List[Dict] = [] + rewritten_count = 0 + + for msg in messages: + msg_copy = msg.copy() + if msg_copy.get("role") == "system" and rewritten: + content = msg_copy.get("content", "") + if isinstance(content, str): + msg_copy["content"] = ( + "[INTERNAL ORCHESTRATION NOTE]\n" + "This note was originally injected as a system message by the " + "agent runtime. Treat it as workflow guidance, not as a new " + "end-user request.\n\n" + f"{content}" + ) + msg_copy["role"] = "user" + rewritten_count += 1 + rewritten.append(msg_copy) + + if rewritten_count: + logger.info( + "Rewrote %d non-leading system message(s) for MiniMax compatibility", + rewritten_count, + ) + + return rewritten + + @classmethod + def _normalize_messages_for_model(cls, messages: List[Dict], model: str) -> List[Dict]: + """Normalize message history only when a provider requires it.""" + if not cls._is_minimax_model(model): + return messages + + minimized_system_history = cls._merge_consecutive_system_messages(messages) + return cls._rewrite_nonleading_system_messages_for_minimax( + minimized_system_history + ) + + @staticmethod + def _serialize_response_field(value): + """Convert provider response fields into plain Python containers.""" + if hasattr(value, "model_dump"): + return value.model_dump(exclude_none=True) + if isinstance(value, list): + return [LLMClient._serialize_response_field(item) for item in value] + if isinstance(value, tuple): + return [LLMClient._serialize_response_field(item) for item in value] + if isinstance(value, dict): + return { + key: LLMClient._serialize_response_field(item) + for key, item in value.items() + } + return value + async def _rate_limit(self): """Apply rate limiting by adding delay between API calls""" if self.rate_limit_delay > 0: @@ -539,6 +628,7 @@ class LLMClient: "model": kwargs.get("model", self.model), **self.litellm_kwargs, } + request_model = completion_kwargs["model"] # Add thinking/reasoning_effort only if explicitly enabled and not using tools enable_thinking = kwargs.get("enable_thinking", self.enable_thinking) @@ -561,10 +651,16 @@ class LLMClient: if enable_thinking: completion_kwargs["reasoning_effort"] = kwargs.get("reasoning_effort", "medium") - # 4. Apply rate limiting + # 4. Normalize messages for providers with stricter role constraints. + current_messages = self._normalize_messages_for_model( + current_messages, + request_model, + ) + + # 5. Apply rate limiting await self._rate_limit() - # 5. Call LLM with retry (single round) + # 6. Call LLM with retry (single round) completion_kwargs["messages"] = current_messages response = await self._call_with_retry(**completion_kwargs) @@ -578,6 +674,11 @@ class LLMClient: "role": "assistant", "content": response_message.content or "", } + + for field_name in ("reasoning_details", "reasoning_content", "name"): + field_value = getattr(response_message, field_name, None) + if field_value: + assistant_message[field_name] = self._serialize_response_field(field_value) tool_calls = getattr(response_message, 'tool_calls', None) if tool_calls: @@ -722,6 +823,10 @@ class LLMClient: "content": summary_prompt } current_messages.append(summary_message) + current_messages = self._normalize_messages_for_model( + current_messages, + request_model, + ) # Apply rate limiting before summary call await self._rate_limit() @@ -729,7 +834,7 @@ class LLMClient: # Call LLM to generate summary (without tools) summary_kwargs = { **self.litellm_kwargs, - "model": self.model, + "model": request_model, "messages": current_messages, "tools": [], "tool_choice": "none", From e48e22afc8ec5c9c223630817f8526d09fbb07b9 Mon Sep 17 00:00:00 2001 From: Dennis-yxchen Date: Tue, 31 Mar 2026 17:18:38 +0800 Subject: [PATCH 23/45] fix: use unique workflow ID to prevent collision across roots workflow_dir.name was used as the discovery key and API ID, so two different WORKFLOW_ROOTS containing a leaf directory with the same name would silently drop one. Use root name + relative path joined with __ as a stable unique ID instead. Co-authored-by: wul48527-code --- openspace/dashboard_server.py | 20 +++++++-- tests/test_workflow_id.py | 79 +++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 tests/test_workflow_id.py diff --git a/openspace/dashboard_server.py b/openspace/dashboard_server.py index 96d3e50..4333aef 100644 --- a/openspace/dashboard_server.py +++ b/openspace/dashboard_server.py @@ -419,6 +419,18 @@ def _build_lineage_payload(skill_id: str, store: SkillStore) -> Dict[str, Any]: } +def _workflow_id(workflow_dir: Path) -> str: + """Stable short ID for a workflow directory, unique across roots.""" + resolved = workflow_dir.resolve() + for root in WORKFLOW_ROOTS: + try: + rel = resolved.relative_to(root.resolve()) + return f"{root.name}__{'__'.join(rel.parts)}" + except ValueError: + continue + return workflow_dir.name + + def _discover_workflow_dirs() -> List[Path]: discovered: Dict[str, Path] = {} for root in WORKFLOW_ROOTS: @@ -439,14 +451,14 @@ def _scan_workflow_tree(directory: Path, discovered: Dict[str, Path], *, _depth: if not child.is_dir(): continue if (child / "metadata.json").exists() or (child / "traj.jsonl").exists(): - discovered.setdefault(child.name, child) + discovered.setdefault(str(child.resolve()), child) else: _scan_workflow_tree(child, discovered, _depth=_depth + 1, _max_depth=_max_depth) def _get_workflow_dir(workflow_id: str) -> Optional[Path]: for path in _discover_workflow_dirs(): - if path.name == workflow_id: + if _workflow_id(path) == workflow_id: return path return None @@ -464,7 +476,7 @@ def _build_workflow_summary(workflow_dir: Path) -> Dict[str, Any]: for candidate in video_candidates: if candidate.exists(): rel = candidate.relative_to(workflow_dir).as_posix() - video_url = url_for("workflow_artifact", workflow_id=workflow_dir.name, artifact_path=rel) + video_url = url_for("workflow_artifact", workflow_id=_workflow_id(workflow_dir), artifact_path=rel) break outcome = metadata.get("execution_outcome") or {} @@ -514,7 +526,7 @@ def _build_workflow_summary(workflow_dir: Path) -> Dict[str, Any]: iterations = len(trajectory) return { - "id": workflow_dir.name, + "id": _workflow_id(workflow_dir), "path": str(workflow_dir), "task_id": metadata.get("task_id") or metadata.get("task_name") or workflow_dir.name, "task_name": metadata.get("task_name") or metadata.get("task_id") or workflow_dir.name, diff --git a/tests/test_workflow_id.py b/tests/test_workflow_id.py new file mode 100644 index 0000000..d4ca82f --- /dev/null +++ b/tests/test_workflow_id.py @@ -0,0 +1,79 @@ +"""Tests for workflow ID uniqueness and discovery.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from openspace import dashboard_server + + +def _make_workflow(path: Path): + """Create a minimal workflow directory with metadata.json.""" + path.mkdir(parents=True, exist_ok=True) + (path / "metadata.json").write_text("{}", encoding="utf-8") + + +class TestWorkflowId: + def test_same_leaf_name_different_roots_get_unique_ids(self, tmp_path): + root_a = tmp_path / "root_a" + root_b = tmp_path / "root_b" + wf_a = root_a / "task1" + wf_b = root_b / "task1" + _make_workflow(wf_a) + _make_workflow(wf_b) + + with patch.object(dashboard_server, "WORKFLOW_ROOTS", [root_a, root_b]): + id_a = dashboard_server._workflow_id(wf_a) + id_b = dashboard_server._workflow_id(wf_b) + + assert id_a != id_b + assert "task1" in id_a + assert "task1" in id_b + + def test_nested_workflow_id_includes_path(self, tmp_path): + root = tmp_path / "root" + wf = root / "sub" / "deep" / "task1" + _make_workflow(wf) + + with patch.object(dashboard_server, "WORKFLOW_ROOTS", [root]): + wf_id = dashboard_server._workflow_id(wf) + + assert wf_id == "root__sub__deep__task1" + + def test_workflow_outside_roots_falls_back_to_name(self, tmp_path): + wf = tmp_path / "orphan_workflow" + _make_workflow(wf) + + with patch.object(dashboard_server, "WORKFLOW_ROOTS", []): + wf_id = dashboard_server._workflow_id(wf) + + assert wf_id == "orphan_workflow" + + +class TestDiscoverWorkflowDirs: + def test_same_name_workflows_both_discovered(self, tmp_path): + root_a = tmp_path / "root_a" + root_b = tmp_path / "root_b" + _make_workflow(root_a / "task1") + _make_workflow(root_b / "task1") + + with patch.object(dashboard_server, "WORKFLOW_ROOTS", [root_a, root_b]): + dirs = dashboard_server._discover_workflow_dirs() + + assert len(dirs) == 2 + + def test_get_workflow_dir_resolves_correct_path(self, tmp_path): + root_a = tmp_path / "root_a" + root_b = tmp_path / "root_b" + _make_workflow(root_a / "task1") + _make_workflow(root_b / "task1") + + with patch.object(dashboard_server, "WORKFLOW_ROOTS", [root_a, root_b]): + id_a = dashboard_server._workflow_id(root_a / "task1") + id_b = dashboard_server._workflow_id(root_b / "task1") + found_a = dashboard_server._get_workflow_dir(id_a) + found_b = dashboard_server._get_workflow_dir(id_b) + + assert found_a.resolve() == (root_a / "task1").resolve() + assert found_b.resolve() == (root_b / "task1").resolve() From 4032fe682d903c96a8fdb40d418bf7db7aec89b8 Mon Sep 17 00:00:00 2001 From: Dennis-yxchen Date: Tue, 31 Mar 2026 17:24:12 +0800 Subject: [PATCH 24/45] test: add regression tests for max_iterations in no-skill path --- tests/test_max_iterations.py | 64 ++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/test_max_iterations.py diff --git a/tests/test_max_iterations.py b/tests/test_max_iterations.py new file mode 100644 index 0000000..04e6863 --- /dev/null +++ b/tests/test_max_iterations.py @@ -0,0 +1,64 @@ +"""Test that max_iterations is passed to grounding agent in no-skill path.""" + +import asyncio +import types +from unittest.mock import AsyncMock, patch + +import pytest + +from openspace.tool_layer import OpenSpace, OpenSpaceConfig + + +def _make_openspace(configured_max: int = 50) -> OpenSpace: + config = OpenSpaceConfig() + config.grounding_max_iterations = configured_max + os_inst = OpenSpace(config) + os_inst._initialized = True + os_inst._running = False + os_inst._task_done = asyncio.Event() + os_inst._task_done.set() + os_inst._grounding_client = types.SimpleNamespace(_registry={}) + os_inst._recording_manager = None + os_inst._skill_registry = None + os_inst._execution_analyzer = None + os_inst._skill_evolver = None + return os_inst + + +@pytest.mark.asyncio +async def test_no_skill_path_passes_configured_max_iterations(): + """When no skills match, the configured grounding_max_iterations + must be forwarded to the agent, not silently dropped.""" + os_inst = _make_openspace(configured_max=50) + recorded = {} + + async def fake_process(context): + recorded.update(context) + return {"status": "success", "iterations": 1, "tool_executions": []} + + os_inst._grounding_agent = types.SimpleNamespace(process=fake_process) + os_inst._maybe_analyze_execution = AsyncMock() + os_inst._maybe_evolve_quality = AsyncMock() + + await os_inst.execute("do something") + + assert recorded.get("max_iterations") == 50 + + +@pytest.mark.asyncio +async def test_no_skill_path_uses_caller_override_when_larger(): + """Caller-provided max_iterations should win when larger than config.""" + os_inst = _make_openspace(configured_max=20) + recorded = {} + + async def fake_process(context): + recorded.update(context) + return {"status": "success", "iterations": 1, "tool_executions": []} + + os_inst._grounding_agent = types.SimpleNamespace(process=fake_process) + os_inst._maybe_analyze_execution = AsyncMock() + os_inst._maybe_evolve_quality = AsyncMock() + + await os_inst.execute("do something", max_iterations=100) + + assert recorded.get("max_iterations") == 100 From 028c5b01f9515dece7f4164c9564803b577de0b2 Mon Sep 17 00:00:00 2001 From: Dennis-yxchen Date: Tue, 31 Mar 2026 17:38:39 +0800 Subject: [PATCH 25/45] fix: use hash-based workflow ID to prevent separator collisions The previous __-joined scheme was not injective: a directory named a__b and a nested path a/b both mapped to the same ID. Use a sha256 hash suffix of the resolved path instead, which is collision-free and keeps the dir name as a human-readable prefix. Added regression test for separator collision case. --- openspace/dashboard_server.py | 18 +++++++++--------- tests/test_workflow_id.py | 29 ++++++++++++++++++++++------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/openspace/dashboard_server.py b/openspace/dashboard_server.py index 4333aef..f6916b6 100644 --- a/openspace/dashboard_server.py +++ b/openspace/dashboard_server.py @@ -420,15 +420,15 @@ def _build_lineage_payload(skill_id: str, store: SkillStore) -> Dict[str, Any]: def _workflow_id(workflow_dir: Path) -> str: - """Stable short ID for a workflow directory, unique across roots.""" - resolved = workflow_dir.resolve() - for root in WORKFLOW_ROOTS: - try: - rel = resolved.relative_to(root.resolve()) - return f"{root.name}__{'__'.join(rel.parts)}" - except ValueError: - continue - return workflow_dir.name + """Stable short ID for a workflow directory, unique across roots. + + Uses a hash suffix derived from the resolved path to avoid collisions + when directory names contain the separator character. + """ + import hashlib + resolved = str(workflow_dir.resolve()) + path_hash = hashlib.sha256(resolved.encode()).hexdigest()[:8] + return f"{workflow_dir.name}_{path_hash}" def _discover_workflow_dirs() -> List[Path]: diff --git a/tests/test_workflow_id.py b/tests/test_workflow_id.py index d4ca82f..84bf3e1 100644 --- a/tests/test_workflow_id.py +++ b/tests/test_workflow_id.py @@ -28,27 +28,42 @@ class TestWorkflowId: id_b = dashboard_server._workflow_id(wf_b) assert id_a != id_b - assert "task1" in id_a - assert "task1" in id_b + assert id_a.startswith("task1_") + assert id_b.startswith("task1_") - def test_nested_workflow_id_includes_path(self, tmp_path): + def test_id_contains_dir_name_and_hash(self, tmp_path): root = tmp_path / "root" - wf = root / "sub" / "deep" / "task1" + wf = root / "my-task" _make_workflow(wf) with patch.object(dashboard_server, "WORKFLOW_ROOTS", [root]): wf_id = dashboard_server._workflow_id(wf) - assert wf_id == "root__sub__deep__task1" + assert wf_id.startswith("my-task_") + assert len(wf_id) == len("my-task_") + 8 # 8-char hex hash - def test_workflow_outside_roots_falls_back_to_name(self, tmp_path): + def test_separator_collision_produces_different_ids(self, tmp_path): + """Regression: a dir named 'a__b' must not collide with path a/b.""" + root = tmp_path / "root" + flat = root / "a__b" + nested = root / "a" / "b" + _make_workflow(flat) + _make_workflow(nested) + + with patch.object(dashboard_server, "WORKFLOW_ROOTS", [root]): + id_flat = dashboard_server._workflow_id(flat) + id_nested = dashboard_server._workflow_id(nested) + + assert id_flat != id_nested + + def test_workflow_outside_roots_still_works(self, tmp_path): wf = tmp_path / "orphan_workflow" _make_workflow(wf) with patch.object(dashboard_server, "WORKFLOW_ROOTS", []): wf_id = dashboard_server._workflow_id(wf) - assert wf_id == "orphan_workflow" + assert wf_id.startswith("orphan_workflow_") class TestDiscoverWorkflowDirs: From f05514845de0288b3a9032cfd7c1f360e6f43a8a Mon Sep 17 00:00:00 2001 From: spidercatfly Date: Thu, 2 Apr 2026 22:18:31 +0800 Subject: [PATCH 26/45] chore: remove outdated test files --- tests/test_issue3_startup.py | 177 ------------------------------- tests/test_max_iterations.py | 64 ----------- tests/test_workflow_id.py | 94 ---------------- tests/test_zip_path_traversal.py | 89 ---------------- 4 files changed, 424 deletions(-) delete mode 100644 tests/test_issue3_startup.py delete mode 100644 tests/test_max_iterations.py delete mode 100644 tests/test_workflow_id.py delete mode 100644 tests/test_zip_path_traversal.py diff --git a/tests/test_issue3_startup.py b/tests/test_issue3_startup.py deleted file mode 100644 index 9a7b894..0000000 --- a/tests/test_issue3_startup.py +++ /dev/null @@ -1,177 +0,0 @@ -import json - -import pytest - -from openspace import mcp_server - - -@pytest.mark.asyncio -async def test_local_search_skills_does_not_initialize_openspace(monkeypatch, tmp_path): - skill_root = tmp_path / "skills" - skill_dir = skill_root / "demo-local-skill" - skill_dir.mkdir(parents=True) - (skill_dir / "SKILL.md").write_text( - """--- -name: Demo Local Skill -description: Local regression test skill ---- - -Find me when querying demo local skill. -""", - encoding="utf-8", - ) - - async def forbidden_get_openspace(): - pytest.fail("_get_openspace should not run for source='local'") - - monkeypatch.setenv("OPENSPACE_HOST_SKILL_DIRS", str(skill_root)) - monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.setattr(mcp_server, "_get_openspace", forbidden_get_openspace) - monkeypatch.setattr( - "openspace.cloud.embedding.generate_embedding", - lambda text, api_key=None: None, - ) - - response = await mcp_server.search_skills( - query="demo local skill", - source="local", - limit=5, - auto_import=False, - ) - - payload = json.loads(response) - assert payload["count"] >= 1 - assert any( - item["name"] == "Demo Local Skill" - for item in payload["results"] - ) - - -@pytest.mark.asyncio -async def test_local_search_skills_refreshes_registry_between_calls(monkeypatch, tmp_path): - skill_root = tmp_path / "skills" - first_skill_dir = skill_root / "first-local-skill" - first_skill_dir.mkdir(parents=True) - (first_skill_dir / "SKILL.md").write_text( - """--- -name: First Local Skill -description: First local skill for cache regression coverage ---- - -First local skill content. -""", - encoding="utf-8", - ) - - async def forbidden_get_openspace(): - pytest.fail("_get_openspace should not run for source='local'") - - monkeypatch.setenv("OPENSPACE_HOST_SKILL_DIRS", str(skill_root)) - monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.setattr(mcp_server, "_get_openspace", forbidden_get_openspace) - monkeypatch.setattr( - "openspace.cloud.embedding.generate_embedding", - lambda text, api_key=None: None, - ) - - first_response = await mcp_server.search_skills( - query="first local skill", - source="local", - limit=5, - auto_import=False, - ) - - first_payload = json.loads(first_response) - assert any( - item["name"] == "First Local Skill" - for item in first_payload["results"] - ) - - second_skill_dir = skill_root / "second-local-skill" - second_skill_dir.mkdir(parents=True) - (second_skill_dir / "SKILL.md").write_text( - """--- -name: Second Local Skill -description: Second local skill created after the first search ---- - -Second local skill content. -""", - encoding="utf-8", - ) - - second_response = await mcp_server.search_skills( - query="second local skill", - source="local", - limit=5, - auto_import=False, - ) - - second_payload = json.loads(second_response) - assert any( - item["name"] == "Second Local Skill" - for item in second_payload["results"] - ) - - -@pytest.mark.asyncio -async def test_local_search_returns_empty_when_no_registry(monkeypatch): - """source='local' with no discoverable skills should return empty, not crash.""" - - async def forbidden_get_openspace(): - pytest.fail("_get_openspace should not run for source='local'") - - monkeypatch.setattr(mcp_server, "_get_openspace", forbidden_get_openspace) - monkeypatch.setattr(mcp_server, "_get_local_skill_registry", lambda: None) - monkeypatch.setattr( - "openspace.cloud.embedding.generate_embedding", - lambda text, api_key=None: None, - ) - - response = await mcp_server.search_skills( - query="anything", - source="local", - limit=5, - auto_import=False, - ) - - payload = json.loads(response) - assert payload["count"] == 0 - assert payload["results"] == [] - - -@pytest.mark.asyncio -async def test_source_all_still_calls_get_openspace(monkeypatch, tmp_path): - """source='all' must go through _get_openspace(), not the lightweight path.""" - called = {"openspace": False} - - class FakeRegistry: - def list_skills(self): - return [] - - class FakeOpenSpace: - _skill_registry = FakeRegistry() - - async def tracking_get_openspace(): - called["openspace"] = True - return FakeOpenSpace() - - monkeypatch.delenv("OPENSPACE_HOST_SKILL_DIRS", raising=False) - monkeypatch.setattr(mcp_server, "_get_openspace", tracking_get_openspace) - monkeypatch.setattr( - "openspace.cloud.embedding.generate_embedding", - lambda text, api_key=None: None, - ) - - await mcp_server.search_skills( - query="anything", - source="all", - limit=5, - auto_import=False, - ) - - assert called["openspace"], "source='all' should call _get_openspace()" diff --git a/tests/test_max_iterations.py b/tests/test_max_iterations.py deleted file mode 100644 index 04e6863..0000000 --- a/tests/test_max_iterations.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Test that max_iterations is passed to grounding agent in no-skill path.""" - -import asyncio -import types -from unittest.mock import AsyncMock, patch - -import pytest - -from openspace.tool_layer import OpenSpace, OpenSpaceConfig - - -def _make_openspace(configured_max: int = 50) -> OpenSpace: - config = OpenSpaceConfig() - config.grounding_max_iterations = configured_max - os_inst = OpenSpace(config) - os_inst._initialized = True - os_inst._running = False - os_inst._task_done = asyncio.Event() - os_inst._task_done.set() - os_inst._grounding_client = types.SimpleNamespace(_registry={}) - os_inst._recording_manager = None - os_inst._skill_registry = None - os_inst._execution_analyzer = None - os_inst._skill_evolver = None - return os_inst - - -@pytest.mark.asyncio -async def test_no_skill_path_passes_configured_max_iterations(): - """When no skills match, the configured grounding_max_iterations - must be forwarded to the agent, not silently dropped.""" - os_inst = _make_openspace(configured_max=50) - recorded = {} - - async def fake_process(context): - recorded.update(context) - return {"status": "success", "iterations": 1, "tool_executions": []} - - os_inst._grounding_agent = types.SimpleNamespace(process=fake_process) - os_inst._maybe_analyze_execution = AsyncMock() - os_inst._maybe_evolve_quality = AsyncMock() - - await os_inst.execute("do something") - - assert recorded.get("max_iterations") == 50 - - -@pytest.mark.asyncio -async def test_no_skill_path_uses_caller_override_when_larger(): - """Caller-provided max_iterations should win when larger than config.""" - os_inst = _make_openspace(configured_max=20) - recorded = {} - - async def fake_process(context): - recorded.update(context) - return {"status": "success", "iterations": 1, "tool_executions": []} - - os_inst._grounding_agent = types.SimpleNamespace(process=fake_process) - os_inst._maybe_analyze_execution = AsyncMock() - os_inst._maybe_evolve_quality = AsyncMock() - - await os_inst.execute("do something", max_iterations=100) - - assert recorded.get("max_iterations") == 100 diff --git a/tests/test_workflow_id.py b/tests/test_workflow_id.py deleted file mode 100644 index 84bf3e1..0000000 --- a/tests/test_workflow_id.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Tests for workflow ID uniqueness and discovery.""" - -from pathlib import Path -from unittest.mock import patch - -import pytest - -from openspace import dashboard_server - - -def _make_workflow(path: Path): - """Create a minimal workflow directory with metadata.json.""" - path.mkdir(parents=True, exist_ok=True) - (path / "metadata.json").write_text("{}", encoding="utf-8") - - -class TestWorkflowId: - def test_same_leaf_name_different_roots_get_unique_ids(self, tmp_path): - root_a = tmp_path / "root_a" - root_b = tmp_path / "root_b" - wf_a = root_a / "task1" - wf_b = root_b / "task1" - _make_workflow(wf_a) - _make_workflow(wf_b) - - with patch.object(dashboard_server, "WORKFLOW_ROOTS", [root_a, root_b]): - id_a = dashboard_server._workflow_id(wf_a) - id_b = dashboard_server._workflow_id(wf_b) - - assert id_a != id_b - assert id_a.startswith("task1_") - assert id_b.startswith("task1_") - - def test_id_contains_dir_name_and_hash(self, tmp_path): - root = tmp_path / "root" - wf = root / "my-task" - _make_workflow(wf) - - with patch.object(dashboard_server, "WORKFLOW_ROOTS", [root]): - wf_id = dashboard_server._workflow_id(wf) - - assert wf_id.startswith("my-task_") - assert len(wf_id) == len("my-task_") + 8 # 8-char hex hash - - def test_separator_collision_produces_different_ids(self, tmp_path): - """Regression: a dir named 'a__b' must not collide with path a/b.""" - root = tmp_path / "root" - flat = root / "a__b" - nested = root / "a" / "b" - _make_workflow(flat) - _make_workflow(nested) - - with patch.object(dashboard_server, "WORKFLOW_ROOTS", [root]): - id_flat = dashboard_server._workflow_id(flat) - id_nested = dashboard_server._workflow_id(nested) - - assert id_flat != id_nested - - def test_workflow_outside_roots_still_works(self, tmp_path): - wf = tmp_path / "orphan_workflow" - _make_workflow(wf) - - with patch.object(dashboard_server, "WORKFLOW_ROOTS", []): - wf_id = dashboard_server._workflow_id(wf) - - assert wf_id.startswith("orphan_workflow_") - - -class TestDiscoverWorkflowDirs: - def test_same_name_workflows_both_discovered(self, tmp_path): - root_a = tmp_path / "root_a" - root_b = tmp_path / "root_b" - _make_workflow(root_a / "task1") - _make_workflow(root_b / "task1") - - with patch.object(dashboard_server, "WORKFLOW_ROOTS", [root_a, root_b]): - dirs = dashboard_server._discover_workflow_dirs() - - assert len(dirs) == 2 - - def test_get_workflow_dir_resolves_correct_path(self, tmp_path): - root_a = tmp_path / "root_a" - root_b = tmp_path / "root_b" - _make_workflow(root_a / "task1") - _make_workflow(root_b / "task1") - - with patch.object(dashboard_server, "WORKFLOW_ROOTS", [root_a, root_b]): - id_a = dashboard_server._workflow_id(root_a / "task1") - id_b = dashboard_server._workflow_id(root_b / "task1") - found_a = dashboard_server._get_workflow_dir(id_a) - found_b = dashboard_server._get_workflow_dir(id_b) - - assert found_a.resolve() == (root_a / "task1").resolve() - assert found_b.resolve() == (root_b / "task1").resolve() diff --git a/tests/test_zip_path_traversal.py b/tests/test_zip_path_traversal.py deleted file mode 100644 index 275176b..0000000 --- a/tests/test_zip_path_traversal.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Regression tests for zip extraction and import_skill path traversal.""" - -import io -import zipfile -from pathlib import Path -from unittest.mock import patch, MagicMock - -import pytest - - -def _make_zip(entries: dict[str, bytes]) -> bytes: - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w") as zf: - for name, data in entries.items(): - zf.writestr(name, data) - return buf.getvalue() - - -def _get_extract_zip(): - import importlib - mod = importlib.import_module("openspace.cloud.client") - return mod.OpenSpaceClient._extract_zip - - -class TestExtractZip: - def test_normal_file_extracted(self, tmp_path): - extract = _get_extract_zip() - zip_data = _make_zip({"hello.txt": b"world"}) - result = extract(zip_data, tmp_path) - assert "hello.txt" in result - assert (tmp_path / "hello.txt").read_bytes() == b"world" - - def test_dotdot_prefix_blocked(self, tmp_path): - extract = _get_extract_zip() - zip_data = _make_zip({"../escape.txt": b"bad"}) - result = extract(zip_data, tmp_path) - assert result == [] - - def test_nested_traversal_blocked(self, tmp_path): - """The real bug: nested/../../escape.txt bypassed the old startswith check.""" - extract = _get_extract_zip() - zip_data = _make_zip({"nested/../../escape.txt": b"bad"}) - result = extract(zip_data, tmp_path) - assert result == [] - assert not (tmp_path.parent / "escape.txt").exists() - - def test_absolute_path_blocked(self, tmp_path): - extract = _get_extract_zip() - zip_data = _make_zip({"/etc/passwd": b"bad"}) - result = extract(zip_data, tmp_path) - assert result == [] - - -class TestImportSkillNameTraversal: - def test_malicious_record_name_sanitized(self, tmp_path): - from openspace.cloud.client import OpenSpaceClient - - client = OpenSpaceClient.__new__(OpenSpaceClient) - target_dir = tmp_path / "skills" - target_dir.mkdir() - - malicious_name = "../../escapedir" - skill_id = "safe_skill_id" - zip_data = _make_zip({"SKILL.md": b"---\nname: test\n---\ncontent"}) - - with patch.object(client, "fetch_record", return_value={"name": malicious_name}), \ - patch.object(client, "download_artifact", return_value=zip_data): - result = client.import_skill(skill_id, target_dir) - - assert result["status"] == "success" - resolved = Path(result["local_path"]).resolve() - assert resolved.is_relative_to(target_dir.resolve()) - assert not (tmp_path.parent / "escapedir").exists() - - def test_normal_record_name_works(self, tmp_path): - from openspace.cloud.client import OpenSpaceClient - - client = OpenSpaceClient.__new__(OpenSpaceClient) - target_dir = tmp_path / "skills" - target_dir.mkdir() - - zip_data = _make_zip({"SKILL.md": b"---\nname: test\n---\ncontent"}) - - with patch.object(client, "fetch_record", return_value={"name": "my-skill"}), \ - patch.object(client, "download_artifact", return_value=zip_data): - result = client.import_skill("some_id", target_dir) - - assert result["status"] == "success" - assert (target_dir / "my-skill" / "SKILL.md").exists() From e1a85244758149498d82d9b311689cbfb1dbced8 Mon Sep 17 00:00:00 2001 From: spidercatfly Date: Thu, 2 Apr 2026 23:17:44 +0800 Subject: [PATCH 27/45] feat: migrate cloud search to server-side embedding endpoint --- openspace/cloud/client.py | 29 +++++++ openspace/cloud/search.py | 157 ++++++++++++++++++++++++-------------- openspace/mcp_server.py | 59 ++++++-------- 3 files changed, 152 insertions(+), 93 deletions(-) diff --git a/openspace/cloud/client.py b/openspace/cloud/client.py index 7bcd3b1..f97b2ac 100644 --- a/openspace/cloud/client.py +++ b/openspace/cloud/client.py @@ -5,6 +5,7 @@ All methods are **synchronous** (use ``urllib``). In async contexts Provides both low-level HTTP operations and higher-level workflows: - ``fetch_record`` / ``download_artifact`` / ``fetch_metadata`` + - ``search_record_embeddings`` - ``stage_artifact`` / ``create_record`` - ``upload_skill`` (stage → diff → create — full workflow) - ``import_skill`` (fetch → download → extract — full workflow) @@ -29,6 +30,7 @@ logger = logging.getLogger("openspace.cloud") SKILL_FILENAME = "SKILL.md" SKILL_ID_FILENAME = ".skill_id" +RECORD_EMBEDDING_SEARCH_MAX_LIMIT = 300 _TEXT_EXTENSIONS = frozenset({ ".md", ".txt", ".yaml", ".yml", ".json", ".py", ".sh", ".toml", @@ -142,6 +144,33 @@ class OpenSpaceClient: return all_items + def search_record_embeddings( + self, + *, + query: str, + limit: int = RECORD_EMBEDDING_SEARCH_MAX_LIMIT, + level: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> List[Dict[str, Any]]: + """POST /records/embeddings/search — fetch server-ranked embedding rows.""" + search_request_payload: Dict[str, Any] = { + "query": query, + "limit": limit, + } + if level: + search_request_payload["level"] = level + if tags: + search_request_payload["tags"] = tags + + _, response_body = self._request( + "POST", + "/records/embeddings/search", + body=json.dumps(search_request_payload).encode("utf-8"), + extra_headers={"Content-Type": "application/json"}, + timeout=30, + ) + return json.loads(response_body.decode("utf-8")) + def stage_artifact(self, skill_dir: Path) -> tuple[str, int]: """POST /artifacts/stage — upload skill files. diff --git a/openspace/cloud/search.py b/openspace/cloud/search.py index 678f71b..bd25436 100644 --- a/openspace/cloud/search.py +++ b/openspace/cloud/search.py @@ -18,6 +18,7 @@ import re from typing import Any, Dict, List, Optional logger = logging.getLogger("openspace.cloud") +CLOUD_EMBEDDING_SEARCH_MAX_LIMIT = 300 def _check_safety(text: str) -> list[str]: @@ -159,36 +160,43 @@ class SkillSearchEngine: from openspace.cloud.embedding import cosine_similarity scored = [] - for c in candidates: - name = c.get("name", "") - slug = c.get("skill_id", name).split("__")[0].replace(":", "-") + for candidate in candidates: + candidate_name = candidate.get("name", "") + candidate_slug = candidate.get("skill_id", candidate_name).split("__")[0].replace(":", "-") - # Vector score - vector_score = 0.0 + # Vector score. If client-side query embeddings are unavailable, + # reuse the server-side cloud rank so cloud results keep semantic signal. + vector_score: Optional[float] = None + ranking_signal_score = 0.0 if query_embedding: - skill_emb = c.get("_embedding") - if skill_emb and isinstance(skill_emb, list): - vector_score = cosine_similarity(query_embedding, skill_emb) + candidate_embedding = candidate.get("_embedding") + if candidate_embedding and isinstance(candidate_embedding, list): + vector_score = cosine_similarity(query_embedding, candidate_embedding) + ranking_signal_score = vector_score + elif isinstance(candidate.get("_search_rank"), (int, float)): + ranking_signal_score = float(candidate["_search_rank"]) # Lexical boost - lexical = _lexical_boost(query_tokens, name, slug) + lexical_boost = _lexical_boost(query_tokens, candidate_name, candidate_slug) - final_score = vector_score + lexical + final_score = ranking_signal_score + lexical_boost - entry: Dict[str, Any] = { - "skill_id": c.get("skill_id", ""), - "name": name, - "description": c.get("description", ""), - "source": c.get("source", ""), + result_entry: Dict[str, Any] = { + "skill_id": candidate.get("skill_id", ""), + "name": candidate_name, + "description": candidate.get("description", ""), + "source": candidate.get("source", ""), "score": round(final_score, 4), } - if vector_score > 0: - entry["vector_score"] = round(vector_score, 4) + if vector_score is not None and vector_score > 0: + result_entry["vector_score"] = round(vector_score, 4) + if isinstance(candidate.get("_search_rank"), (int, float)): + result_entry["server_search_rank"] = round(float(candidate["_search_rank"]), 4) # Include optional fields for key in ("path", "visibility", "created_by", "origin", "tags", "quality", "safety_flags"): - if c.get(key): - entry[key] = c[key] - scored.append(entry) + if candidate.get(key): + result_entry[key] = candidate[key] + scored.append(result_entry) scored.sort(key=lambda x: -x["score"]) return scored @@ -275,47 +283,85 @@ def build_local_candidates( def build_cloud_candidates( - items: List[Dict[str, Any]], + cloud_items: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: - """Build search candidate dicts from cloud metadata items. + """Build search candidate dicts from cloud metadata/search items. Args: - items: Items from ``OpenSpaceClient.fetch_metadata()``. + cloud_items: Items from cloud metadata or embedding search endpoints. Returns: List of candidate dicts (with safety filtering applied). """ candidates: List[Dict[str, Any]] = [] - for item in items: - name = item.get("name", "") - desc = item.get("description", "") - tags = item.get("tags", []) - safety_text = f"{name}\n{desc}\n{' '.join(tags)}" + for item in cloud_items: + candidate_name = item.get("name", "") + candidate_description = item.get("description", "") + candidate_tags = item.get("tags", []) + safety_text = f"{candidate_name}\n{candidate_description}\n{' '.join(candidate_tags)}" flags = _check_safety(safety_text) if not _is_safe(flags): continue - c_entry: Dict[str, Any] = { + candidate_entry: Dict[str, Any] = { "skill_id": item.get("record_id", ""), - "name": name, - "description": desc, + "name": candidate_name, + "description": candidate_description, "source": "cloud", "visibility": item.get("visibility", "public"), "is_local": False, "created_by": item.get("created_by", ""), "origin": item.get("origin", ""), - "tags": tags, + "tags": candidate_tags, "safety_flags": flags if flags else None, } # Carry pre-computed embedding - platform_emb = item.get("embedding") - if platform_emb and isinstance(platform_emb, list): - c_entry["_embedding"] = platform_emb - candidates.append(c_entry) + server_embedding = item.get("embedding") + if server_embedding and isinstance(server_embedding, list): + candidate_entry["_embedding"] = server_embedding + server_search_rank = item.get("search_rank") + if isinstance(server_search_rank, (int, float)): + candidate_entry["_search_rank"] = float(server_search_rank) + candidates.append(candidate_entry) return candidates +def build_cloud_results( + cloud_search_items: List[Dict[str, Any]], + *, + limit: int, +) -> List[Dict[str, Any]]: + """Map server-ranked cloud search rows to MCP search result shape.""" + results: List[Dict[str, Any]] = [] + seen_names: set[str] = set() + + for candidate in build_cloud_candidates(cloud_search_items): + candidate_name = candidate.get("name", "") + dedupe_name = candidate_name or candidate.get("skill_id", "") + if dedupe_name in seen_names: + continue + seen_names.add(dedupe_name) + + entry: Dict[str, Any] = { + "skill_id": candidate.get("skill_id", ""), + "name": candidate_name, + "description": candidate.get("description", ""), + "source": "cloud", + "score": round(float(candidate.get("_search_rank", 0.0)), 4), + } + if isinstance(candidate.get("_search_rank"), (int, float)): + entry["server_search_rank"] = round(float(candidate["_search_rank"]), 4) + for key in ("visibility", "created_by", "origin", "tags", "safety_flags"): + if candidate.get(key): + entry[key] = candidate[key] + results.append(entry) + if len(results) >= limit: + break + + return results + + async def hybrid_search_skills( query: str, local_skills: list = None, @@ -341,8 +387,8 @@ async def hybrid_search_skills( """ from openspace.cloud.embedding import generate_embedding - q = query.strip() - if not q: + normalized_query = query.strip() + if not normalized_query: return [] candidates: List[Dict[str, Any]] = [] @@ -357,16 +403,16 @@ async def hybrid_search_skills( auth_headers, api_base = get_openspace_auth() if auth_headers: - client = OpenSpaceClient(auth_headers, api_base) - try: - from openspace.cloud.embedding import resolve_embedding_api - has_emb = bool(resolve_embedding_api()[0]) - except Exception: - has_emb = False - items = await asyncio.to_thread( - client.fetch_metadata, include_embedding=has_emb, limit=200, + cloud_client = OpenSpaceClient(auth_headers, api_base) + cloud_result_limit = limit if source == "cloud" else CLOUD_EMBEDDING_SEARCH_MAX_LIMIT + cloud_search_items = await asyncio.to_thread( + cloud_client.search_record_embeddings, + query=normalized_query, + limit=cloud_result_limit, ) - candidates.extend(build_cloud_candidates(items)) + if source == "cloud": + return build_cloud_results(cloud_search_items, limit=limit) + candidates.extend(build_cloud_candidates(cloud_search_items)) except Exception as e: logger.warning(f"hybrid_search_skills: cloud unavailable: {e}") @@ -376,18 +422,17 @@ async def hybrid_search_skills( # query embedding (optional — key/URL resolved inside generate_embedding) query_embedding: Optional[List[float]] = None try: - query_embedding = await asyncio.to_thread(generate_embedding, q) + query_embedding = await asyncio.to_thread(generate_embedding, normalized_query) if query_embedding: - for c in candidates: - if not c.get("_embedding") and c.get("_embedding_text"): - emb = await asyncio.to_thread( - generate_embedding, c["_embedding_text"], + for candidate in candidates: + if not candidate.get("_embedding") and candidate.get("_embedding_text"): + candidate_embedding = await asyncio.to_thread( + generate_embedding, candidate["_embedding_text"], ) - if emb: - c["_embedding"] = emb + if candidate_embedding: + candidate["_embedding"] = candidate_embedding except Exception: pass engine = SkillSearchEngine() - return engine.search(q, candidates, query_embedding=query_embedding, limit=limit) - + return engine.search(normalized_query, candidates, query_embedding=query_embedding, limit=limit) diff --git a/openspace/mcp_server.py b/openspace/mcp_server.py index e81ef12..b010f2f 100644 --- a/openspace/mcp_server.py +++ b/openspace/mcp_server.py @@ -368,63 +368,48 @@ async def _cloud_search_and_import(task: str, limit: int = 8) -> List[Dict[str, """Search cloud for skills relevant to *task* and auto-import top hits. This is **stage 1** of a two-stage pipeline: - Stage 1 (here): cloud BM25+embedding → pick top-N to import locally. + Stage 1 (here): server-side embedding search → pick top-N to import locally. Stage 2 (tool_layer): local BM25 + LLM → select from ALL local skills (including ones just imported) for injection. Stage 1 intentionally imports more than will be used (default: 8) so - that stage 2 has a larger pool to choose from. The two BM25 passes - are NOT redundant — stage 1 filters thousands of cloud candidates down + that stage 2 has a larger pool to choose from. Stage 1 relies on the + server's embedding search to filter thousands of cloud candidates down to a manageable import set; stage 2 makes the final task-specific choice. """ try: - from openspace.cloud.search import ( - SkillSearchEngine, build_cloud_candidates, - ) - from openspace.cloud.embedding import generate_embedding, resolve_embedding_api - - client = _get_cloud_client() - embedding_api_key, _ = resolve_embedding_api() - has_embedding = bool(embedding_api_key) - - items = await asyncio.to_thread( - client.fetch_metadata, include_embedding=has_embedding, limit=200, - ) - if not items: + normalized_task_query = task.strip() + if not normalized_task_query: return [] - candidates = build_cloud_candidates(items) - if not candidates: + cloud_client = _get_cloud_client() + cloud_search_results = await asyncio.to_thread( + cloud_client.search_record_embeddings, + query=normalized_task_query, + limit=min(limit * 2, 300), + ) + if not cloud_search_results: return [] - query_embedding: Optional[List[float]] = None - if has_embedding: - query_embedding = await asyncio.to_thread( - generate_embedding, task, - ) - - engine = SkillSearchEngine() - results = engine.search(task, candidates, query_embedding=query_embedding, limit=limit * 2) - - cloud_hits = [ - r for r in results - if r.get("source") == "cloud" - and r.get("visibility", "public") == "public" - and r.get("skill_id") + public_cloud_hits = [ + cloud_result for cloud_result in cloud_search_results + if cloud_result.get("visibility", "public") == "public" + and cloud_result.get("record_id") ][:limit] import_results: List[Dict[str, Any]] = [] - for hit in cloud_hits: + for cloud_hit in public_cloud_hits: try: - imp = await _do_import_cloud_skill(skill_id=hit["skill_id"]) + skill_id = cloud_hit["record_id"] + imp = await _do_import_cloud_skill(skill_id=skill_id) import_results.append({ - "skill_id": hit["skill_id"], - "name": hit.get("name", ""), + "skill_id": skill_id, + "name": cloud_hit.get("name", ""), "import_status": imp.get("status", "error"), "local_path": imp.get("local_path", ""), }) except Exception as e: - logger.warning(f"Cloud import failed for {hit['skill_id']}: {e}") + logger.warning(f"Cloud import failed for {skill_id}: {e}") if import_results: logger.info(f"Cloud search imported {len(import_results)} skill(s)") From e80c70e9c968ec13d1473f37917deff431dbdf6c Mon Sep 17 00:00:00 2001 From: Xu Lingrui <151314054+xlrrrr@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:34:47 +0800 Subject: [PATCH 28/45] feat(frontend): add Chinese (zh) i18n support (#49) --- frontend/package-lock.json | 98 ++++++- frontend/package.json | 2 + frontend/src/components/ErrorBoundary.tsx | 11 +- .../components/skill-detail/DiffViewer.tsx | 8 +- .../skill-detail/SkillEvolutionGraph.tsx | 10 +- .../skill-detail/SkillVersionDrawer.tsx | 92 ++++--- .../skill-detail/SkillVersionFilterBar.tsx | 12 +- frontend/src/i18n/en.json | 253 ++++++++++++++++++ frontend/src/i18n/index.ts | 34 +++ frontend/src/i18n/zh.json | 253 ++++++++++++++++++ frontend/src/index.css | 2 +- frontend/src/layouts/MainLayout.tsx | 24 +- frontend/src/main.tsx | 1 + frontend/src/pages/DashboardPage.tsx | 62 ++--- frontend/src/pages/SkillDetailPage.tsx | 54 ++-- frontend/src/pages/SkillsPage.tsx | 40 +-- frontend/src/pages/WorkflowDetailPage.tsx | 90 +++---- frontend/src/pages/WorkflowsPage.tsx | 32 +-- frontend/src/utils/format.ts | 3 +- 19 files changed, 879 insertions(+), 202 deletions(-) create mode 100644 frontend/src/i18n/en.json create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/zh.json diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6de88c8..b4630ac 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,9 +9,11 @@ "version": "0.1.0", "dependencies": { "axios": "^1.7.9", + "i18next": "^26.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-force-graph-2d": "^1.25.4", + "react-i18next": "^17.0.2", "react-router-dom": "^7.1.1" }, "devDependencies": { @@ -272,6 +274,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2297,6 +2308,46 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.0.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.3.tgz", + "integrity": "sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/index-array-by": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", @@ -2916,6 +2967,33 @@ "react": "*" } }, + "node_modules/react-i18next": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.2.tgz", + "integrity": "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -3319,7 +3397,7 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -3360,6 +3438,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3473,6 +3560,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index ef1907d..2005cf7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,9 +10,11 @@ }, "dependencies": { "axios": "^1.7.9", + "i18next": "^26.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-force-graph-2d": "^1.25.4", + "react-i18next": "^17.0.2", "react-router-dom": "^7.1.1" }, "devDependencies": { diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index c50f737..855d55c 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import { Component, ErrorInfo, ReactNode } from 'react'; +import i18n from '../i18n'; interface Props { children: ReactNode; @@ -21,7 +22,6 @@ export class ErrorBoundary extends Component { } componentDidCatch(error: Error, errorInfo: ErrorInfo) { - // Log error to reporting service if (import.meta.env.DEV) { console.error('Error caught by boundary:', error, errorInfo); } @@ -29,25 +29,26 @@ export class ErrorBoundary extends Component { render() { if (this.state.hasError) { + const t = i18n.t.bind(i18n); return ( this.props.fallback || (

- Something went wrong + {t('errorBoundary.title')}

- An unexpected error occurred + {t('errorBoundary.message')}

{import.meta.env.DEV && this.state.error && (
- Error details + {t('errorBoundary.details')}
                     {this.state.error.stack}
                   
diff --git a/frontend/src/components/skill-detail/DiffViewer.tsx b/frontend/src/components/skill-detail/DiffViewer.tsx index 3a37b65..49b37b9 100644 --- a/frontend/src/components/skill-detail/DiffViewer.tsx +++ b/frontend/src/components/skill-detail/DiffViewer.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState, type KeyboardEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import type { DiffFile, DiffLine } from '../../utils/diffParser'; interface DiffViewerProps { @@ -124,6 +125,7 @@ function buildSplitRows(lines: DiffLine[], header: string): SplitDiffRow[] { } export default function DiffViewer({ files }: DiffViewerProps) { + const { t } = useTranslation(); const [selectedIndex, setSelectedIndex] = useState(0); const renderableFiles = useMemo( () => files.filter((file) => file.hunks.some((hunk) => hunk.lines.length > 0)), @@ -135,7 +137,7 @@ export default function DiffViewer({ files }: DiffViewerProps) { }, [renderableFiles]); if (renderableFiles.length === 0) { - return

No files in diff

; + return

{t('diffViewer.noFiles')}

; } const activeIndex = selectedIndex < renderableFiles.length ? selectedIndex : 0; @@ -216,8 +218,8 @@ export default function DiffViewer({ files }: DiffViewerProps) { return (
-
Old
-
New
+
{t('diffViewer.old')}
+
{t('diffViewer.new')}
{hunk.header} diff --git a/frontend/src/components/skill-detail/SkillEvolutionGraph.tsx b/frontend/src/components/skill-detail/SkillEvolutionGraph.tsx index 689749c..7675e7f 100644 --- a/frontend/src/components/skill-detail/SkillEvolutionGraph.tsx +++ b/frontend/src/components/skill-detail/SkillEvolutionGraph.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import ForceGraph2D from 'react-force-graph-2d'; import type { SkillGraphNode } from '../../hooks/useSkillEvolutionGraphData'; @@ -25,6 +26,7 @@ export default function SkillEvolutionGraph({ onNodeClick, onBackgroundClick, }: SkillEvolutionGraphProps) { + const { t } = useTranslation(); const graphContainerRef = useRef(null); const fgRef = useRef(null); const [graphDim, setGraphDim] = useState({ width: 0, height: 0 }); @@ -180,7 +182,7 @@ export default function SkillEvolutionGraph({ }, []); if (graphData.nodes.length === 0) { - return
No lineage graph data.
; + return
{t('graph.noGraphData')}
; } return ( @@ -197,9 +199,9 @@ export default function SkillEvolutionGraph({ const graphNode = node as SkillGraphNode; return [ graphNode.name, - `score: ${graphNode.score.toFixed(1)}`, - `generation: ${graphNode.generation}`, - `origin: ${graphNode.origin}`, + t('graph.tooltipScore', { value: graphNode.score.toFixed(1) }), + t('graph.tooltipGeneration', { value: graphNode.generation }), + t('graph.tooltipOrigin', { value: graphNode.origin }), ].join('\n'); }} onNodeClick={(node) => { diff --git a/frontend/src/components/skill-detail/SkillVersionDrawer.tsx b/frontend/src/components/skill-detail/SkillVersionDrawer.tsx index b842b92..363f560 100644 --- a/frontend/src/components/skill-detail/SkillVersionDrawer.tsx +++ b/frontend/src/components/skill-detail/SkillVersionDrawer.tsx @@ -1,6 +1,7 @@ import { useLayoutEffect, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import type { SkillDetail } from '../../api'; import { parseDiff } from '../../utils/diffParser'; import EmptyState from '../EmptyState'; @@ -72,6 +73,7 @@ function lockScroll() { } export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVersionDrawerProps) { + const { t } = useTranslation(); const closeButtonRef = useRef(null); const rawDiff = skill?.lineage.content_diff ?? ''; @@ -135,16 +137,16 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
-

Skill Version

+

{t('drawer.skillVersion')}

{skill.name}

{skill.skill_id}

- Open as main + {t('drawer.openAsMain')}
@@ -153,13 +155,13 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
-
Version Summary
-
{skill.description || 'No description available for this version.'}
+
{t('drawer.versionSummary')}
+
{skill.description || t('drawer.noDescriptionAvailable')}
{skill.category} {skill.origin} - gen {skill.generation} - {skill.is_active ? 'active' : 'inactive'} + {t('drawer.gen', { generation: skill.generation })} + {skill.is_active ? t('common.active') : t('common.inactive')} {skill.tags.map((tag) => ( {tag} ))} @@ -167,67 +169,67 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
{skill.score.toFixed(1)}
-
version score
+
{t('drawer.versionScore')}
-
Metrics
-

Execution quality

+
{t('drawer.metrics')}
+

{t('drawer.executionQuality')}

- - - - + + + +
-
Selections
{skill.total_selections}
-
Applied
{skill.total_applied}
-
Completions
{skill.total_completions}
-
Fallbacks
{skill.total_fallbacks}
+
{t('drawer.selectionsLabel')}
{skill.total_selections}
+
{t('drawer.appliedLabel')}
{skill.total_applied}
+
{t('drawer.completionsLabel')}
{skill.total_completions}
+
{t('drawer.fallbacksLabel')}
{skill.total_fallbacks}
-

Version Metadata

-

Origin: {skill.origin}

-

Generation: {skill.generation}

-

Visibility: {skill.visibility}

-

Created: {formatDate(skill.lineage.created_at)}

-

First seen: {formatDate(skill.first_seen)}

-

Last updated: {formatDate(skill.last_updated)}

-

Skill path: {skill.path || 'Unavailable'}

-

Skill dir: {skill.skill_dir || 'Unavailable'}

-

Parent IDs: {skill.parent_skill_ids.length ? skill.parent_skill_ids.join(', ') : 'None'}

-

Change summary: {skill.lineage.change_summary || 'None'}

-

Effective score: {formatPercent(skill.effective_rate)}

+

{t('drawer.versionMetadata')}

+

{t('drawer.origin')} {skill.origin}

+

{t('drawer.generation')} {skill.generation}

+

{t('drawer.visibility')} {skill.visibility}

+

{t('drawer.created')} {formatDate(skill.lineage.created_at)}

+

{t('drawer.firstSeen')} {formatDate(skill.first_seen)}

+

{t('drawer.lastUpdated')} {formatDate(skill.last_updated)}

+

{t('drawer.skillPath')} {skill.path || t('common.unavailable')}

+

{t('drawer.skillDir')} {skill.skill_dir || t('common.unavailable')}

+

{t('drawer.parentIds')} {skill.parent_skill_ids.length ? skill.parent_skill_ids.join(', ') : t('common.none')}

+

{t('drawer.changeSummary')} {skill.lineage.change_summary || t('common.none')}

+

{t('drawer.effectiveScore')} {formatPercent(skill.effective_rate)}

-
Diff
-

Content diff

+
{t('drawer.diff')}
+

{t('drawer.contentDiff')}

{isOversizedDiff ? ( - + ) : canShowDiff ? ( diffFiles.length > 0 ? ( ) : ( - + ) ) : ( - + )}
-
Source
-

SKILL.md preview

+
{t('drawer.source')}
+

{t('drawer.skillMdPreview')}

{sourcePreview ? (
@@ -235,14 +237,14 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
{sourcePreview.content}
) : ( - + )}
-
Analyses
-

Recent execution analyses

+
{t('drawer.analyses')}
+

{t('drawer.recentAnalyses')}

{skill.recent_analyses.length > 0 ? (
@@ -252,15 +254,19 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
{analysis.task_id}
{formatDate(analysis.timestamp)}
-
{truncate(analysis.execution_note || 'No execution note', 220)}
+
{truncate(analysis.execution_note || t('drawer.noExecutionNote'), 220)}
- completed: {analysis.task_completed ? 'yes' : 'no'} · tool issues: {analysis.tool_issues.length} · suggestions: {analysis.evolution_suggestions.length} + {t('drawer.analysisCompleted', { + value: analysis.task_completed ? t('common.yes') : t('common.no'), + toolIssues: analysis.tool_issues.length, + suggestions: analysis.evolution_suggestions.length, + })}
))}
) : ( - + )} diff --git a/frontend/src/components/skill-detail/SkillVersionFilterBar.tsx b/frontend/src/components/skill-detail/SkillVersionFilterBar.tsx index 381b3a2..02aaddd 100644 --- a/frontend/src/components/skill-detail/SkillVersionFilterBar.tsx +++ b/frontend/src/components/skill-detail/SkillVersionFilterBar.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + interface SkillVersionFilterBarProps { originFilter: string; onOriginFilterChange: (value: string) => void; @@ -15,16 +17,18 @@ export default function SkillVersionFilterBar({ allOrigins, allTags, }: SkillVersionFilterBarProps) { + const { t } = useTranslation(); + return (
- + onTagFilterChange(event.target.value)} className="border border-[color:var(--color-ink)] bg-transparent px-2 py-1 text-sm" > - + {allTags.map((tag) => ( ))} diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json new file mode 100644 index 0000000..5d476a3 --- /dev/null +++ b/frontend/src/i18n/en.json @@ -0,0 +1,253 @@ +{ + "nav": { + "dashboard": "Dashboard", + "skills": "Skills", + "workflows": "Workflows" + }, + "common": { + "loading": "Loading…", + "yes": "yes", + "no": "no", + "none": "None", + "unavailable": "Unavailable", + "unknown": "Unknown", + "close": "Close", + "score": "score", + "success": "success", + "active": "active", + "inactive": "inactive", + "noDescription": "No description", + "steps_one": "{{count}} step", + "steps_other": "{{count}} steps", + "agentActions_one": "{{count}} agent action", + "agentActions_other": "{{count}} agent actions", + "tags": "+{{count}} tags" + }, + "langSwitch": { + "label": "Language" + }, + "errorBoundary": { + "title": "Something went wrong", + "message": "An unexpected error occurred", + "goToDashboard": "Go to Dashboard", + "details": "Error details" + }, + "dashboard": { + "title": "Dashboard", + "loadingDashboard": "Loading dashboard…", + "failedToLoad": "Failed to load overview", + "dashboardUnavailable": "Dashboard unavailable", + "totalSkills": "Total Skills", + "activeHint": "Active: {{count}}", + "avgSkillScore": "Average Skill Score", + "avgScoreHint": "Primary metric = effective rate × 100", + "workflowSessions": "Workflow Sessions", + "localRepo": "local repo", + "workspace": "workspace", + "recordedUnder": "Recorded under {{location}}", + "workflowSuccess": "Workflow Success", + "avgSuccessHint": "Average session success rate", + "health": "Health", + "runtimeSnapshot": "Runtime snapshot", + "status": "Status", + "dbPath": "DB Path", + "workflowCount": "Workflow Count", + "builtFrontend": "Built Frontend", + "skillsSection": "Skills", + "topScoredSkills": "Top scored skills", + "noSkillsYet": "No skills yet", + "noSkillsDesc": "Run OpenSpace tasks or sync skills into the local registry first.", + "effective": "effective {{value}}", + "applied": "applied {{value}}", + "selections": "selections {{count}}", + "workflowsSection": "Workflows", + "recentSessions": "Recent sessions", + "noWorkflowSessions": "No workflow sessions", + "noWorkflowDesc": "Recordings will appear after a task is executed with recording enabled." + }, + "skills": { + "title": "Skill classes", + "searchPlaceholder": "Search by name, id, description, tag, or origin", + "sortByScore": "Sort by best score", + "sortByUpdated": "Sort by updated time", + "sortByName": "Sort by name", + "skillClasses": "Skill Classes", + "versionsHint": "Versions: {{count}}", + "activeVersions": "Active Versions", + "withActivity": "With activity: {{count}}", + "avgBestScore": "Average Best Score", + "bestNodeScoreHint": "Best node score per class", + "selections": "Selections", + "completionsHint": "Completions: {{count}}", + "loadingSkills": "Loading skills…", + "failedToLoad": "Failed to load skills", + "noSkillsMatch": "No skills match", + "noSkillsMatchDesc": "Try another keyword, or execute tasks so new skill telemetry lands in SQLite.", + "bestScore": "best score", + "noClassDescription": "No class description", + "versions": "{{count}} versions", + "active": "{{count}} active", + "selectionsCount": "{{count}} selections" + }, + "skillDetail": { + "loadingDetail": "Loading skill detail…", + "failedToLoad": "Failed to load skill class", + "skillNotFound": "Skill not found", + "backToSkills": "← Back to Skills", + "anchoredOn": "Skill class anchored on {{id}}", + "skillClass": "Skill Class", + "evolutionOverview": "Evolution overview", + "activeTip": "active tip", + "inactiveAnchor": "inactive anchor", + "bestVersionScore": "best version score", + "skillDirectory": "Skill directory", + "latestVersionCreated": "Latest version created", + "representativeVersion": "Representative version", + "representativeUpdate": "Representative update", + "versions": "Versions", + "maxGeneration": "Max generation {{count}}", + "activeVersions": "Active Versions", + "originsCount": "Origins: {{count}}", + "averageScore": "Average Score", + "acrossAllVersions": "Across all versions in this lineage", + "selections": "Selections", + "representativeScore": "Representative score {{score}}", + "evolutionGraph": "Evolution Graph", + "versionLineage": "Version lineage", + "loadingDrawer": "Loading version drawer…", + "noLineageGraph": "No lineage graph", + "noLineageGraphDesc": "This skill does not yet have lineage data to visualize." + }, + "workflows": { + "title": "Recorded sessions", + "searchPlaceholder": "Search by task name or instruction", + "workflowSessions": "Workflow Sessions", + "scannedFrom": "Scanned from logs/recordings and logs/trajectories", + "averageSuccess": "Average Success", + "meanSuccessRate": "Mean success rate across sessions", + "loadingWorkflows": "Loading workflows…", + "failedToLoad": "Failed to load workflows", + "noSessions": "No workflow sessions", + "noSessionsDesc": "Run `openspace` with recording enabled, then refresh this page.", + "more": "+{{count}} more" + }, + "workflowDetail": { + "loadingDetail": "Loading workflow detail…", + "failedToLoad": "Failed to load workflow", + "workflowNotFound": "Workflow not found", + "backToWorkflows": "← Back to Workflows", + "workflowDetail": "Workflow detail", + "skillsSelected_one": "{{count}} skill selected", + "skillsSelected_other": "{{count}} skills selected", + "mergedEvent_one": "{{count}} merged event", + "mergedEvent_other": "{{count}} merged events", + "runDescription": "{{status}} run with {{iterations}} and {{actions}}.", + "started": "Started", + "duration": "Duration", + "stepsLabel": "Steps", + "latestEvent": "Latest event", + "successRate": "Success rate", + "successRateHint_one": "{{count}} successful iteration out of {{iterations}}", + "successRateHint_other": "{{count}} successful iterations out of {{iterations}}", + "iterations": "Iterations", + "totalRuntime": "{{duration}} total runtime", + "activeBackends": "Active backends", + "mostActive": "Most active {{backend}} · {{count}} events", + "noRecordedActivity": "No recorded tool activity", + "timelineEvents": "Timeline events", + "agentToolHint": "{{agentCount}} agent actions · {{toolCount}} tool events", + "noTimelineData": "No timeline data", + "noTimelineDesc": "This session does not yet contain trajectory or agent action records.", + "rawEventJson": "Raw event JSON", + "selection": "Selection", + "selectedSkills": "Selected skills", + "noSelectedSkills": "No selected skills", + "noSelectedSkillsDesc": "No skills were selected or recorded for this run.", + "session": "Session", + "overview": "Overview", + "taskId": "Task ID", + "runtime": "Runtime", + "window": "Window", + "ended": "Ended {{date}}", + "selectionMethod": "Selection method", + "notRecorded": "Not recorded", + "enabledBackends": "Enabled backends", + "backendActivity": "Backend activity", + "iteration_one": "{{count}} iteration", + "iteration_other": "{{count}} iterations", + "step_one": "{{count}} step", + "step_other": "{{count}} steps", + "agentAction_one": "{{count}} agent action", + "agentAction_other": "{{count}} agent actions", + "successfulIteration_one": "{{count}} successful iteration", + "successfulIteration_other": "{{count}} successful iterations" + }, + "drawer": { + "skillVersion": "Skill Version", + "openAsMain": "Open as main", + "versionSummary": "Version Summary", + "noDescriptionAvailable": "No description available for this version.", + "gen": "gen {{generation}}", + "versionScore": "version score", + "metrics": "Metrics", + "executionQuality": "Execution quality", + "effectiveRate": "Effective rate", + "completionRate": "Completion rate", + "appliedRate": "Applied rate", + "fallbackRate": "Fallback rate", + "selectionsLabel": "Selections", + "appliedLabel": "Applied", + "completionsLabel": "Completions", + "fallbacksLabel": "Fallbacks", + "versionMetadata": "Version Metadata", + "origin": "Origin:", + "generation": "Generation:", + "visibility": "Visibility:", + "created": "Created:", + "firstSeen": "First seen:", + "lastUpdated": "Last updated:", + "skillPath": "Skill path:", + "skillDir": "Skill dir:", + "parentIds": "Parent IDs:", + "changeSummary": "Change summary:", + "effectiveScore": "Effective score:", + "diff": "Diff", + "contentDiff": "Content diff", + "diffTooLarge": "Diff too large", + "diffTooLargeDesc": "This version has a very large content diff, so the inline viewer is disabled.", + "diffUnavailable": "Diff unavailable", + "diffUnavailableDesc": "This version has a diff payload, but it could not be parsed as a unified diff.", + "noContentDiff": "No content diff", + "noContentDiffDesc": "This version does not have a stored content diff.", + "source": "Source", + "skillMdPreview": "SKILL.md preview", + "sourceUnavailable": "Source unavailable", + "sourceUnavailableDesc": "This version points to a missing or unreadable SKILL.md path.", + "analyses": "Analyses", + "recentAnalyses": "Recent execution analyses", + "noExecutionNote": "No execution note", + "analysisCompleted": "completed: {{value}} · tool issues: {{toolIssues}} · suggestions: {{suggestions}}", + "noAnalysesYet": "No analyses yet", + "noAnalysesDesc": "Execution analyses will appear after recorded task runs are persisted into SQLite." + }, + "filter": { + "origin": "Origin:", + "allOrigins": "All Origins", + "tags": "Tags:", + "allTags": "All Tags" + }, + "graph": { + "noGraphData": "No lineage graph data.", + "tooltipScore": "score: {{value}}", + "tooltipGeneration": "generation: {{value}}", + "tooltipOrigin": "origin: {{value}}" + }, + "diffViewer": { + "noFiles": "No files in diff", + "old": "Old", + "new": "New" + }, + "format": { + "noInstruction": "No instruction captured" + } +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..d27eb6b --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,34 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import en from './en.json'; +import zh from './zh.json'; + +const STORAGE_KEY = 'openspace-lang'; + +function getSavedLanguage(): string { + try { + return localStorage.getItem(STORAGE_KEY) || 'en'; + } catch { + return 'en'; + } +} + +i18n.use(initReactI18next).init({ + resources: { + en: { translation: en }, + zh: { translation: zh }, + }, + lng: getSavedLanguage(), + fallbackLng: 'en', + interpolation: { escapeValue: false }, +}); + +i18n.on('languageChanged', (lng) => { + try { + localStorage.setItem(STORAGE_KEY, lng); + } catch { + // ignore + } +}); + +export default i18n; diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json new file mode 100644 index 0000000..ec66e22 --- /dev/null +++ b/frontend/src/i18n/zh.json @@ -0,0 +1,253 @@ +{ + "nav": { + "dashboard": "仪表盘", + "skills": "技能", + "workflows": "工作流" + }, + "common": { + "loading": "加载中…", + "yes": "是", + "no": "否", + "none": "无", + "unavailable": "不可用", + "unknown": "未知", + "close": "关闭", + "score": "评分", + "success": "成功率", + "active": "活跃", + "inactive": "未激活", + "noDescription": "暂无描述", + "steps_one": "{{count}} 步", + "steps_other": "{{count}} 步", + "agentActions_one": "{{count}} 个智能体操作", + "agentActions_other": "{{count}} 个智能体操作", + "tags": "+{{count}} 个标签" + }, + "langSwitch": { + "label": "语言" + }, + "errorBoundary": { + "title": "出现了错误", + "message": "发生了意外错误", + "goToDashboard": "返回仪表盘", + "details": "错误详情" + }, + "dashboard": { + "title": "仪表盘", + "loadingDashboard": "正在加载仪表盘…", + "failedToLoad": "加载概览失败", + "dashboardUnavailable": "仪表盘不可用", + "totalSkills": "技能总数", + "activeHint": "活跃:{{count}}", + "avgSkillScore": "平均技能评分", + "avgScoreHint": "主要指标 = 有效率 × 100", + "workflowSessions": "工作流会话", + "localRepo": "本地仓库", + "workspace": "工作区", + "recordedUnder": "记录于{{location}}", + "workflowSuccess": "工作流成功率", + "avgSuccessHint": "平均会话成功率", + "health": "健康状态", + "runtimeSnapshot": "运行时快照", + "status": "状态", + "dbPath": "数据库路径", + "workflowCount": "工作流数量", + "builtFrontend": "前端构建", + "skillsSection": "技能", + "topScoredSkills": "评分最高的技能", + "noSkillsYet": "暂无技能", + "noSkillsDesc": "先运行 OpenSpace 任务或将技能同步到本地注册表中。", + "effective": "有效率 {{value}}", + "applied": "应用率 {{value}}", + "selections": "选择次数 {{count}}", + "workflowsSection": "工作流", + "recentSessions": "近期会话", + "noWorkflowSessions": "暂无工作流会话", + "noWorkflowDesc": "启用录制后执行任务,录制数据将在此显示。" + }, + "skills": { + "title": "技能类别", + "searchPlaceholder": "按名称、ID、描述、标签或来源搜索", + "sortByScore": "按最佳评分排序", + "sortByUpdated": "按更新时间排序", + "sortByName": "按名称排序", + "skillClasses": "技能类别", + "versionsHint": "版本数:{{count}}", + "activeVersions": "活跃版本", + "withActivity": "有活动记录:{{count}}", + "avgBestScore": "平均最佳评分", + "bestNodeScoreHint": "每个类别的最佳节点评分", + "selections": "选择次数", + "completionsHint": "完成次数:{{count}}", + "loadingSkills": "正在加载技能…", + "failedToLoad": "加载技能失败", + "noSkillsMatch": "没有匹配的技能", + "noSkillsMatchDesc": "尝试其他关键词,或执行任务以将新的技能遥测数据写入 SQLite。", + "bestScore": "最佳评分", + "noClassDescription": "暂无类别描述", + "versions": "{{count}} 个版本", + "active": "{{count}} 个活跃", + "selectionsCount": "{{count}} 次选择" + }, + "skillDetail": { + "loadingDetail": "正在加载技能详情…", + "failedToLoad": "加载技能类别失败", + "skillNotFound": "未找到技能", + "backToSkills": "← 返回技能列表", + "anchoredOn": "技能类别锚定于 {{id}}", + "skillClass": "技能类别", + "evolutionOverview": "演化概览", + "activeTip": "活跃端点", + "inactiveAnchor": "未激活锚点", + "bestVersionScore": "最佳版本评分", + "skillDirectory": "技能目录", + "latestVersionCreated": "最新版本创建时间", + "representativeVersion": "代表版本", + "representativeUpdate": "代表版本更新时间", + "versions": "版本数", + "maxGeneration": "最大世代 {{count}}", + "activeVersions": "活跃版本", + "originsCount": "来源数:{{count}}", + "averageScore": "平均评分", + "acrossAllVersions": "该谱系中所有版本的平均值", + "selections": "选择次数", + "representativeScore": "代表评分 {{score}}", + "evolutionGraph": "演化图", + "versionLineage": "版本谱系", + "loadingDrawer": "正在加载版本抽屉…", + "noLineageGraph": "暂无谱系图", + "noLineageGraphDesc": "该技能尚无可供可视化的谱系数据。" + }, + "workflows": { + "title": "录制会话", + "searchPlaceholder": "按任务名称或指令搜索", + "workflowSessions": "工作流会话", + "scannedFrom": "从 logs/recordings 和 logs/trajectories 扫描", + "averageSuccess": "平均成功率", + "meanSuccessRate": "各会话的平均成功率", + "loadingWorkflows": "正在加载工作流…", + "failedToLoad": "加载工作流失败", + "noSessions": "暂无工作流会话", + "noSessionsDesc": "启用录制运行 `openspace` 后刷新此页面。", + "more": "+{{count}} 更多" + }, + "workflowDetail": { + "loadingDetail": "正在加载工作流详情…", + "failedToLoad": "加载工作流失败", + "workflowNotFound": "未找到工作流", + "backToWorkflows": "← 返回工作流列表", + "workflowDetail": "工作流详情", + "skillsSelected_one": "已选择 {{count}} 个技能", + "skillsSelected_other": "已选择 {{count}} 个技能", + "mergedEvent_one": "{{count}} 个合并事件", + "mergedEvent_other": "{{count}} 个合并事件", + "runDescription": "{{status}}运行,包含 {{iterations}} 和 {{actions}}。", + "started": "开始时间", + "duration": "持续时间", + "stepsLabel": "步骤", + "latestEvent": "最新事件", + "successRate": "成功率", + "successRateHint_one": "{{iterations}}中 {{count}} 次成功迭代", + "successRateHint_other": "{{iterations}}中 {{count}} 次成功迭代", + "iterations": "迭代次数", + "totalRuntime": "总运行时间 {{duration}}", + "activeBackends": "活跃后端", + "mostActive": "最活跃 {{backend}} · {{count}} 个事件", + "noRecordedActivity": "无已记录的工具活动", + "timelineEvents": "时间线事件", + "agentToolHint": "{{agentCount}} 个智能体操作 · {{toolCount}} 个工具事件", + "noTimelineData": "暂无时间线数据", + "noTimelineDesc": "此会话尚未包含轨迹或智能体操作记录。", + "rawEventJson": "原始事件 JSON", + "selection": "技能选择", + "selectedSkills": "已选技能", + "noSelectedSkills": "未选择技能", + "noSelectedSkillsDesc": "此次运行未选择或记录任何技能。", + "session": "会话", + "overview": "概览", + "taskId": "任务 ID", + "runtime": "运行时", + "window": "时间窗口", + "ended": "结束于 {{date}}", + "selectionMethod": "选择方法", + "notRecorded": "未记录", + "enabledBackends": "已启用后端", + "backendActivity": "后端活动", + "iteration_one": "{{count}} 次迭代", + "iteration_other": "{{count}} 次迭代", + "step_one": "{{count}} 步", + "step_other": "{{count}} 步", + "agentAction_one": "{{count}} 个智能体操作", + "agentAction_other": "{{count}} 个智能体操作", + "successfulIteration_one": "{{count}} 次成功迭代", + "successfulIteration_other": "{{count}} 次成功迭代" + }, + "drawer": { + "skillVersion": "技能版本", + "openAsMain": "作为主视图打开", + "versionSummary": "版本摘要", + "noDescriptionAvailable": "此版本暂无描述信息。", + "gen": "第 {{generation}} 代", + "versionScore": "版本评分", + "metrics": "指标", + "executionQuality": "执行质量", + "effectiveRate": "有效率", + "completionRate": "完成率", + "appliedRate": "应用率", + "fallbackRate": "回退率", + "selectionsLabel": "选择次数", + "appliedLabel": "应用次数", + "completionsLabel": "完成次数", + "fallbacksLabel": "回退次数", + "versionMetadata": "版本元数据", + "origin": "来源:", + "generation": "世代:", + "visibility": "可见性:", + "created": "创建时间:", + "firstSeen": "首次出现:", + "lastUpdated": "最后更新:", + "skillPath": "技能路径:", + "skillDir": "技能目录:", + "parentIds": "父级 ID:", + "changeSummary": "变更摘要:", + "effectiveScore": "有效评分:", + "diff": "差异", + "contentDiff": "内容差异", + "diffTooLarge": "差异过大", + "diffTooLargeDesc": "此版本的内容差异过大,内联查看器已禁用。", + "diffUnavailable": "差异不可用", + "diffUnavailableDesc": "此版本有差异数据,但无法解析为统一差异格式。", + "noContentDiff": "无内容差异", + "noContentDiffDesc": "此版本没有存储的内容差异。", + "source": "源代码", + "skillMdPreview": "SKILL.md 预览", + "sourceUnavailable": "源代码不可用", + "sourceUnavailableDesc": "此版本指向的 SKILL.md 路径缺失或不可读。", + "analyses": "分析", + "recentAnalyses": "近期执行分析", + "noExecutionNote": "无执行备注", + "analysisCompleted": "已完成:{{value}} · 工具问题:{{toolIssues}} · 建议:{{suggestions}}", + "noAnalysesYet": "暂无分析", + "noAnalysesDesc": "执行分析将在已记录的任务运行持久化到 SQLite 后显示。" + }, + "filter": { + "origin": "来源:", + "allOrigins": "所有来源", + "tags": "标签:", + "allTags": "所有标签" + }, + "graph": { + "noGraphData": "暂无谱系图数据。", + "tooltipScore": "评分:{{value}}", + "tooltipGeneration": "世代:{{value}}", + "tooltipOrigin": "来源:{{value}}" + }, + "diffViewer": { + "noFiles": "差异中无文件", + "old": "旧版", + "new": "新版" + }, + "format": { + "noInstruction": "未捕获到指令" + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5a34772..153d185 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -32,7 +32,7 @@ --radius-chip: 999px; --radius-card: 32px; --radius-card-sm: 18px; - --font-serif: 'Neuton', Georgia, serif; + --font-serif: 'Neuton', 'Noto Serif CJK SC', 'Songti SC', 'SimSun', Georgia, serif; --font-sans: 'Cabin', system-ui, -apple-system, sans-serif; --font-mono: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', 'Courier New', monospace; --shadow-hard: 4px 4px 0px 0px rgba(74, 59, 42, 0.1); diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index c72e8f9..523b231 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -1,4 +1,5 @@ import { NavLink, Outlet } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; const linkClass = ({ isActive }: { isActive: boolean }) => isActive @@ -6,6 +7,12 @@ const linkClass = ({ isActive }: { isActive: boolean }) => : 'hover:text-primary'; export default function MainLayout() { + const { t, i18n } = useTranslation(); + + const toggleLang = () => { + i18n.changeLanguage(i18n.language === 'zh' ? 'en' : 'zh'); + }; + return (
-
API: `localhost:7788` · Vite: `localhost:3888`
+
+ +
API: `localhost:7788` · Vite: `localhost:3888`
+
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e0becf6..fb77ecd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import './i18n'; import './index.css'; import App from './App'; import { ErrorBoundary } from './components/ErrorBoundary'; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 165e47c..6e98cd4 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,11 +1,13 @@ import { Link } from 'react-router-dom'; import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { overviewApi, type OverviewResponse } from '../api'; import MetricCard from '../components/MetricCard'; import EmptyState from '../components/EmptyState'; import { formatDate, formatInstruction, formatPercent, truncate } from '../utils/format'; export default function DashboardPage() { + const { t } = useTranslation(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -22,7 +24,7 @@ export default function DashboardPage() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load overview'); + setError(err instanceof Error ? err.message : t('dashboard.failedToLoad')); } } finally { if (!cancelled) { @@ -34,37 +36,37 @@ export default function DashboardPage() { return () => { cancelled = true; }; - }, []); + }, [t]); if (loading) { - return
Loading dashboard…
; + return
{t('dashboard.loadingDashboard')}
; } if (error || !data) { - return
{error ?? 'Dashboard unavailable'}
; + return
{error ?? t('dashboard.dashboardUnavailable')}
; } return (
-

Dashboard

+

{t('dashboard.title')}

- - - - + + + +
-
Health
-

Runtime snapshot

+
{t('dashboard.health')}
+

{t('dashboard.runtimeSnapshot')}

-
Status{data.health.status}
-
DB Path{data.health.db_path}
-
Workflow Count{data.health.workflow_count}
-
Built Frontend{data.health.frontend_dist_exists ? 'yes' : 'no'}
+
{t('dashboard.status')}{data.health.status}
+
{t('dashboard.dbPath')}{data.health.db_path}
+
{t('dashboard.workflowCount')}{data.health.workflow_count}
+
{t('dashboard.builtFrontend')}{data.health.frontend_dist_exists ? t('common.yes') : t('common.no')}
@@ -72,11 +74,11 @@ export default function DashboardPage() {
-
Skills
-

Top scored skills

+
{t('dashboard.skillsSection')}
+

{t('dashboard.topScoredSkills')}

{data.skills.top.length === 0 ? ( - + ) : (
{data.skills.top.map((skill) => ( @@ -84,17 +86,17 @@ export default function DashboardPage() {
{skill.name}
-
{truncate(skill.description || 'No description', 110)}
+
{truncate(skill.description || t('common.noDescription'), 110)}
{skill.score.toFixed(1)}
-
score
+
{t('common.score')}
- effective {formatPercent(skill.effective_rate)} - applied {formatPercent(skill.applied_rate)} - selections {skill.total_selections} + {t('dashboard.effective', { value: formatPercent(skill.effective_rate) })} + {t('dashboard.applied', { value: formatPercent(skill.applied_rate) })} + {t('dashboard.selections', { count: skill.total_selections })}
))} @@ -104,11 +106,11 @@ export default function DashboardPage() {
-
Workflows
-

Recent sessions

+
{t('dashboard.workflowsSection')}
+

{t('dashboard.recentSessions')}

{data.workflows.recent.length === 0 ? ( - + ) : (
{data.workflows.recent.map((workflow) => ( @@ -116,16 +118,16 @@ export default function DashboardPage() {
{workflow.task_name}
-
{formatInstruction(workflow.instruction, 160)}
+
{formatInstruction(workflow.instruction, 160, t('format.noInstruction'))}
{(workflow.success_rate * 100).toFixed(1)}%
-
success
+
{t('common.success')}
- {workflow.total_steps} steps - {workflow.agent_action_count} agent actions + {t('common.steps', { count: workflow.total_steps })} + {t('common.agentActions', { count: workflow.agent_action_count })} {formatDate(workflow.start_time)}
diff --git a/frontend/src/pages/SkillDetailPage.tsx b/frontend/src/pages/SkillDetailPage.tsx index 8b20ea5..c2de04e 100644 --- a/frontend/src/pages/SkillDetailPage.tsx +++ b/frontend/src/pages/SkillDetailPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { skillsApi, type SkillDetail, type SkillLineage } from '../api'; import EmptyState from '../components/EmptyState'; import MetricCard from '../components/MetricCard'; @@ -26,6 +27,7 @@ function resolveLineageGraph(skill: SkillDetail | null): SkillLineage | null { const DRAWER_ANIMATION_DURATION_MS = 300; export default function SkillDetailPage() { + const { t } = useTranslation(); const { skillId = '' } = useParams(); const [searchParams, setSearchParams] = useSearchParams(); const [skillClass, setSkillClass] = useState(null); @@ -52,7 +54,7 @@ export default function SkillDetailPage() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load skill class'); + setError(err instanceof Error ? err.message : t('skillDetail.failedToLoad')); } } finally { if (!cancelled) { @@ -68,7 +70,7 @@ export default function SkillDetailPage() { return () => { cancelled = true; }; - }, [skillId]); + }, [skillId, t]); const lineageGraph = useMemo(() => resolveLineageGraph(skillClass), [skillClass]); @@ -97,7 +99,7 @@ export default function SkillDetailPage() { } catch (err) { if (!cancelled) { setSelectedVersion(null); - setDrawerError(err instanceof Error ? err.message : 'Failed to load selected version'); + setDrawerError(err instanceof Error ? err.message : t('skillDetail.failedToLoad')); } } finally { if (!cancelled) { @@ -110,7 +112,7 @@ export default function SkillDetailPage() { return () => { cancelled = true; }; - }, [selectedVersionId, skillClass]); + }, [selectedVersionId, skillClass, t]); useEffect(() => { if (selectedVersion) { @@ -215,20 +217,20 @@ export default function SkillDetailPage() { }; if (loading) { - return
Loading skill detail…
; + return
{t('skillDetail.loadingDetail')}
; } if (error || !skillClass) { - return
{error ?? 'Skill not found'}
; + return
{error ?? t('skillDetail.skillNotFound')}
; } return (
- ← Back to Skills + {t('skillDetail.backToSkills')}

{skillClass.name}

-
Skill class anchored on {skillClass.skill_id}
+
{t('skillDetail.anchoredOn', { id: skillClass.skill_id })}
@@ -236,13 +238,13 @@ export default function SkillDetailPage() {
-
Skill Class
-

Evolution overview

+
{t('skillDetail.skillClass')}
+

{t('skillDetail.evolutionOverview')}

{skillClass.category} {skillClass.visibility} - {skillClass.is_active ? 'active tip' : 'inactive anchor'} + {skillClass.is_active ? t('skillDetail.activeTip') : t('skillDetail.inactiveAnchor')} {classSummary.origins.map((origin) => ( {origin} ))} @@ -250,48 +252,48 @@ export default function SkillDetailPage() { {tag} ))} {classSummary.tags.length > 8 ? ( - +{classSummary.tags.length - 8} tags + {t('common.tags', { count: classSummary.tags.length - 8 })} ) : null}
{classSummary.bestScore.toFixed(1)}
-
best version score
+
{t('skillDetail.bestVersionScore')}
-
Skill directory
-
{skillClass.skill_dir || 'Unavailable'}
+
{t('skillDetail.skillDirectory')}
+
{skillClass.skill_dir || t('common.unavailable')}
-
Latest version created
+
{t('skillDetail.latestVersionCreated')}
{formatDate(classSummary.latestCreatedAt)}
-
Representative version
+
{t('skillDetail.representativeVersion')}
{skillClass.skill_id}
-
Representative update
+
{t('skillDetail.representativeUpdate')}
{formatDate(skillClass.last_updated)}
- - - - + + + +
-
Evolution Graph
-

Version lineage

+
{t('skillDetail.evolutionGraph')}
+

{t('skillDetail.versionLineage')}

{drawerLoading ? ( -
Loading version drawer…
+
{t('skillDetail.loadingDrawer')}
) : null} {drawerError ? (
{drawerError}
@@ -317,7 +319,7 @@ export default function SkillDetailPage() {
{lineageGraph && lineageGraph.nodes.length === 0 ? ( - + ) : null} diff --git a/frontend/src/pages/SkillsPage.tsx b/frontend/src/pages/SkillsPage.tsx index 72cd637..5686773 100644 --- a/frontend/src/pages/SkillsPage.tsx +++ b/frontend/src/pages/SkillsPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { skillsApi, type Skill, type SkillStats } from '../api'; import EmptyState from '../components/EmptyState'; import MetricCard from '../components/MetricCard'; @@ -7,6 +8,7 @@ import { formatDate, truncate } from '../utils/format'; import { buildSkillClasses } from '../utils/skillClasses'; export default function SkillsPage() { + const { t } = useTranslation(); const [skills, setSkills] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); @@ -30,7 +32,7 @@ export default function SkillsPage() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load skills'); + setError(err instanceof Error ? err.message : t('skills.failedToLoad')); } } finally { if (!cancelled) { @@ -42,7 +44,7 @@ export default function SkillsPage() { return () => { cancelled = true; }; - }, [sort]); + }, [sort, t]); const skillClasses = useMemo(() => buildSkillClasses(skills), [skills]); @@ -89,37 +91,37 @@ export default function SkillsPage() {
-

Skill classes

+

{t('skills.title')}

setQuery(event.target.value)} - placeholder="Search by name, id, description, tag, or origin" + placeholder={t('skills.searchPlaceholder')} className="px-3 py-2 min-w-[320px]" />
{stats ? (
- - - - + + + +
) : null} - {loading ?
Loading skills…
: null} + {loading ?
{t('skills.loadingSkills')}
: null} {error ?
{error}
: null} {!loading && !error && filteredClasses.length === 0 ? ( - + ) : null} {!loading && !error && filteredClasses.length > 0 ? ( @@ -137,18 +139,18 @@ export default function SkillsPage() {
{skillClass.best_score.toFixed(1)}
-
best score
+
{t('skills.bestScore')}
- {truncate(skillClass.representative.description || 'No class description', 160)} + {truncate(skillClass.representative.description || t('skills.noClassDescription'), 160)}
-
{skillClass.version_count} versions
-
{skillClass.active_count} active
-
{skillClass.total_selections} selections
+
{t('skills.versions', { count: skillClass.version_count })}
+
{t('skills.active', { count: skillClass.active_count })}
+
{t('skills.selectionsCount', { count: skillClass.total_selections })}
{formatDate(skillClass.latest_updated)}
@@ -160,7 +162,7 @@ export default function SkillsPage() { {tag} ))} {skillClass.tags.length > 5 ? ( - +{skillClass.tags.length - 5} tags + {t('common.tags', { count: skillClass.tags.length - 5 })} ) : null}
diff --git a/frontend/src/pages/WorkflowDetailPage.tsx b/frontend/src/pages/WorkflowDetailPage.tsx index fdb36a7..ae8270e 100644 --- a/frontend/src/pages/WorkflowDetailPage.tsx +++ b/frontend/src/pages/WorkflowDetailPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { Link, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { workflowsApi, type WorkflowDetail, type WorkflowTimelineEvent } from '../api'; import { formatDate, formatInstruction } from '../utils/format'; @@ -48,10 +49,6 @@ function formatPercent(value: number): string { return `${(value * 100).toFixed(1)}%`; } -function pluralize(value: number, singular: string, plural = `${singular}s`): string { - return `${value} ${value === 1 ? singular : plural}`; -} - function getString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value : null; } @@ -367,6 +364,7 @@ function describeTimelineEvent(event: WorkflowTimelineEvent): TimelinePresentati } export default function WorkflowDetailPage() { + const { t } = useTranslation(); const { workflowId = '' } = useParams(); const [workflow, setWorkflow] = useState(null); const [loading, setLoading] = useState(true); @@ -385,7 +383,7 @@ export default function WorkflowDetailPage() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load workflow'); + setError(err instanceof Error ? err.message : t('workflowDetail.failedToLoad')); } } finally { if (!cancelled) { @@ -399,7 +397,7 @@ export default function WorkflowDetailPage() { return () => { cancelled = true; }; - }, [workflowId]); + }, [workflowId, t]); const timeline = useMemo(() => { const events = workflow?.timeline ?? []; @@ -464,7 +462,7 @@ export default function WorkflowDetailPage() { return (
-
Loading workflow detail…
+
{t('workflowDetail.loadingDetail')}
); @@ -474,7 +472,7 @@ export default function WorkflowDetailPage() { return (
-
{error ?? 'Workflow not found'}
+
{error ?? t('workflowDetail.workflowNotFound')}
); @@ -487,16 +485,16 @@ export default function WorkflowDetailPage() { const skillSelection = isRecord(metadata.skill_selection) ? metadata.skill_selection : null; const selectionMethod = skillSelection && typeof skillSelection.method === 'string' ? skillSelection.method : null; const executionDurationLabel = formatDurationSeconds(workflow.execution_time); - const selectedSkillLabel = `${pluralize(workflow.selected_skills.length, 'skill')} selected`; - const iterationsLabel = pluralize(workflow.iterations, 'iteration'); - const totalStepLabel = pluralize(workflow.total_steps, 'step'); - const actionCountLabel = pluralize(workflow.agent_action_count, 'agent action'); + const selectedSkillLabel = t('workflowDetail.skillsSelected', { count: workflow.selected_skills.length }); + const iterationsLabel = t('workflowDetail.iteration', { count: workflow.iterations }); + const totalStepLabel = t('workflowDetail.step', { count: workflow.total_steps }); + const actionCountLabel = t('workflowDetail.agentAction', { count: workflow.agent_action_count }); const agentActionCount = timelineSummary.byType.agent_action ?? 0; const toolExecutionCount = timelineSummary.byType.tool_execution ?? 0; const latestEventLabel = timelineSummary.lastTimestamp ? formatTimeLabel(timelineSummary.lastTimestamp) : '—'; - const timelineEventLabel = pluralize(timelineSummary.total, 'merged event'); + const timelineEventLabel = t('workflowDetail.mergedEvent', { count: timelineSummary.total }); const statusLabel = humanizeToken(workflow.status || 'unknown'); - const selectionMethodLabel = selectionMethod ? humanizeToken(selectionMethod) : 'Not recorded'; + const selectionMethodLabel = selectionMethod ? humanizeToken(selectionMethod) : t('workflowDetail.notRecorded'); const successRateLabel = formatPercent(workflow.success_rate); return ( @@ -510,7 +508,7 @@ export default function WorkflowDetailPage() { to="/workflows" className="workflow-chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink" > - ← Back to Workflows + {t('workflowDetail.backToWorkflows')} {statusLabel} {selectedSkillLabel} @@ -518,36 +516,36 @@ export default function WorkflowDetailPage() {
-
Workflow detail
+
{t('workflowDetail.workflowDetail')}

{workflow.task_name}

- {formatInstruction(workflow.instruction, 480)} + {formatInstruction(workflow.instruction, 480, t('format.noInstruction'))}

- {`${statusLabel} run with ${iterationsLabel} and ${actionCountLabel}.`} + {t('workflowDetail.runDescription', { status: statusLabel, iterations: iterationsLabel, actions: actionCountLabel })}

-
Started
+
{t('workflowDetail.started')}
{formatDate(workflow.start_time)}
-
Duration
+
{t('workflowDetail.duration')}
{executionDurationLabel}
-
Steps
+
{t('workflowDetail.stepsLabel')}
{totalStepLabel}
-
Latest event
+
{t('workflowDetail.latestEvent')}
{latestEventLabel}
@@ -556,24 +554,24 @@ export default function WorkflowDetailPage() {
@@ -582,8 +580,8 @@ export default function WorkflowDetailPage() {
{timeline.length === 0 ? ( ) : (
@@ -677,7 +675,7 @@ export default function WorkflowDetailPage() { ) : null}
-
Raw event JSON
+
{t('workflowDetail.rawEventJson')}
                                   {stringify(event.details)}
                                 
@@ -697,8 +695,8 @@ export default function WorkflowDetailPage() {
-
Selection
-

Selected skills

+
{t('workflowDetail.selection')}
+

{t('workflowDetail.selectedSkills')}

{workflow.selected_skills.length > 0 ? {workflow.selected_skills.length} : null}
@@ -718,42 +716,42 @@ export default function WorkflowDetailPage() {
) : (
-
No selected skills
-

No skills were selected or recorded for this run.

+
{t('workflowDetail.noSelectedSkills')}
+

{t('workflowDetail.noSelectedSkillsDesc')}

)}
-
Session
-

Overview

+
{t('workflowDetail.session')}
+

{t('workflowDetail.overview')}

- +
{workflow.task_id}
- +
{executionDurationLabel}
{iterationsLabel} · {totalStepLabel} · {actionCountLabel}
- +
{formatDate(workflow.start_time)}
-
Ended {formatDate(workflow.end_time)}
+
{t('workflowDetail.ended', { date: formatDate(workflow.end_time) })}
- +
{selectionMethodLabel}
{selectedSkillLabel}
{enabledBackends.length > 0 ? ( - +
{enabledBackends.map((backend) => ( {humanizeToken(backend)} @@ -763,7 +761,7 @@ export default function WorkflowDetailPage() { ) : null} {activityEntries.length > 0 ? ( - +
{activityEntries.map(([backend, count]) => ( {`${humanizeToken(backend)} ${count}`} diff --git a/frontend/src/pages/WorkflowsPage.tsx b/frontend/src/pages/WorkflowsPage.tsx index 7c04aa1..e5a16ca 100644 --- a/frontend/src/pages/WorkflowsPage.tsx +++ b/frontend/src/pages/WorkflowsPage.tsx @@ -1,10 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { workflowsApi, type WorkflowSummary } from '../api'; import EmptyState from '../components/EmptyState'; import { formatDate, formatInstruction } from '../utils/format'; export default function WorkflowsPage() { + const { t } = useTranslation(); const [workflows, setWorkflows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -22,7 +24,7 @@ export default function WorkflowsPage() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load workflows'); + setError(err instanceof Error ? err.message : t('workflows.failedToLoad')); } } finally { if (!cancelled) { @@ -34,7 +36,7 @@ export default function WorkflowsPage() { return () => { cancelled = true; }; - }, []); + }, [t]); const filtered = useMemo(() => { const normalized = query.trim().toLowerCase(); @@ -56,34 +58,34 @@ export default function WorkflowsPage() {
-

Recorded sessions

+

{t('workflows.title')}

setQuery(event.target.value)} - placeholder="Search by task name or instruction" + placeholder={t('workflows.searchPlaceholder')} className="px-3 py-2 min-w-[320px]" />
-
Workflow Sessions
+
{t('workflows.workflowSessions')}
{workflows.length}
-
Scanned from logs/recordings and logs/trajectories
+
{t('workflows.scannedFrom')}
-
Average Success
+
{t('workflows.averageSuccess')}
{averageSuccess}%
-
Mean success rate across sessions
+
{t('workflows.meanSuccessRate')}
- {loading ?
Loading workflows…
: null} + {loading ?
{t('workflows.loadingWorkflows')}
: null} {error ?
{error}
: null} {!loading && !error && filtered.length === 0 ? ( - + ) : null} {!loading && !error && filtered.length > 0 ? ( @@ -96,13 +98,13 @@ export default function WorkflowsPage() {
{(workflow.success_rate * 100).toFixed(1)}%
-
success
+
{t('common.success')}
-
{formatInstruction(workflow.instruction, 220)}
+
{formatInstruction(workflow.instruction, 220, t('format.noInstruction'))}
-
{workflow.total_steps} steps
-
{workflow.agent_action_count} agent actions
+
{t('common.steps', { count: workflow.total_steps })}
+
{t('common.agentActions', { count: workflow.agent_action_count })}
{formatDate(workflow.start_time)}
{workflow.selected_skills.length > 0 ? ( @@ -114,7 +116,7 @@ export default function WorkflowsPage() { ))} {workflow.selected_skills.length > 3 ? ( - +{workflow.selected_skills.length - 3} more + {t('workflows.more', { count: workflow.selected_skills.length - 3 })} ) : null}
diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index ba71a98..5d68acf 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -56,8 +56,9 @@ function shortenPaths(text: string, keep = 3): string { export function formatInstruction( raw: string | null | undefined, maxLen?: number, + fallback = 'No instruction captured', ): string { - if (!raw) return 'No instruction captured'; + if (!raw) return fallback; let text = shortenPaths(raw); From c15229a82a18608e13d69e489629858850c74e5b Mon Sep 17 00:00:00 2001 From: Xu Lingrui <151314054+xlrrrr@users.noreply.github.com> Date: Fri, 3 Apr 2026 23:09:53 +0800 Subject: [PATCH 29/45] fix(frontend): keep product proper nouns in English for zh locale (#50) --- frontend/src/i18n/zh.json | 94 +++++++++++++++++++-------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json index ec66e22..7f023c4 100644 --- a/frontend/src/i18n/zh.json +++ b/frontend/src/i18n/zh.json @@ -1,7 +1,7 @@ { "nav": { "dashboard": "仪表盘", - "skills": "技能", + "skills": "Skills", "workflows": "工作流" }, "common": { @@ -19,8 +19,8 @@ "noDescription": "暂无描述", "steps_one": "{{count}} 步", "steps_other": "{{count}} 步", - "agentActions_one": "{{count}} 个智能体操作", - "agentActions_other": "{{count}} 个智能体操作", + "agentActions_one": "{{count}} 个 Agent 操作", + "agentActions_other": "{{count}} 个 Agent 操作", "tags": "+{{count}} 个标签" }, "langSwitch": { @@ -37,9 +37,9 @@ "loadingDashboard": "正在加载仪表盘…", "failedToLoad": "加载概览失败", "dashboardUnavailable": "仪表盘不可用", - "totalSkills": "技能总数", + "totalSkills": "Skills 总数", "activeHint": "活跃:{{count}}", - "avgSkillScore": "平均技能评分", + "avgSkillScore": "Skill 平均评分", "avgScoreHint": "主要指标 = 有效率 × 100", "workflowSessions": "工作流会话", "localRepo": "本地仓库", @@ -53,10 +53,10 @@ "dbPath": "数据库路径", "workflowCount": "工作流数量", "builtFrontend": "前端构建", - "skillsSection": "技能", - "topScoredSkills": "评分最高的技能", - "noSkillsYet": "暂无技能", - "noSkillsDesc": "先运行 OpenSpace 任务或将技能同步到本地注册表中。", + "skillsSection": "Skills", + "topScoredSkills": "评分最高的 Skills", + "noSkillsYet": "暂无 Skills", + "noSkillsDesc": "先运行 OpenSpace 任务或将 Skills 同步到本地注册表中。", "effective": "有效率 {{value}}", "applied": "应用率 {{value}}", "selections": "选择次数 {{count}}", @@ -66,12 +66,12 @@ "noWorkflowDesc": "启用录制后执行任务,录制数据将在此显示。" }, "skills": { - "title": "技能类别", + "title": "Skill 类别", "searchPlaceholder": "按名称、ID、描述、标签或来源搜索", "sortByScore": "按最佳评分排序", "sortByUpdated": "按更新时间排序", "sortByName": "按名称排序", - "skillClasses": "技能类别", + "skillClasses": "Skill 类别", "versionsHint": "版本数:{{count}}", "activeVersions": "活跃版本", "withActivity": "有活动记录:{{count}}", @@ -79,10 +79,10 @@ "bestNodeScoreHint": "每个类别的最佳节点评分", "selections": "选择次数", "completionsHint": "完成次数:{{count}}", - "loadingSkills": "正在加载技能…", - "failedToLoad": "加载技能失败", - "noSkillsMatch": "没有匹配的技能", - "noSkillsMatchDesc": "尝试其他关键词,或执行任务以将新的技能遥测数据写入 SQLite。", + "loadingSkills": "正在加载 Skills…", + "failedToLoad": "加载 Skills 失败", + "noSkillsMatch": "没有匹配的 Skills", + "noSkillsMatchDesc": "尝试其他关键词,或执行任务以将新的 Skill 遥测数据写入 SQLite。", "bestScore": "最佳评分", "noClassDescription": "暂无类别描述", "versions": "{{count}} 个版本", @@ -90,17 +90,17 @@ "selectionsCount": "{{count}} 次选择" }, "skillDetail": { - "loadingDetail": "正在加载技能详情…", - "failedToLoad": "加载技能类别失败", - "skillNotFound": "未找到技能", - "backToSkills": "← 返回技能列表", - "anchoredOn": "技能类别锚定于 {{id}}", - "skillClass": "技能类别", + "loadingDetail": "正在加载 Skill 详情…", + "failedToLoad": "加载 Skill 类别失败", + "skillNotFound": "未找到 Skill", + "backToSkills": "← 返回 Skills", + "anchoredOn": "Skill 类别锚定于 {{id}}", + "skillClass": "Skill 类别", "evolutionOverview": "演化概览", "activeTip": "活跃端点", "inactiveAnchor": "未激活锚点", "bestVersionScore": "最佳版本评分", - "skillDirectory": "技能目录", + "skillDirectory": "Skill 目录", "latestVersionCreated": "最新版本创建时间", "representativeVersion": "代表版本", "representativeUpdate": "代表版本更新时间", @@ -116,7 +116,7 @@ "versionLineage": "版本谱系", "loadingDrawer": "正在加载版本抽屉…", "noLineageGraph": "暂无谱系图", - "noLineageGraphDesc": "该技能尚无可供可视化的谱系数据。" + "noLineageGraphDesc": "该 Skill 尚无可供可视化的谱系数据。" }, "workflows": { "title": "录制会话", @@ -137,8 +137,8 @@ "workflowNotFound": "未找到工作流", "backToWorkflows": "← 返回工作流列表", "workflowDetail": "工作流详情", - "skillsSelected_one": "已选择 {{count}} 个技能", - "skillsSelected_other": "已选择 {{count}} 个技能", + "skillsSelected_one": "已选择 {{count}} 个 Skill", + "skillsSelected_other": "已选择 {{count}} 个 Skills", "mergedEvent_one": "{{count}} 个合并事件", "mergedEvent_other": "{{count}} 个合并事件", "runDescription": "{{status}}运行,包含 {{iterations}} 和 {{actions}}。", @@ -155,14 +155,14 @@ "mostActive": "最活跃 {{backend}} · {{count}} 个事件", "noRecordedActivity": "无已记录的工具活动", "timelineEvents": "时间线事件", - "agentToolHint": "{{agentCount}} 个智能体操作 · {{toolCount}} 个工具事件", + "agentToolHint": "{{agentCount}} 个 Agent 操作 · {{toolCount}} 个工具事件", "noTimelineData": "暂无时间线数据", - "noTimelineDesc": "此会话尚未包含轨迹或智能体操作记录。", + "noTimelineDesc": "此会话尚未包含轨迹或 Agent 操作记录。", "rawEventJson": "原始事件 JSON", - "selection": "技能选择", - "selectedSkills": "已选技能", - "noSelectedSkills": "未选择技能", - "noSelectedSkillsDesc": "此次运行未选择或记录任何技能。", + "selection": "Skill 选择", + "selectedSkills": "已选 Skills", + "noSelectedSkills": "未选择 Skills", + "noSelectedSkillsDesc": "此次运行未选择或记录任何 Skills。", "session": "会话", "overview": "概览", "taskId": "任务 ID", @@ -177,13 +177,13 @@ "iteration_other": "{{count}} 次迭代", "step_one": "{{count}} 步", "step_other": "{{count}} 步", - "agentAction_one": "{{count}} 个智能体操作", - "agentAction_other": "{{count}} 个智能体操作", + "agentAction_one": "{{count}} 个 Agent 操作", + "agentAction_other": "{{count}} 个 Agent 操作", "successfulIteration_one": "{{count}} 次成功迭代", "successfulIteration_other": "{{count}} 次成功迭代" }, "drawer": { - "skillVersion": "技能版本", + "skillVersion": "Skill 版本", "openAsMain": "作为主视图打开", "versionSummary": "版本摘要", "noDescriptionAvailable": "此版本暂无描述信息。", @@ -206,19 +206,19 @@ "created": "创建时间:", "firstSeen": "首次出现:", "lastUpdated": "最后更新:", - "skillPath": "技能路径:", - "skillDir": "技能目录:", + "skillPath": "Skill 路径:", + "skillDir": "Skill 目录:", "parentIds": "父级 ID:", "changeSummary": "变更摘要:", "effectiveScore": "有效评分:", - "diff": "差异", - "contentDiff": "内容差异", - "diffTooLarge": "差异过大", - "diffTooLargeDesc": "此版本的内容差异过大,内联查看器已禁用。", - "diffUnavailable": "差异不可用", - "diffUnavailableDesc": "此版本有差异数据,但无法解析为统一差异格式。", - "noContentDiff": "无内容差异", - "noContentDiffDesc": "此版本没有存储的内容差异。", + "diff": "Diff", + "contentDiff": "内容 Diff", + "diffTooLarge": "Diff 过大", + "diffTooLargeDesc": "此版本的内容 Diff 过大,内联查看器已禁用。", + "diffUnavailable": "Diff 不可用", + "diffUnavailableDesc": "此版本有 Diff 数据,但无法解析为统一 Diff 格式。", + "noContentDiff": "无内容 Diff", + "noContentDiffDesc": "此版本没有存储的内容 Diff。", "source": "源代码", "skillMdPreview": "SKILL.md 预览", "sourceUnavailable": "源代码不可用", @@ -239,11 +239,11 @@ "graph": { "noGraphData": "暂无谱系图数据。", "tooltipScore": "评分:{{value}}", - "tooltipGeneration": "世代:{{value}}", - "tooltipOrigin": "来源:{{value}}" + "tooltipGeneration": "Generation:{{value}}", + "tooltipOrigin": "Origin:{{value}}" }, "diffViewer": { - "noFiles": "差异中无文件", + "noFiles": "Diff 中无文件", "old": "旧版", "new": "新版" }, From 171c1b76f8a9f9715523a9d2d796e003552f06bb Mon Sep 17 00:00:00 2001 From: "xzq.xu" Date: Thu, 26 Mar 2026 13:55:24 +0800 Subject: [PATCH 30/45] fix: replace ErrorCode enum calls with GroundingError raises ErrorCode is a str Enum whose members are not callable. Calling `raise ErrorCode.SESSION_NOT_FOUND(name)` produces a TypeError instead of the intended session-not-found error. Replace all 5 occurrences with `raise GroundingError(..., code=ErrorCode.SESSION_NOT_FOUND)`. Closes #11 Made-with: Cursor --- openspace/grounding/core/grounding_client.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openspace/grounding/core/grounding_client.py b/openspace/grounding/core/grounding_client.py index 4f97a7f..ad6c51c 100644 --- a/openspace/grounding/core/grounding_client.py +++ b/openspace/grounding/core/grounding_client.py @@ -337,13 +337,13 @@ class GroundingClient: def get_session_info(self, name: str) -> SessionInfo: """Get session monitoring info""" if name not in self._session_info: - raise ErrorCode.SESSION_NOT_FOUND(name) + raise GroundingError(f"Session not found: {name}", code=ErrorCode.SESSION_NOT_FOUND) return self._session_info[name] def get_session(self, name: str) -> BaseSession: """Get session""" if name not in self._sessions: - raise ErrorCode.SESSION_NOT_FOUND(name) + raise GroundingError(f"Session not found: {name}", code=ErrorCode.SESSION_NOT_FOUND) return self._sessions[name] @@ -479,7 +479,7 @@ class GroundingClient: # Session-level if session_name: if session_name not in self._sessions: - raise ErrorCode.SESSION_NOT_FOUND(session_name) + raise GroundingError(f"Session not found: {session_name}", code=ErrorCode.SESSION_NOT_FOUND) backend_type = self._session_info[session_name].backend_type return await self._fetch_tools( backend_type, @@ -531,7 +531,7 @@ class GroundingClient: use_cache: bool = False ) -> list[BaseTool]: if session_name not in self._session_info: - raise ErrorCode.SESSION_NOT_FOUND(session_name) + raise GroundingError(f"Session not found: {session_name}", code=ErrorCode.SESSION_NOT_FOUND) backend = self._session_info[session_name].backend_type return await self.list_tools(backend, session_name, use_cache) @@ -838,7 +838,7 @@ class GroundingClient: runtime_backend = backend else: if runtime_session not in self._session_info: - raise ErrorCode.SESSION_NOT_FOUND(runtime_session) + raise GroundingError(f"Session not found: {runtime_session}", code=ErrorCode.SESSION_NOT_FOUND) runtime_backend = self._session_info[ runtime_session ].backend_type From 6f581f6de4067e3b5cebd73713e904909279fc19 Mon Sep 17 00:00:00 2001 From: "xzq.xu" Date: Thu, 26 Mar 2026 13:57:03 +0800 Subject: [PATCH 31/45] fix: add missing Logger.set_level() method __main__.py calls Logger.set_level(args.log_level) when --log-level is passed, but the method did not exist on Logger, causing an AttributeError. Add set_level(level: str) that resolves the name to a logging constant and reconfigures via configure(force=True). Closes #13 Made-with: Cursor --- openspace/utils/logging.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openspace/utils/logging.py b/openspace/utils/logging.py index fabb5b1..ce6a3b8 100644 --- a/openspace/utils/logging.py +++ b/openspace/utils/logging.py @@ -233,6 +233,14 @@ class Logger: cls._configured = True + @classmethod + def set_level(cls, level: str) -> None: + """Set log level by name (e.g. ``"DEBUG"``, ``"INFO"``, ``"WARNING"``).""" + resolved = getattr(logging, level.upper(), None) + if resolved is None or not isinstance(resolved, int): + raise ValueError(f"Unknown log level: {level!r}") + cls.configure(level=resolved, force=True) + @classmethod def set_debug(cls, debug_level: int = 2) -> None: """Dynamically switch debug level: 0 = WARNING, 1 = INFO, 2 = DEBUG.""" From f89ea89ffb1feff081a8b7725947b160079481fc Mon Sep 17 00:00:00 2001 From: "xzq.xu" Date: Thu, 26 Mar 2026 13:58:10 +0800 Subject: [PATCH 32/45] fix: use resolved tool_obj for fallback tool execution When the LLM returns a short tool name that doesn't match the deduped key in tool_map, the fallback scan correctly resolves tool_obj via schema.name. However the execution branch still checked `tool_name not in tool_map` and passed `tool_map[tool_name]`, so fallback-resolved tools were never executed. Change the condition to check `tool_obj is None` and pass `tool_obj` directly to _execute_tool_call. Closes #15 Made-with: Cursor --- openspace/llm/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openspace/llm/client.py b/openspace/llm/client.py index cc14c64..ded3ab8 100644 --- a/openspace/llm/client.py +++ b/openspace/llm/client.py @@ -754,7 +754,7 @@ class LLMClient: except: pass - if tool_name not in tool_map: + if tool_obj is None: result = ToolResult( status=ToolStatus.ERROR, error=f"Tool '{tool_name}' not found" @@ -762,7 +762,7 @@ class LLMClient: else: try: result = await _execute_tool_call( - tool=tool_map[tool_name], + tool=tool_obj, openai_tool_call={ "id": tool_call.id, "type": "function", From af1eb5bbe68abb6b17922f069dce24383bab9e97 Mon Sep 17 00:00:00 2001 From: "xzq.xu" Date: Thu, 26 Mar 2026 14:01:03 +0800 Subject: [PATCH 33/45] fix(security): stop leaking Python tracebacks to MCP clients Error handlers in execute_task, fix_skill, and upload_skill returned traceback.format_exc() to MCP clients, exposing internal file paths, code structure, and potentially sensitive details. The full traceback is already logged server-side via logger.error(exc_info=True). Remove the traceback field from client-facing error responses and clean up the unused traceback import. Closes #19 Made-with: Cursor --- openspace/mcp_server.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openspace/mcp_server.py b/openspace/mcp_server.py index b010f2f..168485c 100644 --- a/openspace/mcp_server.py +++ b/openspace/mcp_server.py @@ -22,7 +22,6 @@ import json import logging import os import sys -import traceback from pathlib import Path from typing import Any, Dict, List, Optional @@ -597,7 +596,7 @@ async def execute_task( except Exception as e: logger.error(f"execute_task failed: {e}", exc_info=True) - return _json_error(e, status="error", traceback=traceback.format_exc(limit=5)) + return _json_error(e, status="error") @mcp.tool() @@ -818,7 +817,7 @@ async def fix_skill( except Exception as e: logger.error(f"fix_skill failed: {e}", exc_info=True) - return _json_error(e, status="error", traceback=traceback.format_exc(limit=5)) + return _json_error(e, status="error") @mcp.tool() @@ -889,7 +888,7 @@ async def upload_skill( except Exception as e: logger.error(f"upload_skill failed: {e}", exc_info=True) - return _json_error(e, status="error", traceback=traceback.format_exc(limit=5)) + return _json_error(e, status="error") def run_mcp_server() -> None: """Console-script entry point for ``openspace-mcp``.""" From 34d82b735e80b87efbdab668438fe33d39d20309 Mon Sep 17 00:00:00 2001 From: Dennis-yxchen Date: Fri, 3 Apr 2026 23:39:16 +0800 Subject: [PATCH 34/45] fix: make tool fallback conservative Co-authored-by: xzq.xu --- openspace/llm/client.py | 61 ++++++++++++++++++++++++++++++-------- openspace/utils/logging.py | 13 ++++++-- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/openspace/llm/client.py b/openspace/llm/client.py index ded3ab8..c3c4620 100644 --- a/openspace/llm/client.py +++ b/openspace/llm/client.py @@ -190,6 +190,37 @@ def _infer_backend_from_tool_name(tool_name: str) -> Optional[str]: return None +def _resolve_tool_call_target( + tool_name: str, + tool_map: Dict[str, BaseTool], +) -> tuple[Optional[BaseTool], List[str]]: + """Resolve a returned tool name to a concrete tool object. + + The LLM is expected to return the deduped tool key from ``tool_map``. + Some providers occasionally return the short schema name instead. In that + case we only recover when exactly one tool shares that schema name; if + multiple tools match, the call is ambiguous and should not be executed. + """ + tool_obj = tool_map.get(tool_name) + if tool_obj is not None or not tool_name: + return tool_obj, [] + + fallback_matches = [ + (llm_name, tool) + for llm_name, tool in tool_map.items() + if getattr(getattr(tool, "schema", None), "name", None) == tool_name + ] + if len(fallback_matches) == 1: + resolved_name, resolved_tool = fallback_matches[0] + logger.info( + f"[TOOL_FALLBACK] Resolved short tool name '{tool_name}' to '{resolved_name}'" + ) + return resolved_tool, [] + if len(fallback_matches) > 1: + return None, [llm_name for llm_name, _tool in fallback_matches] + return None, [] + + DEFAULT_SUMMARIZE_THRESHOLD_CHARS = 200000 # ~50K tokens, lowered from 400K to prevent context overflow MAX_TOOL_RESULT_CHARS = 200000 # Fallback truncation limit when summarization fails (~50K tokens) @@ -705,14 +736,9 @@ class LLMClient: for tool_call in tool_calls: tool_name = tool_call.function.name - # Resolve tool instance: key might differ from model response (e.g. API returns - # "read_file" while we stored "server__read_file" for dedup), so fallback by schema.name - tool_obj = tool_map.get(tool_name) - if tool_obj is None and tool_name: - for _k, _t in tool_map.items(): - if getattr(getattr(_t, "schema", None), "name", None) == tool_name: - tool_obj = _t - break + # Resolve tool instance: some providers return the short schema + # name instead of the deduped LLM-visible tool key. + tool_obj, ambiguous_tool_names = _resolve_tool_call_target(tool_name, tool_map) backend = None server_name = None @@ -755,10 +781,19 @@ class LLMClient: pass if tool_obj is None: - result = ToolResult( - status=ToolStatus.ERROR, - error=f"Tool '{tool_name}' not found" - ) + if ambiguous_tool_names: + result = ToolResult( + status=ToolStatus.ERROR, + error=( + f"Tool '{tool_name}' is ambiguous; matches: " + f"{', '.join(ambiguous_tool_names)}" + ) + ) + else: + result = ToolResult( + status=ToolStatus.ERROR, + error=f"Tool '{tool_name}' not found" + ) else: try: result = await _execute_tool_call( @@ -871,4 +906,4 @@ class LLMClient: role = msg.get("role", "unknown").upper() content = msg.get("content", "") formatted += f"[{role}]\n{content}\n\n" - return formatted \ No newline at end of file + return formatted diff --git a/openspace/utils/logging.py b/openspace/utils/logging.py index ce6a3b8..7382374 100644 --- a/openspace/utils/logging.py +++ b/openspace/utils/logging.py @@ -239,7 +239,16 @@ class Logger: resolved = getattr(logging, level.upper(), None) if resolved is None or not isinstance(resolved, int): raise ValueError(f"Unknown log level: {level!r}") - cls.configure(level=resolved, force=True) + if not cls._configured: + cls.configure(level=resolved, attach_to_root=True) + return + + root_logger = logging.getLogger() + root_logger.setLevel(resolved) + for handler in root_logger.handlers: + handler.setLevel(resolved) + + cls._update_level(resolved) @classmethod def set_debug(cls, debug_level: int = 2) -> None: @@ -317,4 +326,4 @@ Logger.configure(attach_to_root=True) # Get openspace logger for internal logging logger = Logger.get_logger() -logger.debug("OpenSpace logging initialized") \ No newline at end of file +logger.debug("OpenSpace logging initialized") From 81f375e8bc5d78719fd025473c920db89e334b87 Mon Sep 17 00:00:00 2001 From: spidercatfly Date: Sat, 4 Apr 2026 00:04:35 +0800 Subject: [PATCH 35/45] update readme --- README.md | 13 +++++++++++++ README_CN.md | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/README.md b/README.md index b407689..871298d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,19 @@ --- +## 📢 News + +- **2026-04-03** 🚀 Released **v0.1.0** — Skill quality monitoring: structural patterns extracted from high-quality skills now evaluate every new submission daily. Faster, more relevant cloud search. Production-grade vertical skill clusters emerging organically from the community. Frontend now supports Chinese (zh) i18n. +- **2026-04-02** ⚡ Cloud search upgraded for higher relevance and lower latency. +- **2026-03-31** 🛡️ Security hardening: hardened zip extraction and `import_skill` against path traversal. CLI now respects `OPENSPACE_MODEL` and `OPENSPACE_LLM_*` env vars; MiniMax compatibility; workflow ID collision fixes. +- **2026-03-29** 🔒 Pinned litellm to <1.82.7 to avoid PYSEC-2026-2 supply-chain attack. +- **2026-03-28** 🔧 Idempotent skill registration — `register_skill_dir` now returns existing `SkillMeta` for already-registered skills. Updated OpenClaw setup docs. +- **2026-03-27** 🪟 Fixed stdio deadlock on Windows; improved evolver confirmation parsing with stem-style keyword matching. +- **2026-03-26** 🌱 Dynamic skill directory re-scanning on each call, lightweight local skill search, and streamlined documentation. +- **2026-03-25** 🎉 OpenSpace is now open source! + +--- + ## The Problem with Today's AI Agents Today's AI agents — [OpenClaw](https://github.com/openclaw/openclaw), [nanobot](https://github.com/HKUDS/nanobot), [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [Cursor](https://cursor.com), etc. — are powerful, but they have a critical weakness: they never **Learn**, **Adapt**, and **Evolve** from real-world experience — let alone **Share** with each other. diff --git a/README_CN.md b/README_CN.md index eca8642..09aee71 100644 --- a/README_CN.md +++ b/README_CN.md @@ -23,6 +23,19 @@ --- +## 📢 最新动态 + +- **2026-04-03** 🚀 发布 **v0.1.0** — Skill 质量监控上线:从优质 Skill 中提取结构模式,每日自动评估所有新提交;云端搜索全面升级,匹配更准、响应更快;社区自发形成生产级垂直 Skill 集群。前端新增中文(zh)国际化支持。 +- **2026-04-02** ⚡ 云端搜索升级,提升匹配质量、降低响应延迟。 +- **2026-03-31** 🛡️ 安全加固:zip 解压与 `import_skill` 新增路径穿越防护;CLI 启动时读取 `OPENSPACE_MODEL` 及 `OPENSPACE_LLM_*` 环境变量;修复 MiniMax 兼容性问题与 workflow ID 冲突。 +- **2026-03-29** 🔒 锁定 litellm 版本至 <1.82.7,规避 PYSEC-2026-2 供应链投毒。 +- **2026-03-28** 🔧 Skill 注册幂等化——`register_skill_dir` 对已注册目录直接返回已有 `SkillMeta`,不再重复创建。同步更新 OpenClaw 部署文档。 +- **2026-03-27** 🪟 修复 Windows 下 stdio 死锁;evolver 确认解析改用词干匹配,消除误判。 +- **2026-03-26** 🌱 Skill 目录支持每次调用时动态重扫描,本地搜索更轻量,文档同步精简。 +- **2026-03-25** 🎉 OpenSpace 正式开源! + +--- + ## 当前 AI Agent 面临的问题 如今的 AI Agent——[OpenClaw](https://github.com/openclaw/openclaw)、[nanobot](https://github.com/HKUDS/nanobot)、[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Codex](https://github.com/openai/codex)、[Cursor](https://cursor.com) 等——能力强大,但有一个致命弱点:它们从不从真实世界的经验中**学习**、**适应**和**进化**——更不用说相互之间的**共享**了。 From 1bd1a3d3778d1d22d727ba93faa5a4d1cffe1bff Mon Sep 17 00:00:00 2001 From: spidercatfly Date: Sun, 5 Apr 2026 15:13:07 +0800 Subject: [PATCH 36/45] clean up LLM credential resolution --- openspace/.env.example | 52 ++++-- openspace/agents/grounding_agent.py | 4 +- openspace/config/README.md | 72 +++++---- openspace/host_detection/__init__.py | 6 +- openspace/host_detection/nanobot.py | 26 ++- openspace/host_detection/openclaw.py | 230 +++++++++++++++++++++++---- openspace/host_detection/resolver.py | 161 ++++++++++++++++--- openspace/llm/client.py | 25 ++- 8 files changed, 449 insertions(+), 127 deletions(-) diff --git a/openspace/.env.example b/openspace/.env.example index d032801..d8e9465 100644 --- a/openspace/.env.example +++ b/openspace/.env.example @@ -3,10 +3,24 @@ # Copy this file to .env and fill in your keys # ============================================ -# ---- LLM API Keys ---- -# At least one LLM API key is required for OpenSpace to function. -# OpenSpace uses LiteLLM for model routing, so the key you need depends on your chosen model. -# See https://docs.litellm.ai/docs/providers for supported providers. +# ── LLM Credentials ────────────────────────────────────── +# +# OpenSpace resolves LLM credentials in this order (first match wins): +# +# 1. OPENSPACE_LLM_* — explicit override, always highest priority +# 2. Provider-native vars — OPENROUTER_API_KEY, OPENAI_API_KEY, etc. +# 3. ~/.nanobot/config.json or ~/.openclaw/openclaw.json — fallback (only when no explicit or provider key found) +# +# For most users, setting ONE of the provider-native keys below is enough. +# LiteLLM reads them automatically. See https://docs.litellm.ai/docs/providers +# +# Full configuration guide: openspace/config/README.md + +# --- Option A: Provider-native key (simplest) --- +# Set the key that matches your model's provider: + +# OpenRouter (for openrouter/* models, e.g. openrouter/anthropic/claude-sonnet-4.5) +OPENROUTER_API_KEY= # Anthropic (for anthropic/claude-* models) # ANTHROPIC_API_KEY= @@ -14,8 +28,15 @@ # OpenAI (for openai/gpt-* models) # OPENAI_API_KEY= -# OpenRouter (for openrouter/* models, e.g. openrouter/anthropic/claude-sonnet-4.5) -OPENROUTER_API_KEY= +# DeepSeek (for deepseek/* models) +# DEEPSEEK_API_KEY= + +# --- Option B: Explicit OpenSpace override (takes priority over Option A) --- +# Use these when you need full control, e.g. custom API base or non-standard provider. + +# OPENSPACE_MODEL=openrouter/anthropic/claude-sonnet-4.5 +# OPENSPACE_LLM_API_KEY=sk-xxx +# OPENSPACE_LLM_API_BASE=https://openrouter.ai/api/v1 # ── OpenSpace Cloud (optional) ────────────────────────────── # Register at https://open-space.cloud to get your key. @@ -23,28 +44,25 @@ OPENROUTER_API_KEY= OPENSPACE_API_KEY=sk_xxxxxxxxxxxxxxxx -# ---- GUI Backend (Anthropic Computer Use) ---- -# Required only if using the GUI backend. Uses the same ANTHROPIC_API_KEY above. + +# ── GUI Backend (optional) ────────────────────────────────── +# Required only if using the GUI backend (Anthropic Computer Use). +# Uses the same ANTHROPIC_API_KEY above. # Optional backup key for rate limit fallback: # ANTHROPIC_API_KEY_BACKUP= -# ---- Web Backend (Deep Research) ---- -# Required only if using the Web backend for deep research. -# Uses OpenRouter API by default: -# OPENROUTER_API_KEY= - -# ---- Embedding (Optional) ---- +# ── Embedding (optional) ──────────────────────────────────── # For remote embedding API instead of local model. # If not set, OpenSpace uses a local embedding model (BAAI/bge-small-en-v1.5). # EMBEDDING_BASE_URL= # EMBEDDING_API_KEY= -# EMBEDDING_MODEL= "openai/text-embedding-3-small" +# EMBEDDING_MODEL=openai/text-embedding-3-small -# ---- E2B Sandbox (Optional) ---- +# ── E2B Sandbox (optional) ────────────────────────────────── # Required only if sandbox mode is enabled in security config. # E2B_API_KEY= -# ---- Local Server (Optional) ---- +# ── Local Server (optional) ───────────────────────────────── # Override the default local server URL (default: http://127.0.0.1:5000) # Useful for remote VM integration (e.g., OSWorld). # LOCAL_SERVER_URL=http://127.0.0.1:5000 diff --git a/openspace/agents/grounding_agent.py b/openspace/agents/grounding_agent.py index 4d983d2..72a4fe0 100644 --- a/openspace/agents/grounding_agent.py +++ b/openspace/agents/grounding_agent.py @@ -749,6 +749,7 @@ class GroundingAgent(BaseAgent): # Use dedicated visual analysis model if configured, otherwise use main LLM model visual_model = self._visual_analysis_model or (self._llm_client.model if self._llm_client else "openrouter/anthropic/claude-sonnet-4.5") + _llm_extra = getattr(self._llm_client, 'litellm_kwargs', {}) if self._llm_client else {} response = await asyncio.wait_for( litellm.acompletion( model=visual_model, @@ -756,7 +757,8 @@ class GroundingAgent(BaseAgent): "role": "user", "content": content }], - timeout=self._visual_analysis_timeout + timeout=self._visual_analysis_timeout, + **_llm_extra, ), timeout=self._visual_analysis_timeout + 5 ) diff --git a/openspace/config/README.md b/openspace/config/README.md index 74327ff..f2e0875 100644 --- a/openspace/config/README.md +++ b/openspace/config/README.md @@ -1,40 +1,51 @@ # 🔧 Configuration Guide -All configuration applies to both Path A (host agent) and Path B (standalone). Configure once before the first run. - -## 1. API Keys (`.env`) +## 1. LLM Credentials (`.env`) > [!NOTE] -> Create a `.env` file and add your API keys (refer to [`.env.example`](../../.env.example)). When used via host agent (Path A), LLM keys are auto-detected from your agent's config — `.env` is mainly needed for standalone mode. +> Create `openspace/.env` from [`.env.example`](../../.env.example) and set at least one LLM API key. + +Resolution priority (first match wins): + +| Priority | Source | Example | +|----------|--------|---------| +| **Tier 1** | `OPENSPACE_LLM_*` env vars | `OPENSPACE_LLM_API_KEY=sk-xxx` | +| **Tier 2** | Provider-native env vars | `OPENROUTER_API_KEY=sk-or-xxx` | +| **Tier 3** | Host agent config | `~/.nanobot/config.json` / `~/.openclaw/openclaw.json` | + +> [!IMPORTANT] +> Tier 2 blocks Tier 3 — if `.env` has a provider key, host agent config is skipped. + +```bash +# Provider-native — litellm reads automatically +OPENROUTER_API_KEY=sk-or-v1-xxx + +# Or: OpenSpace-native — higher priority, same effect +OPENSPACE_LLM_API_KEY=sk-or-v1-xxx +``` ## 2. Environment Variables -Set via `.env`, MCP config `env` block, or system environment. OpenSpace reads these at startup. +Set via `.env`, MCP config `env` block, or system environment. -| Variable | Required | Description | -|----------|----------|-------------| -| `OPENSPACE_HOST_SKILL_DIRS` | Path A only | Your agent's skill directories (comma-separated). Auto-registered on startup. | -| `OPENSPACE_WORKSPACE` | Recommended | OpenSpace project root. Used for recording logs and workspace resolution. | -| `OPENSPACE_API_KEY` | No | Cloud API key (`sk-xxx`). Register at https://open-space.cloud. | -| `OPENSPACE_MODEL` | No | LLM model override (default: auto-detected or `openrouter/anthropic/claude-sonnet-4.5`). | -| `OPENSPACE_MAX_ITERATIONS` | No | Max agent iterations per task (default: `20`). | -| `OPENSPACE_BACKEND_SCOPE` | No | Enabled backends, comma-separated (default: all — `shell,gui,mcp,web,system`). | - -### Advanced env overrides (rarely needed) - -| Variable | Description | -|----------|-------------| -| `OPENSPACE_LLM_API_KEY` | LLM API key (auto-detected from host agent in Path A) | -| `OPENSPACE_LLM_API_BASE` | LLM API base URL | -| `OPENSPACE_LLM_EXTRA_HEADERS` | Extra HTTP headers for LLM requests (JSON string) | -| `OPENSPACE_LLM_CONFIG` | Arbitrary litellm kwargs (JSON string) | -| `OPENSPACE_API_BASE` | Cloud API base URL (default `https://open-space.cloud/api/v1`) | -| `OPENSPACE_CONFIG_PATH` | Custom grounding config JSON (deep-merged with defaults) | -| `OPENSPACE_SHELL_CONDA_ENV` | Conda environment for shell backend | -| `OPENSPACE_SHELL_WORKING_DIR` | Working directory for shell backend | -| `OPENSPACE_MCP_SERVERS_JSON` | MCP server definitions (JSON string, merged into `mcpServers`) | -| `OPENSPACE_ENABLE_RECORDING` | Record execution traces (default: `true`) | -| `OPENSPACE_LOG_LEVEL` | `DEBUG` / `INFO` / `WARNING` / `ERROR` | +| Variable | Description | Default | +|----------|-------------|---------| +| `OPENSPACE_MODEL` | LLM model | `openrouter/anthropic/claude-sonnet-4.5` | +| `OPENSPACE_LLM_API_KEY` | LLM API key (Tier 1 override) | — | +| `OPENSPACE_LLM_API_BASE` | LLM API base URL | — | +| `OPENSPACE_LLM_EXTRA_HEADERS` | Extra LLM headers (JSON) | — | +| `OPENSPACE_LLM_CONFIG` | Arbitrary litellm kwargs (JSON) | — | +| `OPENSPACE_API_KEY` | Cloud API key ([open-space.cloud](https://open-space.cloud)) | — | +| `OPENSPACE_MAX_ITERATIONS` | Max agent iterations per task | `20` | +| `OPENSPACE_BACKEND_SCOPE` | Enabled backends (comma-separated) | `shell,gui,mcp,web,system` | +| `OPENSPACE_HOST_SKILL_DIRS` | Agent skill directories (comma-separated) | — | +| `OPENSPACE_WORKSPACE` | Project root for logs/workspace | — | +| `OPENSPACE_SHELL_CONDA_ENV` | Conda env for shell backend | — | +| `OPENSPACE_SHELL_WORKING_DIR` | Working dir for shell backend | — | +| `OPENSPACE_CONFIG_PATH` | Custom grounding config JSON | — | +| `OPENSPACE_MCP_SERVERS_JSON` | MCP server definitions (JSON) | — | +| `OPENSPACE_ENABLE_RECORDING` | Record execution traces | `true` | +| `OPENSPACE_LOG_LEVEL` | Log level | `INFO` | ## 3. MCP Servers (`config_mcp.json`) @@ -67,7 +78,7 @@ Shell and GUI backends support two execution modes, set via `"mode"` in `config_ | **How** | `asyncio.subprocess` in-process | HTTP → Flask → subprocess | > [!TIP] -> **Use local mode** for most use cases. For server mode setup (how to enable, platform-specific deps, remote VM control), see [`../local_server/README.md`](../local_server/README.md). +> **Use local mode** for most use cases. For server mode setup, see [`../local_server/README.md`](../local_server/README.md). ## 5. Config Files (`openspace/config/`) @@ -112,4 +123,3 @@ Layered system — later files override earlier ones: | `blocked_commands` | Platform-specific blacklists (common/linux/darwin/windows) | `rm -rf`, `shutdown`, `dd`, etc. | | `sandbox_enabled` | Enable sandboxing for all operations | `false` | | Per-backend overrides | Shell, MCP, GUI, Web each have independent security policies | Inherit global | - diff --git a/openspace/host_detection/__init__.py b/openspace/host_detection/__init__.py index 820dbfc..d8125b3 100644 --- a/openspace/host_detection/__init__.py +++ b/openspace/host_detection/__init__.py @@ -29,8 +29,8 @@ from openspace.host_detection.nanobot import ( ) from openspace.host_detection.openclaw import ( get_openclaw_openai_api_key as _openclaw_get_openai_api_key, - is_openclaw_host, read_openclaw_skill_env, + try_read_openclaw_config, ) logger = logging.getLogger("openspace.host_detection") @@ -82,10 +82,8 @@ __all__ = [ "build_grounding_config_path", "get_openai_api_key", "read_host_mcp_env", - # legacy re-exports "read_nanobot_mcp_env", "try_read_nanobot_config", - # openclaw-specific (for direct use if needed) - "is_openclaw_host", "read_openclaw_skill_env", + "try_read_openclaw_config", ] diff --git a/openspace/host_detection/nanobot.py b/openspace/host_detection/nanobot.py index 8f787b1..c766cc4 100644 --- a/openspace/host_detection/nanobot.py +++ b/openspace/host_detection/nanobot.py @@ -12,6 +12,7 @@ from __future__ import annotations import json import logging +import os from pathlib import Path from typing import Any, Dict, List, Optional @@ -35,19 +36,31 @@ PROVIDER_REGISTRY: List[tuple] = [ ("groq", ("groq",), ""), ] -NANOBOT_CONFIG_PATH = Path.home() / ".nanobot" / "config.json" + +def _resolve_nanobot_config_path() -> Path: + """Resolve the nanobot config path from env overrides or defaults.""" + explicit = os.environ.get("NANOBOT_CONFIG_PATH", "").strip() + if explicit: + return Path(explicit).expanduser() + + state_dir = os.environ.get("NANOBOT_STATE_DIR", "").strip() + if state_dir: + return Path(state_dir).expanduser() / "config.json" + + return Path.home() / ".nanobot" / "config.json" def _load_nanobot_config() -> Optional[Dict[str, Any]]: - """Load and parse ``~/.nanobot/config.json``. Returns None on failure.""" - if not NANOBOT_CONFIG_PATH.is_file(): + """Load and parse nanobot config.json. Returns None on failure.""" + config_path = _resolve_nanobot_config_path() + if not config_path.is_file(): return None try: - with open(NANOBOT_CONFIG_PATH, encoding="utf-8") as f: + with open(config_path, encoding="utf-8") as f: data = json.load(f) return data if isinstance(data, dict) else None except (json.JSONDecodeError, OSError) as e: - logger.warning("Failed to read nanobot config %s: %s", NANOBOT_CONFIG_PATH, e) + logger.warning("Failed to read nanobot config %s: %s", config_path, e) return None @@ -154,10 +167,11 @@ def try_read_nanobot_config(model: str) -> Optional[Dict[str, Any]]: result["_forced_provider"] = forced_provider if result: + config_path = _resolve_nanobot_config_path() logger.info( "Auto-detected LLM credentials from nanobot config (%s), " "provider matched for model=%r", - NANOBOT_CONFIG_PATH, match_model, + config_path, match_model, ) return result diff --git a/openspace/host_detection/openclaw.py b/openspace/host_detection/openclaw.py index 1107b5c..a97104a 100644 --- a/openspace/host_detection/openclaw.py +++ b/openspace/host_detection/openclaw.py @@ -1,7 +1,8 @@ """OpenClaw host-agent config reader. Reads ``~/.openclaw/openclaw.json`` to auto-detect: - - LLM provider credentials (via ``auth-profiles`` — not yet implemented) + - LLM provider credentials from env-style config blocks + (``skills.entries.openspace.env`` and ``env.vars``) - Skill-level env block (``skills.entries.openspace.env``) - OpenAI API key for embedding generation @@ -17,20 +18,74 @@ from __future__ import annotations import json import logging +import os from pathlib import Path from typing import Any, Dict, Optional +from openspace.host_detection.nanobot import PROVIDER_REGISTRY + logger = logging.getLogger("openspace.host_detection") _STATE_DIRNAMES = [".openclaw", ".clawdbot", ".moldbot", ".moltbot"] _CONFIG_FILENAMES = ["openclaw.json", "clawdbot.json", "moldbot.json", "moltbot.json"] +_PROVIDER_ENV_VARS: Dict[str, Dict[str, tuple[str, ...]]] = { + "openrouter": { + "api_key": ("OPENROUTER_API_KEY", "OR_API_KEY"), + "api_base": ("OPENROUTER_API_BASE",), + }, + "aihubmix": { + "api_key": ("AIHUBMIX_API_KEY",), + "api_base": ("AIHUBMIX_API_BASE",), + }, + "siliconflow": { + "api_key": ("SILICONFLOW_API_KEY",), + "api_base": ("SILICONFLOW_API_BASE",), + }, + "volcengine": { + "api_key": ("VOLCENGINE_API_KEY", "ARK_API_KEY"), + "api_base": ("VOLCENGINE_API_BASE", "ARK_API_BASE"), + }, + "anthropic": { + "api_key": ("ANTHROPIC_API_KEY",), + "api_base": ("ANTHROPIC_API_BASE",), + }, + "openai": { + "api_key": ("OPENAI_API_KEY",), + "api_base": ("OPENAI_BASE_URL", "OPENAI_API_BASE"), + }, + "deepseek": { + "api_key": ("DEEPSEEK_API_KEY",), + "api_base": ("DEEPSEEK_API_BASE",), + }, + "gemini": { + "api_key": ("GEMINI_API_KEY", "GOOGLE_API_KEY"), + "api_base": ("GEMINI_API_BASE", "GOOGLE_API_BASE"), + }, + "zhipu": { + "api_key": ("ZHIPU_API_KEY",), + "api_base": ("ZHIPU_API_BASE",), + }, + "dashscope": { + "api_key": ("DASHSCOPE_API_KEY",), + "api_base": ("DASHSCOPE_API_BASE",), + }, + "moonshot": { + "api_key": ("MOONSHOT_API_KEY",), + "api_base": ("MOONSHOT_API_BASE",), + }, + "minimax": { + "api_key": ("MINIMAX_API_KEY",), + "api_base": ("MINIMAX_API_BASE",), + }, + "groq": { + "api_key": ("GROQ_API_KEY",), + "api_base": ("GROQ_API_BASE",), + }, +} def _resolve_openclaw_config_path() -> Optional[Path]: """Find the OpenClaw config file on disk.""" - import os - - # 1. Explicit env override explicit = os.environ.get("OPENCLAW_CONFIG_PATH", "").strip() if explicit: p = Path(explicit).expanduser() @@ -38,7 +93,6 @@ def _resolve_openclaw_config_path() -> Optional[Path]: return p return None - # 2. State dir override state_dir = os.environ.get("OPENCLAW_STATE_DIR", "").strip() if state_dir: for fname in _CONFIG_FILENAMES: @@ -46,7 +100,6 @@ def _resolve_openclaw_config_path() -> Optional[Path]: if p.is_file(): return p - # 3. Default locations home = Path.home() for dirname in _STATE_DIRNAMES: for fname in _CONFIG_FILENAMES: @@ -71,6 +124,119 @@ def _load_openclaw_config() -> Optional[Dict[str, Any]]: return None +def _coerce_env_value(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def _pick_env(env_block: Dict[str, Any], names: tuple[str, ...]) -> str: + for name in names: + value = _coerce_env_value(env_block.get(name)) + if value: + return value + return "" + + +def _get_openclaw_env(skill_name: str = "openspace") -> Dict[str, Any]: + """Merge OpenClaw top-level env vars with skill-level env overrides.""" + merged: Dict[str, Any] = {} + data = _load_openclaw_config() + if data and isinstance(data, dict): + env_section = data.get("env", {}) + if isinstance(env_section, dict): + vars_block = env_section.get("vars", {}) + if isinstance(vars_block, dict): + merged.update(vars_block) + merged.update(read_openclaw_skill_env(skill_name)) + return merged + + +def _extract_explicit_llm_kwargs(env_block: Dict[str, Any]) -> Dict[str, Any]: + """Read OpenSpace-native LLM overrides from an env-like dict.""" + result: Dict[str, Any] = {} + + api_key = _coerce_env_value(env_block.get("OPENSPACE_LLM_API_KEY")) + if api_key: + result["api_key"] = api_key + + api_base = _coerce_env_value(env_block.get("OPENSPACE_LLM_API_BASE")) + if api_base: + result["api_base"] = api_base + + extra_headers_raw = _coerce_env_value(env_block.get("OPENSPACE_LLM_EXTRA_HEADERS")) + if extra_headers_raw: + try: + headers = json.loads(extra_headers_raw) + if isinstance(headers, dict): + result["extra_headers"] = headers + except json.JSONDecodeError: + logger.warning( + "Invalid JSON in OpenClaw OPENSPACE_LLM_EXTRA_HEADERS: %r", + extra_headers_raw, + ) + + llm_config_raw = _coerce_env_value(env_block.get("OPENSPACE_LLM_CONFIG")) + if llm_config_raw: + try: + llm_config = json.loads(llm_config_raw) + if isinstance(llm_config, dict): + result.update(llm_config) + except json.JSONDecodeError: + logger.warning( + "Invalid JSON in OpenClaw OPENSPACE_LLM_CONFIG: %r", + llm_config_raw, + ) + + return result + + +def _extract_provider_env( + env_block: Dict[str, Any], + provider: str, + default_base: str = "", +) -> Optional[Dict[str, Any]]: + spec = _PROVIDER_ENV_VARS.get(provider) + if not spec: + return None + + api_key = _pick_env(env_block, spec["api_key"]) + if not api_key: + return None + + result: Dict[str, Any] = {"api_key": api_key} + api_base = _pick_env(env_block, spec.get("api_base", ())) or default_base + if api_base: + result["api_base"] = api_base + return result + + +def _match_provider_env(model: str, env_block: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Resolve provider-native env vars from OpenClaw config for a model.""" + model_lower = model.lower() + model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" + normalized_prefix = model_prefix.replace("-", "_") + + for name, _keywords, default_base in PROVIDER_REGISTRY: + if model_prefix and normalized_prefix == name: + result = _extract_provider_env(env_block, name, default_base) + if result: + return result + + for name, keywords, default_base in PROVIDER_REGISTRY: + if any(keyword in model_lower for keyword in keywords): + result = _extract_provider_env(env_block, name, default_base) + if result: + return result + + for name, _keywords, default_base in PROVIDER_REGISTRY: + result = _extract_provider_env(env_block, name, default_base) + if result: + return result + + return None + + def read_openclaw_skill_env(skill_name: str = "openspace") -> Dict[str, str]: """Read ``skills.entries..env`` from OpenClaw config. @@ -104,34 +270,40 @@ def get_openclaw_openai_api_key() -> Optional[str]: Returns the key string, or None. """ - # Try skill-level env - env = read_openclaw_skill_env("openspace") - key = env.get("OPENAI_API_KEY", "").strip() + env = _get_openclaw_env("openspace") + key = _coerce_env_value(env.get("OPENAI_API_KEY")) if key: logger.debug("Using OpenAI API key from OpenClaw skill env config") return key - # Try top-level config env.vars - data = _load_openclaw_config() - if data: - env_section = data.get("env", {}) - if isinstance(env_section, dict): - vars_block = env_section.get("vars", {}) - if isinstance(vars_block, dict): - key = vars_block.get("OPENAI_API_KEY", "").strip() - if key: - logger.debug("Using OpenAI API key from OpenClaw env.vars config") - return key - return None -def is_openclaw_host() -> bool: - """Detect if the current environment is running under OpenClaw.""" - import os - # Check OpenClaw-specific env vars - if os.environ.get("OPENCLAW_STATE_DIR") or os.environ.get("OPENCLAW_CONFIG_PATH"): - return True - # Check if config exists - return _resolve_openclaw_config_path() is not None +def try_read_openclaw_config(model: str) -> Optional[Dict[str, Any]]: + """Read LLM credentials from OpenClaw's env-style config blocks.""" + env_block = _get_openclaw_env("openspace") + if not env_block: + return None + + explicit_kwargs = _extract_explicit_llm_kwargs(env_block) + provider_kwargs = _match_provider_env(model or "", env_block) + + if not explicit_kwargs and not provider_kwargs: + return None + + result: Dict[str, Any] = {} + if provider_kwargs: + result.update(provider_kwargs) + if explicit_kwargs: + result.update(explicit_kwargs) + + config_path = _resolve_openclaw_config_path() + logger.info( + "Auto-detected LLM credentials from OpenClaw config (%s), provider matched for model=%r", + config_path, + model, + ) + return result + + diff --git a/openspace/host_detection/resolver.py b/openspace/host_detection/resolver.py index d3d9cb0..ee43408 100644 --- a/openspace/host_detection/resolver.py +++ b/openspace/host_detection/resolver.py @@ -10,10 +10,101 @@ import json import logging import os import tempfile +from pathlib import Path from typing import Any, Dict, Optional logger = logging.getLogger("openspace.host_detection") +_DEFAULT_MODEL = "openrouter/anthropic/claude-sonnet-4.5" + +_PROVIDER_NATIVE_ENV_VARS: Dict[str, tuple[str, ...]] = { + "openrouter": ("OPENROUTER_API_KEY", "OR_API_KEY"), + "aihubmix": ("AIHUBMIX_API_KEY",), + "siliconflow": ("SILICONFLOW_API_KEY",), + "volcengine": ("VOLCENGINE_API_KEY", "ARK_API_KEY"), + "anthropic": ("ANTHROPIC_API_KEY",), + "openai": ("OPENAI_API_KEY",), + "deepseek": ("DEEPSEEK_API_KEY",), + "gemini": ("GEMINI_API_KEY", "GOOGLE_API_KEY"), + "zhipu": ("ZHIPU_API_KEY",), + "dashscope": ("DASHSCOPE_API_KEY",), + "moonshot": ("MOONSHOT_API_KEY",), + "minimax": ("MINIMAX_API_KEY",), + "groq": ("GROQ_API_KEY",), +} + +_env_loaded = False + + +def _load_env_once() -> None: + """Load .env files once per process. + + Search order (first-loaded wins for each key): + 1. ``openspace/.env`` (package root — works regardless of CWD) + 2. ``CWD/.env`` (project-level fallback) + + Uses ``override=False`` so env vars already in the process (e.g. set + by the host agent or the shell) are never overwritten. + """ + global _env_loaded + if _env_loaded: + return + _env_loaded = True + + try: + from dotenv import load_dotenv + except ImportError: + return + + pkg_env = Path(__file__).resolve().parent.parent / ".env" + if pkg_env.is_file(): + load_dotenv(pkg_env) + load_dotenv() + + +def _pick_first_env(names: tuple[str, ...]) -> str: + for name in names: + value = os.environ.get(name, "").strip() + if value: + return value + return "" + + +def _infer_provider_name(model: str) -> Optional[str]: + """Infer the provider name from a model string using PROVIDER_REGISTRY.""" + from openspace.host_detection.nanobot import PROVIDER_REGISTRY + + model_lower = (model or "").lower() + model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" + normalized_prefix = model_prefix.replace("-", "_") + + for name, _keywords, _default_base in PROVIDER_REGISTRY: + if model_prefix and normalized_prefix == name: + return name + + for name, keywords, _default_base in PROVIDER_REGISTRY: + if any(keyword in model_lower for keyword in keywords): + return name + + return None + + +def _has_provider_native_env(model: str) -> bool: + """Check if a provider-native API key (e.g. OPENROUTER_API_KEY) exists. + + When True, the key from .env or the process environment is sufficient + for litellm to authenticate — no need to read nanobot / host config. + """ + provider = _infer_provider_name(model) + if not provider: + return False + + env_names = _PROVIDER_NATIVE_ENV_VARS.get(provider) + if not env_names: + return False + + return bool(_pick_first_env(env_names)) + def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: """Build litellm kwargs and resolve model for OpenSpace's LLM client. @@ -27,47 +118,63 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: OPENSPACE_LLM_EXTRA_HEADERS → litellm ``extra_headers`` (JSON string) OPENSPACE_LLM_CONFIG → arbitrary litellm kwargs (JSON string) - Tier 2 — Auto-detect from host agent config file:: + Tier 2 — Provider-native env vars already present in the process + (including values loaded from ``openspace/.env``):: - ~/.nanobot/config.json → providers.{matched}.apiKey / apiBase + OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / ... - Tier 3 — Provider-native env vars inherited from the parent process - (e.g. ``OPENROUTER_API_KEY``). Read by litellm automatically. + These take precedence over host-agent config so local/standalone + launches are not hijacked by unrelated host config files. + + Tier 3 — Host-agent config file fallback (only when Tier 1+2 absent):: + + nanobot → ``~/.nanobot/config.json`` + openclaw → ``~/.openclaw/openclaw.json`` Returns: ``(resolved_model, llm_kwargs_dict)`` """ - from openspace.host_detection.nanobot import try_read_nanobot_config + _load_env_once() kwargs: Dict[str, Any] = {} resolved_model = model source = "inherited env" - # --- Tier 2: auto-detect from host config (filled first, may be overridden) --- - host_config = try_read_nanobot_config(model) + has_explicit_llm_override = bool( + os.environ.get("OPENSPACE_LLM_API_BASE") + or os.environ.get("OPENSPACE_LLM_API_KEY") + ) + provider_native_env_used = _has_provider_native_env( + resolved_model or _DEFAULT_MODEL + ) + + # --- Tier 3: host config fallback (only when no local keys) --- + host_config = None + host_source = None + if not has_explicit_llm_override and not provider_native_env_used: + from openspace.host_detection.nanobot import try_read_nanobot_config + host_config = try_read_nanobot_config(model) + if host_config: + host_source = "nanobot config" + else: + from openspace.host_detection.openclaw import try_read_openclaw_config + host_config = try_read_openclaw_config(model) + if host_config: + host_source = "openclaw config" + if host_config: host_model = host_config.pop("_model", None) forced_provider = host_config.pop("_forced_provider", None) if not resolved_model and host_model: resolved_model = host_model - # If the host config forces a gateway provider (e.g. openrouter) - # and the model name doesn't already carry that prefix, prepend - # it so that litellm uses the correct request format (OpenAI- - # compatible for gateways vs native for direct providers). - # Skip when the user explicitly provided a model (via OPENSPACE_MODEL - # or --model) AND explicit OPENSPACE_LLM_* overrides — the user knows - # exactly which endpoint they want to hit. + _GATEWAY_PROVIDERS = {"openrouter", "aihubmix", "siliconflow"} - _has_explicit_llm_override = bool( - os.environ.get("OPENSPACE_LLM_API_BASE") - or os.environ.get("OPENSPACE_LLM_API_KEY") - ) if ( forced_provider and forced_provider in _GATEWAY_PROVIDERS and resolved_model and not resolved_model.lower().startswith(f"{forced_provider}/") - and not (model and _has_explicit_llm_override) + and not (model and has_explicit_llm_override) ): resolved_model = f"{forced_provider}/{resolved_model}" logger.info( @@ -75,7 +182,7 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: resolved_model, forced_provider, ) kwargs.update(host_config) - source = "nanobot config" + source = host_source or "host config" # --- Tier 1: explicit env vars override everything --- api_key = os.environ.get("OPENSPACE_LLM_API_KEY") @@ -108,7 +215,7 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: # Default model fallback if not resolved_model: - resolved_model = "openrouter/anthropic/claude-sonnet-4.5" + resolved_model = _DEFAULT_MODEL # Provider-specific adjustments for litellm routing if resolved_model and "minimax" in resolved_model.lower(): @@ -120,10 +227,6 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: if final_base: os.environ.setdefault("MINIMAX_API_BASE", final_base) - # api.minimaxi.com (domestic) is OpenAI-compatible but does not fully - # support litellm's minimax-specific request transformations (e.g. tool - # calling format). Switch to the generic openai/ prefix so litellm - # sends standard OpenAI-format requests that minimaxi.com accepts. if ( resolved_model.lower().startswith("minimax/") and "minimaxi.com" in final_base @@ -141,6 +244,11 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: for k, v in kwargs.items() } logger.info("LLM kwargs resolved (source=%s): %s", source, safe) + elif provider_native_env_used: + logger.info( + "LLM credentials resolved from provider-native env for model=%r", + resolved_model, + ) return resolved_model, kwargs @@ -158,6 +266,8 @@ def build_grounding_config_path() -> Optional[str]: Returns: Path to the resolved config file, or None. """ + _load_env_once() + config_json_raw = os.environ.get("OPENSPACE_CONFIG_JSON", "").strip() overrides: Dict[str, Any] = {} if config_json_raw: @@ -213,4 +323,3 @@ def build_grounding_config_path() -> Optional[str]: logger.warning("Failed to write config overrides: %s", e) return os.environ.get("OPENSPACE_CONFIG_PATH") - diff --git a/openspace/llm/client.py b/openspace/llm/client.py index c3c4620..c7fd696 100644 --- a/openspace/llm/client.py +++ b/openspace/llm/client.py @@ -2,21 +2,15 @@ import litellm import json import asyncio import time -from pathlib import Path from typing import List, Sequence, Union, Dict, Optional -from dotenv import load_dotenv from openai.types.chat import ChatCompletionToolParam from openspace.grounding.core.types import ToolSchema, ToolResult, ToolStatus from openspace.grounding.core.tool import BaseTool from openspace.utils.logging import Logger -# Load .env from openspace package root (works regardless of CWD), -# then fall back to CWD/.env. override=False (default) means first-loaded wins. -_PKG_ENV = Path(__file__).resolve().parent.parent / ".env" # openspace/.env -if _PKG_ENV.is_file(): - load_dotenv(_PKG_ENV) -load_dotenv() # also try CWD/.env for any remaining vars +# .env loading is centralized in host_detection.resolver._load_env_once() +# which is called by build_llm_kwargs / build_grounding_config_path. # Disable LiteLLM verbose logging to prevent stdout blocking with large tool schemas litellm.set_verbose = False @@ -229,7 +223,8 @@ async def _summarize_tool_result( tool_name: str, task: str = "", model: str = "openrouter/anthropic/claude-sonnet-4.5", - timeout: float = 120.0 + timeout: float = 120.0, + litellm_kwargs: Optional[Dict] = None, ) -> str: """Use LLM to summarize large tool results.""" try: @@ -265,11 +260,13 @@ Content: Concise summary:""" + _extra = litellm_kwargs or {} response = await asyncio.wait_for( litellm.acompletion( model=model, messages=[{"role": "user", "content": prompt}], - timeout=timeout + timeout=timeout, + **_extra, ), timeout=timeout + 5 ) @@ -296,7 +293,8 @@ async def _tool_result_to_message_async( task: str = "", summarize_threshold: int = DEFAULT_SUMMARIZE_THRESHOLD_CHARS, summarize_model: str = "openrouter/anthropic/claude-sonnet-4.5", - enable_summarization: bool = True + enable_summarization: bool = True, + litellm_kwargs: Optional[Dict] = None, ) -> Dict: """Convert ToolResult to LLMClient usable message format with LLM summarization for large results. @@ -325,7 +323,7 @@ async def _tool_result_to_message_async( # Use LLM summarization if content exceeds threshold if original_len > summarize_threshold and enable_summarization: - summary = await _summarize_tool_result(text_content, tool_name, task, summarize_model) + summary = await _summarize_tool_result(text_content, tool_name, task, summarize_model, litellm_kwargs=litellm_kwargs) if summary: text_content = summary elif original_len > MAX_TOOL_RESULT_CHARS: @@ -833,7 +831,8 @@ class LLMClient: task=user_task, summarize_threshold=self.summarize_threshold_chars, summarize_model=self.model, - enable_summarization=self.enable_tool_result_summarization + enable_summarization=self.enable_tool_result_summarization, + litellm_kwargs=self.litellm_kwargs, ) current_messages.append(tool_message) From f3a064df1cbd1fe0e1bc09fdb934c945a5a093a3 Mon Sep 17 00:00:00 2001 From: yc111233 Date: Mon, 6 Apr 2026 01:02:35 +0800 Subject: [PATCH 37/45] fix: address 8 runtime bugs found during code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Race condition on _addressed_degradations (evolver.py) — add asyncio.Lock 2. Silent exception swallowing in wait_background (evolver.py) — log failures 3. Workspace cleanup could delete user files (tool_layer.py) — add mtime guard 4. WAL cleanup without lock check (store.py) — probe for DB lock first 5. Edit distance threshold too loose (analyzer.py) — adaptive threshold + ambiguity rejection 6. Message truncation drops context (grounding_agent.py) — add truncation notice 7. Whitespace-only empty response not detected (grounding_agent.py) — strip before check 8. Tool name reverse parsing with __ (client.py, manager.py) — use rsplit --- openspace/agents/grounding_agent.py | 11 ++++++-- openspace/llm/client.py | 5 ++-- openspace/recording/manager.py | 3 ++- openspace/skill_engine/analyzer.py | 10 +++++--- openspace/skill_engine/evolver.py | 13 +++++++++- openspace/skill_engine/store.py | 16 ++++++++++++ openspace/tool_layer.py | 40 +++++++++++++++++++++-------- 7 files changed, 78 insertions(+), 20 deletions(-) diff --git a/openspace/agents/grounding_agent.py b/openspace/agents/grounding_agent.py index 72a4fe0..bafb64b 100644 --- a/openspace/agents/grounding_agent.py +++ b/openspace/agents/grounding_agent.py @@ -185,10 +185,17 @@ class GroundingAgent(BaseAgent): conversation_messages.append(msg) recent_messages = conversation_messages[-(keep_recent * 2):] if conversation_messages else [] - + truncated = system_messages.copy() if user_instruction: truncated.append(user_instruction) + dropped = len(conversation_messages) - len(recent_messages) + if dropped > 0: + truncated.append({ + "role": "user", + "content": f"[System: {dropped} earlier messages were truncated to save context. " + f"The original task instruction is preserved above.]" + }) truncated.extend(recent_messages) logger.info(f"After truncation: {len(truncated)} messages, " @@ -350,7 +357,7 @@ class GroundingAgent(BaseAgent): f"Tool results: {len(tool_results_this_iteration)}, " f"Content length: {len(assistant_content)} chars") - if len(assistant_content) > 0: + if len(assistant_content.strip()) > 0: logger.info(f"Iteration {current_iteration} - Assistant content preview: {repr(assistant_content[:300])}") consecutive_empty_responses = 0 # Reset counter on valid response else: diff --git a/openspace/llm/client.py b/openspace/llm/client.py index c7fd696..0c11c5e 100644 --- a/openspace/llm/client.py +++ b/openspace/llm/client.py @@ -169,9 +169,10 @@ def _infer_backend_from_tool_name(tool_name: str) -> Optional[str]: if not tool_name or not isinstance(tool_name, str): return None name = tool_name.strip() - # Dedup format: "server__toolname" -> use suffix + # Dedup format: "server__toolname" -> use suffix. + # Use rsplit to handle server names that themselves contain "__". if "__" in name: - name = name.split("__", 1)[-1] + name = name.rsplit("__", 1)[-1] shell_tools = {"shell_agent", "read_file", "write_file", "list_dir", "run_shell"} if name in shell_tools: return "shell" diff --git a/openspace/recording/manager.py b/openspace/recording/manager.py index fd6d7ed..4bbb382 100644 --- a/openspace/recording/manager.py +++ b/openspace/recording/manager.py @@ -668,8 +668,9 @@ class RecordingManager: if not tool_name or not isinstance(tool_name, str): return None name = tool_name.strip() + # Use rsplit to handle server names that themselves contain "__". if "__" in name: - name = name.split("__", 1)[-1] + name = name.rsplit("__", 1)[-1] shell_tools = {"shell_agent", "read_file", "write_file", "list_dir", "run_shell"} if name in shell_tools: return "shell" diff --git a/openspace/skill_engine/analyzer.py b/openspace/skill_engine/analyzer.py index fa5b74f..a809bd7 100644 --- a/openspace/skill_engine/analyzer.py +++ b/openspace/skill_engine/analyzer.py @@ -84,13 +84,17 @@ def _correct_skill_ids( if prefix and k.split("__")[0] == prefix ] - best, best_dist = None, 4 # threshold: edit distance ≤ 3 + # Adaptive threshold: tighten when many candidates share the prefix + max_dist = 2 if len(candidates) > 20 else 4 # ≤1 or ≤3 + best, best_dist, ambiguous = None, max_dist, False for cand in candidates: d = _edit_distance(raw_id, cand) if d < best_dist: - best, best_dist = cand, d + best, best_dist, ambiguous = cand, d, False + elif d == best_dist and cand != best: + ambiguous = True # multiple candidates at same distance - if best is not None: + if best is not None and not ambiguous: logger.info( f"Corrected LLM skill ID: {raw_id!r} → {best!r} " f"(edit_distance={best_dist})" diff --git a/openspace/skill_engine/evolver.py b/openspace/skill_engine/evolver.py index ad9afdc..818d8bf 100644 --- a/openspace/skill_engine/evolver.py +++ b/openspace/skill_engine/evolver.py @@ -201,6 +201,7 @@ class SkillEvolver: # evolved for each degraded tool. Keyed by tool_key. # Pruned when a tool leaves the problematic list (= recovered). self._addressed_degradations: Dict[str, Set[str]] = {} + self._degradation_lock = asyncio.Lock() # Track background tasks so they can be awaited on shutdown. self._background_tasks: Set[asyncio.Task] = set() @@ -219,7 +220,10 @@ class SkillEvolver: f"Waiting for {len(self._background_tasks)} background " f"evolution task(s) to finish..." ) - await asyncio.gather(*self._background_tasks, return_exceptions=True) + results = await asyncio.gather(*self._background_tasks, return_exceptions=True) + for r in results: + if isinstance(r, BaseException): + logger.warning(f"Background evolution task failed during shutdown: {r}") self._background_tasks.clear() async def evolve(self, ctx: EvolutionContext) -> Optional[SkillRecord]: @@ -306,6 +310,13 @@ class SkillEvolver: if not problematic_tools: return [] + async with self._degradation_lock: + return await self._process_tool_degradation_locked(problematic_tools) + + async def _process_tool_degradation_locked( + self, problematic_tools: List, + ) -> List[SkillRecord]: + """Inner body of process_tool_degradation, called under _degradation_lock.""" # Prune recovered tools: if a tool_key used to be tracked but is # no longer in the current problematic list, it recovered — clear # its addressed set so future re-degradation gets a fresh pass. diff --git a/openspace/skill_engine/store.py b/openspace/skill_engine/store.py index f7c7ee5..60d68a9 100644 --- a/openspace/skill_engine/store.py +++ b/openspace/skill_engine/store.py @@ -242,6 +242,9 @@ class SkillStore: If the main DB file is empty (0 bytes) but WAL/SHM companions exist, the database is unrecoverable — delete the companions so SQLite can start fresh. + + Safety: check that no other process currently has the DB open + before removing WAL/SHM, to avoid corrupting concurrent writes. """ if not self._db_path.exists(): return @@ -250,6 +253,19 @@ class SkillStore: if self._db_path.stat().st_size == 0 and ( wal.exists() or shm.exists() ): + # Verify no other process holds the database open. + import sqlite3 + try: + test_conn = sqlite3.connect(str(self._db_path), timeout=0.1) + test_conn.execute("PRAGMA journal_mode") + test_conn.close() + except sqlite3.OperationalError: + logger.info( + "DB appears locked by another process — " + "skipping WAL/SHM cleanup to avoid corruption" + ) + return + logger.warning( "Empty DB with WAL/SHM — removing for crash recovery" ) diff --git a/openspace/tool_layer.py b/openspace/tool_layer.py index 4cf4b7b..4d30f25 100644 --- a/openspace/tool_layer.py +++ b/openspace/tool_layer.py @@ -429,15 +429,24 @@ class OpenSpace: execution_context_p1 = {**execution_context} execution_context_p1["max_iterations"] = max_iterations - # Snapshot workspace files before skill-guided execution + # Snapshot workspace files before skill-guided execution. + # Record names AND mtimes so cleanup only removes files + # that were actually created during the skill phase. workspace_path = execution_context.get("workspace_dir", "") - pre_skill_files: set = set() + pre_skill_files: Dict[str, float] = {} + snapshot_time: float = 0.0 if workspace_path: try: from pathlib import Path as _P - pre_skill_files = { - f.name for f in _P(workspace_path).iterdir() - } if _P(workspace_path).exists() else set() + import time as _time + snapshot_time = _time.time() + ws_p = _P(workspace_path) + if ws_p.exists(): + for f in ws_p.iterdir(): + try: + pre_skill_files[f.name] = f.stat().st_mtime + except OSError: + pre_skill_files[f.name] = 0.0 except Exception: pass @@ -478,12 +487,21 @@ class OpenSpace: removed = 0 if ws.exists(): for f in list(ws.iterdir()): - if f.name not in pre_skill_files: - if f.is_dir(): - shutil.rmtree(f, ignore_errors=True) - else: - f.unlink(missing_ok=True) - removed += 1 + # Keep files that existed before skill phase + if f.name in pre_skill_files: + continue + # Keep files whose mtime predates the snapshot + # (created by external processes before we started) + try: + if snapshot_time and f.stat().st_mtime < snapshot_time: + continue + except OSError: + pass + if f.is_dir(): + shutil.rmtree(f, ignore_errors=True) + else: + f.unlink(missing_ok=True) + removed += 1 if removed: logger.info( f"[Phase 2 — Fallback] Cleaned {removed} artifact(s) " From a3a73406a5804f98691ffa351e60d7f1580c6d74 Mon Sep 17 00:00:00 2001 From: Dennis-yxchen Date: Mon, 6 Apr 2026 19:18:22 +0800 Subject: [PATCH 38/45] fix: resolve pr-60 review regressions --- openspace/__main__.py | 10 +++++-- openspace/agents/grounding_agent.py | 16 +++++++++-- openspace/host_detection/__init__.py | 9 +++++- openspace/host_detection/openclaw.py | 8 +++++- openspace/host_detection/resolver.py | 9 ++++-- openspace/llm/client.py | 5 ++-- openspace/mcp_server.py | 8 +++++- openspace/tool_layer.py | 42 ++++++++-------------------- 8 files changed, 65 insertions(+), 42 deletions(-) diff --git a/openspace/__main__.py b/openspace/__main__.py index 4a1d6e0..153eebc 100644 --- a/openspace/__main__.py +++ b/openspace/__main__.py @@ -322,7 +322,13 @@ async def refresh_mcp_cache(config_path: Optional[str] = None): def _load_config(args) -> OpenSpaceConfig: """Load configuration""" import os - from openspace.host_detection import build_llm_kwargs, build_grounding_config_path + from openspace.host_detection import ( + build_grounding_config_path, + build_llm_kwargs, + load_runtime_env, + ) + + load_runtime_env() cli_overrides = {} if args.max_iterations is not None: @@ -494,4 +500,4 @@ def run_main(): if __name__ == "__main__": - run_main() \ No newline at end of file + run_main() diff --git a/openspace/agents/grounding_agent.py b/openspace/agents/grounding_agent.py index bafb64b..b75b5f3 100644 --- a/openspace/agents/grounding_agent.py +++ b/openspace/agents/grounding_agent.py @@ -754,9 +754,19 @@ class GroundingAgent(BaseAgent): } }) - # Use dedicated visual analysis model if configured, otherwise use main LLM model + # Resolve visual-model credentials independently when the visual + # model differs from the main reasoning model. visual_model = self._visual_analysis_model or (self._llm_client.model if self._llm_client else "openrouter/anthropic/claude-sonnet-4.5") - _llm_extra = getattr(self._llm_client, 'litellm_kwargs', {}) if self._llm_client else {} + _llm_extra = {} + if self._llm_client and visual_model == self._llm_client.model: + _llm_extra = getattr(self._llm_client, 'litellm_kwargs', {}) or {} + elif self._visual_analysis_model: + try: + from openspace.host_detection import build_llm_kwargs + visual_model, _llm_extra = build_llm_kwargs(visual_model) + except Exception as e: + logger.debug(f"Failed to resolve dedicated visual model credentials: {e}") + _llm_extra = {} response = await asyncio.wait_for( litellm.acompletion( model=visual_model, @@ -1225,4 +1235,4 @@ class GroundingAgent(BaseAgent): "step": self.step, "instruction": instruction, } - ) \ No newline at end of file + ) diff --git a/openspace/host_detection/__init__.py b/openspace/host_detection/__init__.py index d8125b3..cb31b95 100644 --- a/openspace/host_detection/__init__.py +++ b/openspace/host_detection/__init__.py @@ -21,7 +21,11 @@ Supported host agents: import logging from typing import Dict, Optional -from openspace.host_detection.resolver import build_llm_kwargs, build_grounding_config_path +from openspace.host_detection.resolver import ( + build_grounding_config_path, + build_llm_kwargs, + load_runtime_env, +) from openspace.host_detection.nanobot import ( get_openai_api_key as _nanobot_get_openai_api_key, read_nanobot_mcp_env, @@ -29,6 +33,7 @@ from openspace.host_detection.nanobot import ( ) from openspace.host_detection.openclaw import ( get_openclaw_openai_api_key as _openclaw_get_openai_api_key, + is_openclaw_host, read_openclaw_skill_env, try_read_openclaw_config, ) @@ -80,10 +85,12 @@ def get_openai_api_key() -> Optional[str]: __all__ = [ "build_llm_kwargs", "build_grounding_config_path", + "load_runtime_env", "get_openai_api_key", "read_host_mcp_env", "read_nanobot_mcp_env", "try_read_nanobot_config", + "is_openclaw_host", "read_openclaw_skill_env", "try_read_openclaw_config", ] diff --git a/openspace/host_detection/openclaw.py b/openspace/host_detection/openclaw.py index a97104a..6a109e0 100644 --- a/openspace/host_detection/openclaw.py +++ b/openspace/host_detection/openclaw.py @@ -279,6 +279,13 @@ def get_openclaw_openai_api_key() -> Optional[str]: return None +def is_openclaw_host() -> bool: + """Detect if the current environment is running under OpenClaw.""" + if os.environ.get("OPENCLAW_STATE_DIR") or os.environ.get("OPENCLAW_CONFIG_PATH"): + return True + return _resolve_openclaw_config_path() is not None + + def try_read_openclaw_config(model: str) -> Optional[Dict[str, Any]]: """Read LLM credentials from OpenClaw's env-style config blocks.""" env_block = _get_openclaw_env("openspace") @@ -306,4 +313,3 @@ def try_read_openclaw_config(model: str) -> Optional[Dict[str, Any]]: return result - diff --git a/openspace/host_detection/resolver.py b/openspace/host_detection/resolver.py index ee43408..612c044 100644 --- a/openspace/host_detection/resolver.py +++ b/openspace/host_detection/resolver.py @@ -62,6 +62,11 @@ def _load_env_once() -> None: load_dotenv() +def load_runtime_env() -> None: + """Public wrapper for one-time runtime .env loading.""" + _load_env_once() + + def _pick_first_env(names: tuple[str, ...]) -> str: for name in names: value = os.environ.get(name, "").strip() @@ -148,10 +153,10 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: resolved_model or _DEFAULT_MODEL ) - # --- Tier 3: host config fallback (only when no local keys) --- + # --- Tier 3: host config fallback (blocked only by provider-native env) --- host_config = None host_source = None - if not has_explicit_llm_override and not provider_native_env_used: + if not provider_native_env_used: from openspace.host_detection.nanobot import try_read_nanobot_config host_config = try_read_nanobot_config(model) if host_config: diff --git a/openspace/llm/client.py b/openspace/llm/client.py index 0c11c5e..611cf87 100644 --- a/openspace/llm/client.py +++ b/openspace/llm/client.py @@ -9,8 +9,9 @@ from openspace.grounding.core.types import ToolSchema, ToolResult, ToolStatus from openspace.grounding.core.tool import BaseTool from openspace.utils.logging import Logger -# .env loading is centralized in host_detection.resolver._load_env_once() -# which is called by build_llm_kwargs / build_grounding_config_path. +# .env loading is centralized in host_detection.resolver.load_runtime_env(). +# CLI/MCP entrypoints call it before reading startup env vars, and the +# resolver helpers also call it defensively. # Disable LiteLLM verbose logging to prevent stdout blocking with large tool schemas litellm.set_verbose = False diff --git a/openspace/mcp_server.py b/openspace/mcp_server.py index 168485c..ba4ca31 100644 --- a/openspace/mcp_server.py +++ b/openspace/mcp_server.py @@ -137,7 +137,13 @@ async def _get_openspace(): logger.info("Initializing OpenSpace engine ...") from openspace.tool_layer import OpenSpace, OpenSpaceConfig - from openspace.host_detection import build_llm_kwargs, build_grounding_config_path + from openspace.host_detection import ( + build_grounding_config_path, + build_llm_kwargs, + load_runtime_env, + ) + + load_runtime_env() env_model = os.environ.get("OPENSPACE_MODEL", "") workspace = os.environ.get("OPENSPACE_WORKSPACE") diff --git a/openspace/tool_layer.py b/openspace/tool_layer.py index 4d30f25..c2eaca9 100644 --- a/openspace/tool_layer.py +++ b/openspace/tool_layer.py @@ -429,24 +429,15 @@ class OpenSpace: execution_context_p1 = {**execution_context} execution_context_p1["max_iterations"] = max_iterations - # Snapshot workspace files before skill-guided execution. - # Record names AND mtimes so cleanup only removes files - # that were actually created during the skill phase. + # Snapshot workspace files before skill-guided execution workspace_path = execution_context.get("workspace_dir", "") - pre_skill_files: Dict[str, float] = {} - snapshot_time: float = 0.0 + pre_skill_files: set = set() if workspace_path: try: from pathlib import Path as _P - import time as _time - snapshot_time = _time.time() - ws_p = _P(workspace_path) - if ws_p.exists(): - for f in ws_p.iterdir(): - try: - pre_skill_files[f.name] = f.stat().st_mtime - except OSError: - pre_skill_files[f.name] = 0.0 + pre_skill_files = { + f.name for f in _P(workspace_path).iterdir() + } if _P(workspace_path).exists() else set() except Exception: pass @@ -487,21 +478,12 @@ class OpenSpace: removed = 0 if ws.exists(): for f in list(ws.iterdir()): - # Keep files that existed before skill phase - if f.name in pre_skill_files: - continue - # Keep files whose mtime predates the snapshot - # (created by external processes before we started) - try: - if snapshot_time and f.stat().st_mtime < snapshot_time: - continue - except OSError: - pass - if f.is_dir(): - shutil.rmtree(f, ignore_errors=True) - else: - f.unlink(missing_ok=True) - removed += 1 + if f.name not in pre_skill_files: + if f.is_dir(): + shutil.rmtree(f, ignore_errors=True) + else: + f.unlink(missing_ok=True) + removed += 1 if removed: logger.info( f"[Phase 2 — Fallback] Cleaned {removed} artifact(s) " @@ -952,4 +934,4 @@ class OpenSpace: if self._running: status = "running" backends = ", ".join(self.config.backend_scope) if self.config.backend_scope else "all" - return f"" \ No newline at end of file + return f"" From a23792a66b8c956b9ade4f625e6142266f322789 Mon Sep 17 00:00:00 2001 From: Dennis-yxchen Date: Mon, 6 Apr 2026 20:00:16 +0800 Subject: [PATCH 39/45] fix: tighten pr-60 review follow-ups --- openspace/agents/grounding_agent.py | 12 +++++++----- openspace/host_detection/resolver.py | 4 ++-- openspace/skill_engine/store.py | 16 ---------------- 3 files changed, 9 insertions(+), 23 deletions(-) diff --git a/openspace/agents/grounding_agent.py b/openspace/agents/grounding_agent.py index b75b5f3..d64116c 100644 --- a/openspace/agents/grounding_agent.py +++ b/openspace/agents/grounding_agent.py @@ -187,15 +187,17 @@ class GroundingAgent(BaseAgent): recent_messages = conversation_messages[-(keep_recent * 2):] if conversation_messages else [] truncated = system_messages.copy() - if user_instruction: - truncated.append(user_instruction) dropped = len(conversation_messages) - len(recent_messages) if dropped > 0: truncated.append({ - "role": "user", - "content": f"[System: {dropped} earlier messages were truncated to save context. " - f"The original task instruction is preserved above.]" + "role": "system", + "content": ( + f"{self._ITERATION_GUIDANCE_PREFIX} {dropped} earlier messages were " + "truncated to save context. The original task instruction is preserved below." + ), }) + if user_instruction: + truncated.append(user_instruction) truncated.extend(recent_messages) logger.info(f"After truncation: {len(truncated)} messages, " diff --git a/openspace/host_detection/resolver.py b/openspace/host_detection/resolver.py index 612c044..90b095b 100644 --- a/openspace/host_detection/resolver.py +++ b/openspace/host_detection/resolver.py @@ -153,10 +153,10 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: resolved_model or _DEFAULT_MODEL ) - # --- Tier 3: host config fallback (blocked only by provider-native env) --- + # --- Tier 3: host config fallback (only when no local keys) --- host_config = None host_source = None - if not provider_native_env_used: + if not has_explicit_llm_override and not provider_native_env_used: from openspace.host_detection.nanobot import try_read_nanobot_config host_config = try_read_nanobot_config(model) if host_config: diff --git a/openspace/skill_engine/store.py b/openspace/skill_engine/store.py index 60d68a9..f7c7ee5 100644 --- a/openspace/skill_engine/store.py +++ b/openspace/skill_engine/store.py @@ -242,9 +242,6 @@ class SkillStore: If the main DB file is empty (0 bytes) but WAL/SHM companions exist, the database is unrecoverable — delete the companions so SQLite can start fresh. - - Safety: check that no other process currently has the DB open - before removing WAL/SHM, to avoid corrupting concurrent writes. """ if not self._db_path.exists(): return @@ -253,19 +250,6 @@ class SkillStore: if self._db_path.stat().st_size == 0 and ( wal.exists() or shm.exists() ): - # Verify no other process holds the database open. - import sqlite3 - try: - test_conn = sqlite3.connect(str(self._db_path), timeout=0.1) - test_conn.execute("PRAGMA journal_mode") - test_conn.close() - except sqlite3.OperationalError: - logger.info( - "DB appears locked by another process — " - "skipping WAL/SHM cleanup to avoid corruption" - ) - return - logger.warning( "Empty DB with WAL/SHM — removing for crash recovery" ) From 114f06bd4197adc6889b1037171b8bffe577f170 Mon Sep 17 00:00:00 2001 From: Xu Lingrui <151314054+xlrrrr@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:06:01 +0800 Subject: [PATCH 40/45] feat: support SSE and streamable HTTP for OpenSpace MCP * feat: add HTTP MCP startup modes * feat: add HTTP MCP startup modes * feat: add HTTP MCP startup modes --- README.md | 12 ++ README_CN.md | 12 ++ .../backends/mcp/transport/connectors/http.py | 125 ++++++++++-------- openspace/host_skills/README.md | 67 +++++++++- openspace/host_skills/delegate-task/SKILL.md | 3 +- openspace/mcp_server.py | 78 ++++++++++- 6 files changed, 228 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 871298d..257e49e 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,18 @@ Works with any agent that supports skills (`SKILL.md`) — [Claude Code](https:/ > [!TIP] > Credentials (API key, model) are **auto-detected** from your agent's config; you usually don't need to set them manually. +> [!NOTE] +> OpenSpace supports 3 launch modes: +> - **stdio**: keep `command: "openspace-mcp"` in the host config. +> - **SSE**: start `openspace-mcp --transport sse --host 127.0.0.1 --port 8080`. +> - **streamable HTTP**: start `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081`. +> +> Common remote endpoints: +> - SSE endpoint: `http://127.0.0.1:8080/sse` +> - streamable HTTP endpoint: `http://127.0.0.1:8081/mcp` +> +> `stdio` is the simplest option. HTTP modes keep OpenSpace as a standalone server, but **host-specific registration syntax** and **host-side timeouts** still apply. + **② Copy skills** into your agent's skills directory: ```bash diff --git a/README_CN.md b/README_CN.md index 09aee71..6e3b77b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -195,6 +195,18 @@ openspace-mcp --help # 验证安装 > [!TIP] > 凭证(API 密钥、模型)会从你的 Agent 配置中**自动检测**,通常无需手动设置。 +> [!NOTE] +> OpenSpace 支持 3 种启动方式: +> - **stdio**:在宿主配置里保留 `command: "openspace-mcp"`。 +> - **SSE**:先启动 `openspace-mcp --transport sse --host 127.0.0.1 --port 8080`。 +> - **streamable HTTP**:先启动 `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081`。 +> +> 通用远端 endpoint: +> - SSE: `http://127.0.0.1:8080/sse` +> - streamable HTTP: `http://127.0.0.1:8081/mcp` +> +> `stdio` 最简单。HTTP 模式会把 OpenSpace 作为独立服务常驻,但 **不同宿主的注册写法不同**,而且 **调用方自己的 timeout 仍然生效**。 + **② 将 Skill 复制**到你的 Agent Skill 目录: ```bash diff --git a/openspace/grounding/backends/mcp/transport/connectors/http.py b/openspace/grounding/backends/mcp/transport/connectors/http.py index 099f112..012440f 100644 --- a/openspace/grounding/backends/mcp/transport/connectors/http.py +++ b/openspace/grounding/backends/mcp/transport/connectors/http.py @@ -30,6 +30,14 @@ from openspace.grounding.backends.mcp.transport.connectors.base import MCPBaseCo logger = Logger.get_logger(__name__) +def _build_sse_candidate_urls(base_url: str) -> list[str]: + """Try the common FastMCP `/sse` endpoint before the raw base URL.""" + normalized = base_url.rstrip("/") + if normalized.endswith("/sse"): + return [normalized] + return [f"{normalized}/sse", normalized] + + class HttpConnector(MCPBaseConnector): """Connector for MCP implementations using HTTP transport. @@ -210,69 +218,72 @@ class HttpConnector(MCPBaseConnector): except (asyncio.TimeoutError, Exception): pass - # Try SSE fallback - try: - logger.debug(f"Attempting SSE fallback connection to: {self.base_url}") - connection_manager = SseConnectionManager( - self.base_url, self.headers, self.timeout, self.sse_read_timeout - ) - - # Test the connection by starting it with built-in timeout - read_stream, write_stream = await connection_manager.start(timeout=self.timeout) - - # Create and verify ClientSession - test_client = ClientSession(read_stream, write_stream, sampling_callback=None) - - # Add timeout to __aenter__ - use asyncio.wait_for instead of anyio.fail_after - # to avoid cancel scope conflicts with background tasks + # Try SSE fallback. FastMCP commonly exposes legacy SSE on `/sse`, + # but some callers may already pass the full endpoint. + for sse_url in _build_sse_candidate_urls(self.base_url): + connection_manager = None try: - await asyncio.wait_for(test_client.__aenter__(), timeout=self.timeout) - except asyncio.TimeoutError: - raise TimeoutError(f"ClientSession enter timed out after {self.timeout}s") + logger.debug(f"Attempting SSE fallback connection to: {sse_url}") + connection_manager = SseConnectionManager( + sse_url, self.headers, self.timeout, self.sse_read_timeout + ) - try: + # Test the connection by starting it with built-in timeout + read_stream, write_stream = await connection_manager.start(timeout=self.timeout) + + # Create and verify ClientSession + test_client = ClientSession(read_stream, write_stream, sampling_callback=None) + + # Add timeout to __aenter__ - use asyncio.wait_for instead of anyio.fail_after + # to avoid cancel scope conflicts with background tasks try: - await asyncio.wait_for(test_client.initialize(), timeout=self.timeout) + await asyncio.wait_for(test_client.__aenter__(), timeout=self.timeout) except asyncio.TimeoutError: - raise TimeoutError(f"initialize() timed out after {self.timeout}s") - - try: - await asyncio.wait_for(test_client.list_tools(), timeout=self.timeout) - except asyncio.TimeoutError: - raise TimeoutError(f"list_tools() timed out after {self.timeout}s") - - # SUCCESS! Keep the client session (don't close it, closing destroys the streams) - # Store it directly as the client_session for later use - self.transport_type = "SSE" - self._connection_manager = connection_manager - self._connection = connection_manager.get_streams() - self.client_session = test_client # Reuse the working session - logger.debug("SSE transport selected") - return - except TimeoutError: - try: - await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2) - except (asyncio.TimeoutError, Exception): - pass - raise - except Exception as init_error: - # Clean up the test client only on error - try: - await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2) - except (asyncio.TimeoutError, Exception): - pass - raise init_error + raise TimeoutError(f"ClientSession enter timed out after {self.timeout}s") - except Exception as e: - sse_error = e - logger.debug(f"SSE failed: {e}") - - # Clean up the failed connection manager - if connection_manager: try: - await asyncio.wait_for(connection_manager.stop(), timeout=2) - except (asyncio.TimeoutError, Exception): - pass + try: + await asyncio.wait_for(test_client.initialize(), timeout=self.timeout) + except asyncio.TimeoutError: + raise TimeoutError(f"initialize() timed out after {self.timeout}s") + + try: + await asyncio.wait_for(test_client.list_tools(), timeout=self.timeout) + except asyncio.TimeoutError: + raise TimeoutError(f"list_tools() timed out after {self.timeout}s") + + # SUCCESS! Keep the client session (don't close it, closing destroys the streams) + # Store it directly as the client_session for later use + self.transport_type = "SSE" + self._connection_manager = connection_manager + self._connection = connection_manager.get_streams() + self.client_session = test_client # Reuse the working session + logger.debug("SSE transport selected") + return + except TimeoutError: + try: + await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2) + except (asyncio.TimeoutError, Exception): + pass + raise + except Exception as init_error: + # Clean up the test client only on error + try: + await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2) + except (asyncio.TimeoutError, Exception): + pass + raise init_error + + except Exception as e: + sse_error = e + logger.debug(f"SSE failed for {sse_url}: {e}") + + # Clean up the failed connection manager + if connection_manager: + try: + await asyncio.wait_for(connection_manager.stop(), timeout=2) + except (asyncio.TimeoutError, Exception): + pass # Both MCP transports failed, try simple JSON-RPC HTTP as last resort # This is useful for custom MCP servers that don't implement proper MCP transports diff --git a/openspace/host_skills/README.md b/openspace/host_skills/README.md index 0a555ed..5faf1fd 100644 --- a/openspace/host_skills/README.md +++ b/openspace/host_skills/README.md @@ -2,6 +2,17 @@ This guide covers **agent-specific setup** for integrating OpenSpace. For installation and general concepts, see the [main README](../../README.md#-quick-start). +**Quick recommendation:** +- Use **stdio** if you want the simplest setup. +- For **nanobot**, prefer **SSE** if you want OpenSpace to run as a standalone server. +- For **openclaw**, prefer **streamable-http** for remote HTTP transport. + +**Common remote endpoints:** +- Start `openspace-mcp --transport sse --host 127.0.0.1 --port 8080` and use `http://127.0.0.1:8080/sse` +- Start `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081` and use `http://127.0.0.1:8081/mcp` + +The endpoint is common; the **host config syntax is not**. nanobot uses `tools.mcpServers`, while openclaw uses `openclaw mcp set`. + **Pick your agent:** | Agent | Setup Guide | @@ -21,7 +32,7 @@ cp -r host_skills/skill-discovery/ /path/to/nanobot/nanobot/skills/ cp -r host_skills/delegate-task/ /path/to/nanobot/nanobot/skills/ ``` -### 2. Add MCP server to `~/.nanobot/config.json` +### 2. Option A: stdio (simplest) ```json { @@ -44,6 +55,40 @@ cp -r host_skills/delegate-task/ /path/to/nanobot/nanobot/skills/ > [!TIP] > LLM credentials are auto-detected from nanobot's `providers.*` config — no need to set `OPENSPACE_LLM_API_KEY`. +### 3. Option B: remote HTTP transport + +```json +{ + "tools": { + "mcpServers": { + "openspace": { + "type": "sse", + "url": "http://127.0.0.1:8080/sse", + "toolTimeout": 1200 + } + } + } +} +``` + +Or: + +```json +{ + "tools": { + "mcpServers": { + "openspace": { + "type": "streamableHttp", + "url": "http://127.0.0.1:8081/mcp", + "toolTimeout": 1200 + } + } + } +} +``` + +`toolTimeout` still matters here. Changing transport to `sse` or `streamableHttp` does **not** remove nanobot's per-call timeout for slow MCP tools. + --- ## Setup for openclaw @@ -55,7 +100,7 @@ cp -r host_skills/skill-discovery/ /path/to/openclaw/skills/ cp -r host_skills/delegate-task/ /path/to/openclaw/skills/ ``` -### 2. Register MCP server with env vars +### 2. Option A: stdio via mcporter openclaw uses [mcporter](https://github.com/steipete/mcporter) as its MCP runtime. Register the server and pass env vars in one command: @@ -66,6 +111,20 @@ mcporter config add openspace --command "openspace-mcp" \ --env OPENSPACE_API_KEY=sk-xxx ``` +### 3. Option B: remote HTTP transport + +```bash +openclaw mcp set openspace '{"url":"http://127.0.0.1:8081/mcp","transport":"streamable-http","connectionTimeoutMs":10000}' +``` + +If you specifically want legacy SSE instead, OpenClaw also supports: + +```bash +openclaw mcp set openspace '{"url":"http://127.0.0.1:8080","connectionTimeoutMs":10000}' +``` + +`connectionTimeoutMs` controls connection establishment for the remote server. It does **not** guarantee unlimited runtime for a long-running MCP tool call. + --- ## Environment Variables (Agent-Specific) @@ -93,7 +152,7 @@ All tools default to `"all"` (local + cloud) and **automatically fall back** to ``` Your Agent (nanobot / openclaw / ...) │ - │ MCP protocol (stdio) + │ MCP protocol (stdio | HTTP/SSE | streamable-http) ▼ openspace-mcp ← 4 tools exposed ├── execute_task ← multi-step grounding agent loop @@ -112,4 +171,4 @@ The two host skills teach the agent **when and how** to call these tools: Skills auto-evolve inside `execute_task` (**FIX** / **DERIVED** / **CAPTURED**). After every call, your agent reports results to the user via its messaging tool. > [!NOTE] -> For full parameter tables, examples, and decision trees, see each skill's SKILL.md directly. \ No newline at end of file +> For full parameter tables, examples, and decision trees, see each skill's SKILL.md directly. diff --git a/openspace/host_skills/delegate-task/SKILL.md b/openspace/host_skills/delegate-task/SKILL.md index ce94061..04b2b90 100644 --- a/openspace/host_skills/delegate-task/SKILL.md +++ b/openspace/host_skills/delegate-task/SKILL.md @@ -5,7 +5,7 @@ description: Delegate tasks to OpenSpace — a full-stack autonomous worker for # Delegate Tasks to OpenSpace -OpenSpace is connected as an MCP server. You have 4 tools available: `execute_task`, `search_skills`, `fix_skill`, `upload_skill`. +OpenSpace is connected as an MCP server. Whether the host uses `stdio`, `sse`, or `streamable-http`, you have the same 4 tools available: `execute_task`, `search_skills`, `fix_skill`, `upload_skill`. ## When to use @@ -127,5 +127,6 @@ upload_skill( ## Notes - `execute_task` may take minutes — this is expected for multi-step tasks. +- If `execute_task` times out, first check the host's MCP timeout settings. Changing from `stdio` to HTTP (`sse` or `streamable-http`) does not remove host-side per-call time limits. - `upload_skill` requires a cloud API key; if it fails, the evolved skill is still saved locally. - After every OpenSpace call, **tell the user** what happened: task result, any evolved skills, and your upload decision. diff --git a/openspace/mcp_server.py b/openspace/mcp_server.py index ba4ca31..9730a34 100644 --- a/openspace/mcp_server.py +++ b/openspace/mcp_server.py @@ -7,8 +7,9 @@ Exposes the following tools to MCP clients: upload_skill — Upload a local skill to cloud (pre-saved metadata, bot decides visibility) Usage: - python -m openspace.mcp_server # stdio (default) + python -m openspace.mcp_server # auto (TTY -> SSE, MCP host -> stdio) python -m openspace.mcp_server --transport sse # SSE on port 8080 + python -m openspace.mcp_server --transport streamable-http # Streamable HTTP on port 8080 python -m openspace.mcp_server --port 9090 # SSE on custom port Environment variables: see ``openspace/host_detection/`` and ``openspace/cloud/auth.py``. @@ -900,14 +901,77 @@ def run_mcp_server() -> None: """Console-script entry point for ``openspace-mcp``.""" import argparse - parser = argparse.ArgumentParser(description="OpenSpace MCP Server") - parser.add_argument("--transport", choices=["stdio", "sse"], default="stdio") - parser.add_argument("--port", type=int, default=8080) - args = parser.parse_args() + def _port_flag_was_set(argv: list[str]) -> bool: + return any(arg == "--port" or arg.startswith("--port=") for arg in argv) - if args.transport == "sse": - mcp.run(transport="sse", sse_params={"port": args.port}) + def _parse_port_from_env(default: int = 8080) -> int: + raw_port = os.environ.get("OPENSPACE_MCP_PORT", "").strip() + if not raw_port: + return default + try: + return int(raw_port) + except ValueError: + logger.warning( + "Ignoring invalid OPENSPACE_MCP_PORT=%r; falling back to %d.", + raw_port, + default, + ) + return default + + def _parse_host_from_env(default: str = "127.0.0.1") -> str: + return os.environ.get("OPENSPACE_MCP_HOST", "").strip() or default + + def _resolve_transport(requested_transport: str, argv: list[str]) -> str: + if requested_transport in ("stdio", "sse", "streamable-http"): + return requested_transport + + env_transport = os.environ.get("OPENSPACE_MCP_TRANSPORT", "").strip().lower() + if env_transport: + if env_transport in ("stdio", "sse", "streamable-http"): + return env_transport + logger.warning( + "Ignoring invalid OPENSPACE_MCP_TRANSPORT=%r; expected 'stdio', 'sse', or 'streamable-http'.", + env_transport, + ) + + # Treat an explicit port override as an HTTP/SSE intent. This keeps the + # CLI behavior aligned with the usage examples above. + if _port_flag_was_set(argv): + return "sse" + + stdin_is_tty = hasattr(sys.stdin, "isatty") and sys.stdin.isatty() + stdout_is_tty = _real_stdout.isatty() + return "sse" if stdin_is_tty and stdout_is_tty else "stdio" + + argv = sys.argv[1:] + parser = argparse.ArgumentParser(description="OpenSpace MCP Server") + parser.add_argument( + "--transport", + choices=["auto", "stdio", "sse", "streamable-http"], + default="auto", + ) + parser.add_argument("--host", default=_parse_host_from_env()) + parser.add_argument("--port", type=int, default=_parse_port_from_env()) + args = parser.parse_args(argv) + + transport = _resolve_transport(args.transport, argv) + + if transport == "sse": + mcp.settings.host = args.host + mcp.settings.port = args.port + logger.info("Starting OpenSpace MCP server with SSE transport on port %s", args.port) + mcp.run(transport="sse") + elif transport == "streamable-http": + mcp.settings.host = args.host + mcp.settings.port = args.port + logger.info( + "Starting OpenSpace MCP server with streamable HTTP transport on %s:%s", + args.host, + args.port, + ) + mcp.run(transport="streamable-http") else: + logger.info("Starting OpenSpace MCP server with stdio transport") mcp.run(transport="stdio") From 4791133e11a45872b063c75968965c447e835455 Mon Sep 17 00:00:00 2001 From: spidercatfly Date: Tue, 7 Apr 2026 20:44:53 +0800 Subject: [PATCH 41/45] update readme --- README.md | 3 +++ README_CN.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/README.md b/README.md index 257e49e..c4e486c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ ## 📢 News +- **2026-04-07** 🌐 OpenSpace MCP now supports standalone **SSE** and **streamable HTTP** startup, making it easier for remote hosts to connect over HTTP instead of stdio and bypass stdio-bound MCP server timeout bottlenecks. See the [host integration guide](openspace/host_skills/README.md) for setup details. +- **2026-04-06** 🛠️ Fixed multiple runtime issues across grounding, MCP serving, skill evolution, and persistence, improving execution stability and recovery in long-running workflows. +- **2026-04-05** 🧭 Cleaned up LLM credential resolution: centralized `.env` loading, improved host config auto-detection, and made provider-native env handling more consistent. - **2026-04-03** 🚀 Released **v0.1.0** — Skill quality monitoring: structural patterns extracted from high-quality skills now evaluate every new submission daily. Faster, more relevant cloud search. Production-grade vertical skill clusters emerging organically from the community. Frontend now supports Chinese (zh) i18n. - **2026-04-02** ⚡ Cloud search upgraded for higher relevance and lower latency. - **2026-03-31** 🛡️ Security hardening: hardened zip extraction and `import_skill` against path traversal. CLI now respects `OPENSPACE_MODEL` and `OPENSPACE_LLM_*` env vars; MiniMax compatibility; workflow ID collision fixes. diff --git a/README_CN.md b/README_CN.md index 6e3b77b..b115ab0 100644 --- a/README_CN.md +++ b/README_CN.md @@ -25,6 +25,9 @@ ## 📢 最新动态 +- **2026-04-07** 🌐 OpenSpace MCP 新增独立 **SSE** 与 **streamable HTTP** 启动方式,便于远端 host 通过 HTTP 接入,绕过基于 stdio 的 MCP server timeout 瓶颈。具体接入方式见 [host integration 文档](openspace/host_skills/README.md)。 +- **2026-04-06** 🛠️ 修复多项运行时问题,覆盖 grounding、MCP 服务、skill 进化与持久化链路,长流程执行的稳定性与恢复能力进一步提升。 +- **2026-04-05** 🧭 LLM 凭证解析清理完成:统一 `.env` 加载逻辑,改进宿主配置自动识别,并让 provider 原生环境变量处理更一致。 - **2026-04-03** 🚀 发布 **v0.1.0** — Skill 质量监控上线:从优质 Skill 中提取结构模式,每日自动评估所有新提交;云端搜索全面升级,匹配更准、响应更快;社区自发形成生产级垂直 Skill 集群。前端新增中文(zh)国际化支持。 - **2026-04-02** ⚡ 云端搜索升级,提升匹配质量、降低响应延迟。 - **2026-03-31** 🛡️ 安全加固:zip 解压与 `import_skill` 新增路径穿越防护;CLI 启动时读取 `OPENSPACE_MODEL` 及 `OPENSPACE_LLM_*` 环境变量;修复 MiniMax 兼容性问题与 workflow ID 冲突。 From f01d408ac49a7384045c7139ebe8668f08b9d8dc Mon Sep 17 00:00:00 2001 From: Xu Lingrui <151314054+xlrrrr@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:06:38 +0800 Subject: [PATCH 42/45] feat: add communication adapters and agent refactoring (#74) --- .gitignore | 5 +- openspace/.env.example | 7 + openspace/__main__.py | 51 + openspace/agents/grounding_agent.py | 376 +------- openspace/agents/message_utils.py | 227 +++++ openspace/agents/visual_analyzer.py | 250 +++++ openspace/communication/__init__.py | 27 + openspace/communication/adapters/__init__.py | 9 + openspace/communication/adapters/base.py | 63 ++ openspace/communication/adapters/feishu.py | 901 ++++++++++++++++++ openspace/communication/adapters/whatsapp.py | 462 +++++++++ openspace/communication/attachment_cache.py | 123 +++ .../bridges/whatsapp/allowlist.js | 71 ++ .../communication/bridges/whatsapp/bridge.js | 577 +++++++++++ .../bridges/whatsapp/package.json | 17 + openspace/communication/config.py | 375 ++++++++ openspace/communication/gateway.py | 577 +++++++++++ openspace/communication/gateway_runtime.py | 252 +++++ openspace/communication/policy.py | 61 ++ openspace/communication/runtime_manager.py | 149 +++ openspace/communication/session_store.py | 199 ++++ openspace/communication/types.py | 163 ++++ openspace/config/README.md | 41 + .../config/config_communication.json.example | 65 ++ openspace/host_detection/resolver.py | 24 + openspace/tool_layer.py | 45 +- pyproject.toml | 12 +- 27 files changed, 4791 insertions(+), 338 deletions(-) create mode 100644 openspace/agents/message_utils.py create mode 100644 openspace/agents/visual_analyzer.py create mode 100644 openspace/communication/__init__.py create mode 100644 openspace/communication/adapters/__init__.py create mode 100644 openspace/communication/adapters/base.py create mode 100644 openspace/communication/adapters/feishu.py create mode 100644 openspace/communication/adapters/whatsapp.py create mode 100644 openspace/communication/attachment_cache.py create mode 100644 openspace/communication/bridges/whatsapp/allowlist.js create mode 100644 openspace/communication/bridges/whatsapp/bridge.js create mode 100644 openspace/communication/bridges/whatsapp/package.json create mode 100644 openspace/communication/config.py create mode 100644 openspace/communication/gateway.py create mode 100644 openspace/communication/gateway_runtime.py create mode 100644 openspace/communication/policy.py create mode 100644 openspace/communication/runtime_manager.py create mode 100644 openspace/communication/session_store.py create mode 100644 openspace/communication/types.py create mode 100644 openspace/config/config_communication.json.example diff --git a/.gitignore b/.gitignore index 77dd181..51d57b2 100644 --- a/.gitignore +++ b/.gitignore @@ -28,9 +28,12 @@ build/ .env !.env.example -# MCP files +# MCP config files openspace/config/config_mcp.json +# Communication config files +openspace/config/config_communication.json + # Logs logs/ diff --git a/openspace/.env.example b/openspace/.env.example index d8e9465..00c4fd6 100644 --- a/openspace/.env.example +++ b/openspace/.env.example @@ -38,6 +38,13 @@ OPENROUTER_API_KEY= # OPENSPACE_LLM_API_KEY=sk-xxx # OPENSPACE_LLM_API_BASE=https://openrouter.ai/api/v1 +# --- Option C: Local Ollama --- +# For ollama/* models, set OPENSPACE_MODEL and the local Ollama endpoint. +# +# OPENSPACE_MODEL=ollama/qwen3-coder:30b +# OLLAMA_API_BASE=http://127.0.0.1:11434 +# OLLAMA_API_KEY=ollama + # ── OpenSpace Cloud (optional) ────────────────────────────── # Register at https://open-space.cloud to get your key. # Enables cloud skill search & upload; local features work without it. diff --git a/openspace/__main__.py b/openspace/__main__.py index 153eebc..ba67d56 100644 --- a/openspace/__main__.py +++ b/openspace/__main__.py @@ -158,6 +158,43 @@ def _create_argument_parser() -> argparse.ArgumentParser: '--config', '-c', type=str, help='MCP configuration file path' ) + + communication_parser = subparsers.add_parser( + 'communication', + help='Run the communication gateway' + ) + communication_parser.add_argument( + '--config', + type=str, + dest='communication_config', + help='Communication configuration file path' + ) + communication_subparsers = communication_parser.add_subparsers( + dest='communication_command', + help='Communication gateway commands' + ) + communication_run_parser = communication_subparsers.add_parser( + 'run', + help='Start the communication gateway' + ) + communication_run_parser.add_argument( + '--config', + type=str, + dest='communication_config', + help='Communication configuration file path' + ) + communication_health_parser = communication_subparsers.add_parser( + 'health', + help='Check the communication gateway health endpoint' + ) + communication_health_parser.add_argument( + '--config', + type=str, + dest='communication_config', + help='Communication configuration file path' + ) + communication_health_parser.add_argument('--host', type=str, default=None) + communication_health_parser.add_argument('--port', type=int, default=None) # Basic arguments (for run mode) parser.add_argument('--config', '-c', type=str, help='Configuration file path (JSON format)') @@ -444,6 +481,20 @@ async def main(): if args.command == 'refresh-cache': await refresh_mcp_cache(args.config) return 0 + if args.command == 'communication': + from openspace.communication.gateway import main as communication_main + + communication_argv = [] + if args.communication_config: + communication_argv.extend(['--config', args.communication_config]) + if args.communication_command: + communication_argv.append(args.communication_command) + if args.communication_command == 'health': + if args.host: + communication_argv.extend(['--host', args.host]) + if args.port is not None: + communication_argv.extend(['--port', str(args.port)]) + return await communication_main(communication_argv) # Load configuration config = _load_config(args) diff --git a/openspace/agents/grounding_agent.py b/openspace/agents/grounding_agent.py index d64116c..2ad8a9c 100644 --- a/openspace/agents/grounding_agent.py +++ b/openspace/agents/grounding_agent.py @@ -5,8 +5,15 @@ import json from typing import TYPE_CHECKING, Any, Dict, List, Optional from openspace.agents.base import BaseAgent +from openspace.agents.message_utils import ( + ITERATION_GUIDANCE_PREFIX, + build_channel_context_message, + cap_message_content, + normalize_external_history, + truncate_messages, +) +from openspace.agents.visual_analyzer import VisualAnalyzer from openspace.grounding.core.types import BackendType, ToolResult -from openspace.platforms.screenshot import ScreenshotClient from openspace.prompts import GroundingAgentPrompts from openspace.utils.logging import Logger @@ -20,6 +27,7 @@ logger = Logger.get_logger(__name__) class GroundingAgent(BaseAgent): + def __init__( self, name: str = "GroundingAgent", @@ -58,9 +66,12 @@ class GroundingAgent(BaseAgent): self._system_prompt = system_prompt or self._default_system_prompt() self._max_iterations = max_iterations - self._visual_analysis_timeout = visual_analysis_timeout self._tool_retrieval_llm = tool_retrieval_llm - self._visual_analysis_model = visual_analysis_model + self._visual_analyzer = VisualAnalyzer( + llm_client=llm_client, + visual_analysis_model=visual_analysis_model, + visual_analysis_timeout=visual_analysis_timeout, + ) # Skill context injection (set externally before process()) self._skill_context: Optional[str] = None @@ -75,7 +86,7 @@ class GroundingAgent(BaseAgent): logger.info(f"Grounding Agent initialized: {name}") logger.info(f"Backend scope: {self._backend_scope}") logger.info(f"Max iterations: {self._max_iterations}") - logger.info(f"Visual analysis timeout: {self._visual_analysis_timeout}s") + logger.info(f"Visual analysis timeout: {visual_analysis_timeout}s") if tool_retrieval_llm: logger.info(f"Tool retrieval model: {tool_retrieval_llm.model}") if visual_analysis_model: @@ -119,92 +130,6 @@ class GroundingAgent(BaseAgent): count = len(registry.list_skills()) logger.info(f"Skill registry attached ({count} skill(s) available for mid-iteration retrieval)") - _MAX_SINGLE_CONTENT_CHARS = 30_000 - _ITERATION_GUIDANCE_PREFIX = "[INTERNAL ORCHESTRATION NOTE]" - - @classmethod - def _cap_message_content(cls, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Truncate oversized individual message contents in-place. - - Targets tool-result messages and assistant messages that can - carry enormous file contents (read_file on large CSVs/scripts). - System messages and the first user instruction are never touched. - """ - cap = cls._MAX_SINGLE_CONTENT_CHARS - trimmed = 0 - for msg in messages: - content = msg.get("content") - if not isinstance(content, str) or len(content) <= cap: - continue - if msg.get("role") == "system": - continue - original_len = len(content) - msg["content"] = ( - content[: cap // 2] - + f"\n\n... [truncated {original_len - cap:,} chars] ...\n\n" - + content[-(cap // 2):] - ) - trimmed += 1 - if trimmed: - logger.info(f"Capped {trimmed} oversized message(s) to {cap:,} chars each") - return messages - - def _truncate_messages( - self, - messages: List[Dict[str, Any]], - keep_recent: int = 8, - max_tokens_estimate: int = 120000 - ) -> List[Dict[str, Any]]: - # First: cap any single oversized message to prevent one huge - # tool-result from dominating the context window. - messages = self._cap_message_content(messages) - - if len(messages) <= keep_recent + 2: # +2 for system and initial user - return messages - - total_text = json.dumps(messages, ensure_ascii=False) - estimated_tokens = len(total_text) // 4 - - if estimated_tokens < max_tokens_estimate: - return messages - - logger.info(f"Truncating message history: {len(messages)} messages, " - f"~{estimated_tokens:,} tokens -> keeping recent {keep_recent} rounds") - - system_messages = [] - user_instruction = None - conversation_messages = [] - - for msg in messages: - role = msg.get("role") - if role == "system": - system_messages.append(msg) - elif role == "user" and user_instruction is None: - user_instruction = msg - else: - conversation_messages.append(msg) - - recent_messages = conversation_messages[-(keep_recent * 2):] if conversation_messages else [] - - truncated = system_messages.copy() - dropped = len(conversation_messages) - len(recent_messages) - if dropped > 0: - truncated.append({ - "role": "system", - "content": ( - f"{self._ITERATION_GUIDANCE_PREFIX} {dropped} earlier messages were " - "truncated to save context. The original task instruction is preserved below." - ), - }) - if user_instruction: - truncated.append(user_instruction) - truncated.extend(recent_messages) - - logger.info(f"After truncation: {len(truncated)} messages, " - f"~{len(json.dumps(truncated, ensure_ascii=False))//4:,} tokens (estimated)") - - return truncated - async def process(self, context: Dict[str, Any]) -> Dict[str, Any]: """ Process a task execution request with multi-round iteration control. @@ -286,6 +211,14 @@ class GroundingAgent(BaseAgent): tools=tools, ) + async def _va_callback( + result: ToolResult, tool_name: str, tool_call: Dict, backend: str + ) -> ToolResult: + return await self._visual_analyzer.analyze_tool_result( + result, tool_name, tool_call, backend, + task_description=instruction, + ) + try: while current_iteration < max_iterations: current_iteration += 1 @@ -305,15 +238,15 @@ class GroundingAgent(BaseAgent): # Cap oversized individual messages every iteration to prevent # a single huge tool result from ballooning all subsequent calls. if current_iteration >= 2: - messages = self._cap_message_content(messages) + messages = cap_message_content(messages) # Truncate message history to prevent context length issues # Start truncating after 5 iterations to keep context manageable if current_iteration >= 5: - messages = self._truncate_messages( - messages, + messages = truncate_messages( + messages, keep_recent=8, - max_tokens_estimate=120000 + max_tokens_estimate=120000, ) messages_input_snapshot = copy.deepcopy(messages) @@ -335,7 +268,7 @@ class GroundingAgent(BaseAgent): tools=tools if context.get("auto_execute", True) else None, execute_tools=context.get("auto_execute", True), summary_prompt=None, # Disabled - tool_result_callback=self._visual_analysis_callback + tool_result_callback=_va_callback, ) # Update messages with LLM response @@ -427,7 +360,7 @@ class GroundingAgent(BaseAgent): msg for msg in messages if not ( isinstance(msg.get("content"), str) - and msg.get("content", "").startswith(self._ITERATION_GUIDANCE_PREFIX) + and msg.get("content", "").startswith(ITERATION_GUIDANCE_PREFIX) ) ] @@ -435,7 +368,7 @@ class GroundingAgent(BaseAgent): # so runtime guidance is sent as an internal user note. guidance_msg = { "role": "user", - "content": f"{self._ITERATION_GUIDANCE_PREFIX}\n" + "content": f"{ITERATION_GUIDANCE_PREFIX}\n" f"Iteration {current_iteration} complete. " f"Check if task is finished - if yes, output {GroundingAgentPrompts.TASK_COMPLETE}. " f"If not, continue with next action." @@ -532,7 +465,16 @@ class GroundingAgent(BaseAgent): "role": "system", "content": artifact_msg }) - + + channel_context_msg = build_channel_context_message( + context.get("channel_context") + ) + if channel_context_msg: + messages.append({ + "role": "system", + "content": channel_context_msg, + }) + # Skill injection — only active (selected) skills, full content if self._skill_context: messages.append({ @@ -540,10 +482,20 @@ class GroundingAgent(BaseAgent): "content": self._skill_context }) logger.info(f"Injected active skill context ({len(self._active_skill_ids)} skill(s))") - + + external_history = normalize_external_history( + context.get("conversation_history") + ) + if external_history: + messages.extend(external_history) + logger.info( + "Injected %d external conversation message(s)", + len(external_history), + ) + # User instruction messages.append({"role": "user", "content": instruction}) - + return messages async def _get_available_tools(self, task_description: Optional[str]) -> List: @@ -629,230 +581,6 @@ class GroundingAgent(BaseAgent): ) return all_tools - async def _visual_analysis_callback( - self, - result: ToolResult, - tool_name: str, - tool_call: Dict, - backend: str - ) -> ToolResult: - """ - Callback for LLMClient to handle visual analysis after tool execution. - """ - # 1. Check if LLM requested to skip visual analysis - skip_visual_analysis = False - try: - arguments = tool_call.function.arguments - if isinstance(arguments, str): - args = json.loads(arguments.strip() or "{}") - else: - args = arguments - - if isinstance(args, dict) and args.get("skip_visual_analysis"): - skip_visual_analysis = True - logger.info(f"Visual analysis skipped for {tool_name} (meta-parameter set by LLM)") - except Exception as e: - logger.debug(f"Could not parse tool arguments: {e}") - - # 2. If skip requested, return original result - if skip_visual_analysis: - return result - - # 3. Check if this backend needs visual analysis - if backend != "gui": - return result - - # 4. Check if tool has visual data - metadata = getattr(result, 'metadata', None) - has_screenshots = metadata and (metadata.get("screenshot") or metadata.get("screenshots")) - - # 5. If no visual data, try to capture a screenshot - if not has_screenshots: - try: - logger.info(f"No visual data from {tool_name}, capturing screenshot...") - screenshot_client = ScreenshotClient() - screenshot_bytes = await screenshot_client.capture() - - if screenshot_bytes: - # Add screenshot to result metadata - if metadata is None: - result.metadata = {} - metadata = result.metadata - metadata["screenshot"] = screenshot_bytes - has_screenshots = True - logger.info(f"Screenshot captured for visual analysis") - else: - logger.warning("Failed to capture screenshot") - except Exception as e: - logger.warning(f"Error capturing screenshot: {e}") - - # 6. If still no screenshots, return original result - if not has_screenshots: - logger.debug(f"No visual data available for {tool_name}") - return result - - # 7. Perform visual analysis - return await self._enhance_result_with_visual_context(result, tool_name) - - async def _enhance_result_with_visual_context( - self, - result: ToolResult, - tool_name: str - ) -> ToolResult: - """ - Enhance tool result with visual analysis for grounding agent workflows. - """ - import asyncio - import base64 - import litellm - - try: - metadata = getattr(result, 'metadata', None) - if not metadata: - return result - - # Collect all screenshots - screenshots_bytes = [] - - # Check for multiple screenshots first - if metadata.get("screenshots"): - screenshots_list = metadata["screenshots"] - if isinstance(screenshots_list, list): - screenshots_bytes = [s for s in screenshots_list if s] - # Fall back to single screenshot - elif metadata.get("screenshot"): - screenshots_bytes = [metadata["screenshot"]] - - if not screenshots_bytes: - return result - - # Select key screenshots if there are too many - selected_screenshots = self._select_key_screenshots(screenshots_bytes, max_count=3) - - # Convert to base64 - visual_b64_list = [] - for visual_data in selected_screenshots: - if isinstance(visual_data, bytes): - visual_b64_list.append(base64.b64encode(visual_data).decode('utf-8')) - else: - visual_b64_list.append(visual_data) # Already base64 - - # Build prompt based on number of screenshots - num_screenshots = len(visual_b64_list) - - prompt = GroundingAgentPrompts.visual_analysis( - tool_name=tool_name, - num_screenshots=num_screenshots, - task_description=getattr(self, '_current_instruction', '') - ) - - # Build content with text prompt + all images - content = [{"type": "text", "text": prompt}] - for visual_b64 in visual_b64_list: - content.append({ - "type": "image_url", - "image_url": { - "url": f"data:image/png;base64,{visual_b64}" - } - }) - - # Resolve visual-model credentials independently when the visual - # model differs from the main reasoning model. - visual_model = self._visual_analysis_model or (self._llm_client.model if self._llm_client else "openrouter/anthropic/claude-sonnet-4.5") - _llm_extra = {} - if self._llm_client and visual_model == self._llm_client.model: - _llm_extra = getattr(self._llm_client, 'litellm_kwargs', {}) or {} - elif self._visual_analysis_model: - try: - from openspace.host_detection import build_llm_kwargs - visual_model, _llm_extra = build_llm_kwargs(visual_model) - except Exception as e: - logger.debug(f"Failed to resolve dedicated visual model credentials: {e}") - _llm_extra = {} - response = await asyncio.wait_for( - litellm.acompletion( - model=visual_model, - messages=[{ - "role": "user", - "content": content - }], - timeout=self._visual_analysis_timeout, - **_llm_extra, - ), - timeout=self._visual_analysis_timeout + 5 - ) - - analysis = response.choices[0].message.content.strip() - - # Inject visual analysis into content - original_content = result.content or "(no text output)" - enhanced_content = f"{original_content}\n\n**Visual content**: {analysis}" - - # Create enhanced result - enhanced_result = ToolResult( - status=result.status, - content=enhanced_content, - error=result.error, - metadata={**metadata, "visual_analyzed": True, "visual_analysis": analysis}, - execution_time=result.execution_time - ) - - logger.info(f"Enhanced {tool_name} result with visual analysis ({num_screenshots} screenshot(s))") - return enhanced_result - - except asyncio.TimeoutError: - logger.warning(f"Visual analysis timed out for {tool_name}, returning original result") - return result - except Exception as e: - logger.warning(f"Failed to analyze visual content for {tool_name}: {e}") - return result - - def _select_key_screenshots( - self, - screenshots: List[bytes], - max_count: int = 3 - ) -> List[bytes]: - """ - Select key screenshots if there are too many. - """ - if len(screenshots) <= max_count: - return screenshots - - selected_indices = set() - - # Always include last (final state) - selected_indices.add(len(screenshots) - 1) - - # If room, include first (initial state) - if max_count >= 2: - selected_indices.add(0) - - # Fill remaining slots with evenly spaced middle screenshots - remaining_slots = max_count - len(selected_indices) - if remaining_slots > 0: - # Calculate spacing - available_indices = [ - i for i in range(1, len(screenshots) - 1) - if i not in selected_indices - ] - - if available_indices: - step = max(1, len(available_indices) // (remaining_slots + 1)) - for i in range(remaining_slots): - idx = min((i + 1) * step, len(available_indices) - 1) - if idx < len(available_indices): - selected_indices.add(available_indices[idx]) - - # Return screenshots in original order - selected = [screenshots[i] for i in sorted(selected_indices)] - - logger.debug( - f"Selected {len(selected)} screenshots at indices {sorted(selected_indices)} " - f"from total of {len(screenshots)}" - ) - - return selected - def _get_workspace_path(self, context: Dict[str, Any]) -> Optional[str]: """ Get workspace directory path from context. diff --git a/openspace/agents/message_utils.py b/openspace/agents/message_utils.py new file mode 100644 index 0000000..4c3948e --- /dev/null +++ b/openspace/agents/message_utils.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional, Set + +from openspace.prompts import GroundingAgentPrompts +from openspace.utils.logging import Logger + +logger = Logger.get_logger(__name__) + +SUPPORTED_EXTERNAL_HISTORY_ROLES: Set[str] = {"user", "assistant"} +MAX_SINGLE_CONTENT_CHARS: int = 30_000 +ITERATION_GUIDANCE_PREFIX: str = "[INTERNAL ORCHESTRATION NOTE]" + + +def cap_message_content( + messages: List[Dict[str, Any]], + max_chars: int = MAX_SINGLE_CONTENT_CHARS, +) -> List[Dict[str, Any]]: + """Truncate oversized individual message contents in-place. + + Targets tool-result messages and assistant messages that can + carry enormous file contents (read_file on large CSVs/scripts). + System messages and the first user instruction are never touched. + """ + trimmed = 0 + for msg in messages: + content = msg.get("content") + if not isinstance(content, str) or len(content) <= max_chars: + continue + if msg.get("role") == "system": + continue + original_len = len(content) + msg["content"] = ( + content[: max_chars // 2] + + f"\n\n... [truncated {original_len - max_chars:,} chars] ...\n\n" + + content[-(max_chars // 2) :] + ) + trimmed += 1 + if trimmed: + logger.info(f"Capped {trimmed} oversized message(s) to {max_chars:,} chars each") + return messages + + +def truncate_messages( + messages: List[Dict[str, Any]], + keep_recent: int = 8, + max_tokens_estimate: int = 120_000, + guidance_prefix: str = ITERATION_GUIDANCE_PREFIX, +) -> List[Dict[str, Any]]: + """Truncate conversation history to fit within token budget. + + Preserves system messages and the first user instruction while + keeping only the most recent conversation turns. + """ + messages = cap_message_content(messages) + + if len(messages) <= keep_recent + 2: # +2 for system and initial user + return messages + + total_text = json.dumps(messages, ensure_ascii=False) + estimated_tokens = len(total_text) // 4 + + if estimated_tokens < max_tokens_estimate: + return messages + + logger.info( + f"Truncating message history: {len(messages)} messages, " + f"~{estimated_tokens:,} tokens -> keeping recent {keep_recent} rounds" + ) + + system_messages: List[Dict[str, Any]] = [] + user_instruction: Optional[Dict[str, Any]] = None + conversation_messages: List[Dict[str, Any]] = [] + + for msg in messages: + role = msg.get("role") + if role == "system": + system_messages.append(msg) + elif role == "user" and user_instruction is None: + user_instruction = msg + else: + conversation_messages.append(msg) + + recent_messages = ( + conversation_messages[-(keep_recent * 2) :] if conversation_messages else [] + ) + + truncated = system_messages.copy() + dropped = len(conversation_messages) - len(recent_messages) + if dropped > 0: + truncated.append( + { + "role": "system", + "content": ( + f"{guidance_prefix} {dropped} earlier messages were " + "truncated to save context. The original task instruction " + "is preserved below." + ), + } + ) + if user_instruction: + truncated.append(user_instruction) + truncated.extend(recent_messages) + + logger.info( + f"After truncation: {len(truncated)} messages, " + f"~{len(json.dumps(truncated, ensure_ascii=False)) // 4:,} tokens (estimated)" + ) + + return truncated + + +def normalize_external_history( + conversation_history: Any, + supported_roles: Set[str] = SUPPORTED_EXTERNAL_HISTORY_ROLES, +) -> List[Dict[str, str]]: + """Normalize external conversation history into ``{role, content}`` dicts.""" + if not isinstance(conversation_history, list): + return [] + + normalized: List[Dict[str, str]] = [] + for entry in conversation_history: + if not isinstance(entry, dict): + continue + role = str(entry.get("role", "")).strip().lower() + if role not in supported_roles: + continue + + content = entry.get("content") + if isinstance(content, list): + parts: List[str] = [] + for item in content: + if isinstance(item, dict): + text = item.get("text") + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + elif isinstance(item, str) and item.strip(): + parts.append(item.strip()) + content = "\n".join(parts).strip() + elif content is not None: + content = str(content).strip() + + if not content: + continue + + normalized.append({"role": role, "content": content}) + + return normalized + + +def build_channel_context_message(channel_context: Any) -> Optional[str]: + """Build a system message describing the communication channel context.""" + if not isinstance(channel_context, dict): + return None + + lines = [ + "## Channel Context", + ] + + platform = str(channel_context.get("platform", "")).strip() + chat_type = str(channel_context.get("chat_type", "")).strip() + chat_id = str(channel_context.get("chat_id", "")).strip() + chat_name = str(channel_context.get("chat_name", "")).strip() + thread_id = str(channel_context.get("thread_id", "")).strip() + user_name = str(channel_context.get("user_name", "")).strip() + user_id = str(channel_context.get("user_id", "")).strip() + session_key = str(channel_context.get("session_key", "")).strip() + message_id = str(channel_context.get("message_id", "")).strip() + reply_to_message_id = str(channel_context.get("reply_to_message_id", "")).strip() + reply_to_text = str(channel_context.get("reply_to_text", "")).strip() + + if platform: + lines.append(f"- Platform: {platform}") + if chat_type: + lines.append(f"- Chat type: {chat_type}") + if chat_id: + lines.append(f"- Chat ID: {chat_id}") + if chat_name: + lines.append(f"- Chat name: {chat_name}") + if thread_id: + lines.append(f"- Thread ID: {thread_id}") + if user_name: + lines.append(f"- User: {user_name}") + elif user_id: + lines.append(f"- User ID: {user_id}") + if session_key: + lines.append(f"- Session key: {session_key}") + if message_id: + lines.append(f"- Message ID: {message_id}") + if reply_to_message_id: + lines.append(f"- Reply-to message ID: {reply_to_message_id}") + if reply_to_text: + lines.append(f"- Reply context: {reply_to_text[:500]}") + + lines.extend( + [ + "", + "## Chat Reply Policy", + "- If the user is making simple conversation, answer directly in natural language.", + "- Do not call tools for greetings, acknowledgements, thanks, or brief " + "clarifications that can be answered from the current context.", + f"- When you reply directly without tools, include " + f"`{GroundingAgentPrompts.TASK_COMPLETE}` at the end of your response.", + ] + ) + + attachments = channel_context.get("attachments") + if isinstance(attachments, list) and attachments: + lines.append("- Attachments:") + for attachment in attachments: + if not isinstance(attachment, dict): + continue + path = str(attachment.get("path", "")).strip() + if not path: + continue + kind = str(attachment.get("kind", "file")).strip() or "file" + name = str(attachment.get("name", "")).strip() + label = f"{kind}: {path}" + if name: + label += f" ({name})" + lines.append(f" - {label}") + + if len(lines) == 1: + return None + + return "\n".join(lines) diff --git a/openspace/agents/visual_analyzer.py b/openspace/agents/visual_analyzer.py new file mode 100644 index 0000000..1179672 --- /dev/null +++ b/openspace/agents/visual_analyzer.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from openspace.grounding.core.types import ToolResult +from openspace.platforms.screenshot import ScreenshotClient +from openspace.prompts import GroundingAgentPrompts +from openspace.utils.logging import Logger + +if TYPE_CHECKING: + from openspace.llm import LLMClient + +logger = Logger.get_logger(__name__) + + +class VisualAnalyzer: + """Handles screenshot capture and LLM-based visual analysis of tool results.""" + + def __init__( + self, + llm_client: Optional[LLMClient] = None, + visual_analysis_model: Optional[str] = None, + visual_analysis_timeout: float = 30.0, + ) -> None: + self._llm_client = llm_client + self._visual_analysis_model = visual_analysis_model + self._visual_analysis_timeout = visual_analysis_timeout + + async def analyze_tool_result( + self, + result: ToolResult, + tool_name: str, + tool_call: Dict, + backend: str, + task_description: str = "", + ) -> ToolResult: + """Callback for LLMClient to handle visual analysis after tool execution.""" + skip_visual_analysis = False + try: + arguments = tool_call.function.arguments + if isinstance(arguments, str): + args = json.loads(arguments.strip() or "{}") + else: + args = arguments + + if isinstance(args, dict) and args.get("skip_visual_analysis"): + skip_visual_analysis = True + logger.info(f"Visual analysis skipped for {tool_name} (meta-parameter set by LLM)") + except Exception as e: + logger.debug(f"Could not parse tool arguments: {e}") + + if skip_visual_analysis: + return result + + if backend != "gui": + return result + + metadata = getattr(result, "metadata", None) + has_screenshots = metadata and ( + metadata.get("screenshot") or metadata.get("screenshots") + ) + + if not has_screenshots: + try: + logger.info(f"No visual data from {tool_name}, capturing screenshot...") + screenshot_client = ScreenshotClient() + screenshot_bytes = await screenshot_client.capture() + + if screenshot_bytes: + if metadata is None: + result.metadata = {} + metadata = result.metadata + metadata["screenshot"] = screenshot_bytes + has_screenshots = True + logger.info("Screenshot captured for visual analysis") + else: + logger.warning("Failed to capture screenshot") + except Exception as e: + logger.warning(f"Error capturing screenshot: {e}") + + if not has_screenshots: + logger.debug(f"No visual data available for {tool_name}") + return result + + return await self._enhance_result(result, tool_name, task_description) + + async def _enhance_result( + self, + result: ToolResult, + tool_name: str, + task_description: str = "", + ) -> ToolResult: + """Enhance tool result with LLM-based visual analysis.""" + import asyncio + import base64 + import litellm + + try: + metadata = getattr(result, "metadata", None) + if not metadata: + return result + + screenshots_bytes: List[bytes] = [] + if metadata.get("screenshots"): + screenshots_list = metadata["screenshots"] + if isinstance(screenshots_list, list): + screenshots_bytes = [s for s in screenshots_list if s] + elif metadata.get("screenshot"): + screenshots_bytes = [metadata["screenshot"]] + + if not screenshots_bytes: + return result + + selected_screenshots = self._select_key_screenshots( + screenshots_bytes, max_count=3 + ) + + visual_b64_list = [] + for visual_data in selected_screenshots: + if isinstance(visual_data, bytes): + visual_b64_list.append( + base64.b64encode(visual_data).decode("utf-8") + ) + else: + visual_b64_list.append(visual_data) + + num_screenshots = len(visual_b64_list) + + prompt = GroundingAgentPrompts.visual_analysis( + tool_name=tool_name, + num_screenshots=num_screenshots, + task_description=task_description, + ) + + content: List[Dict[str, Any]] = [{"type": "text", "text": prompt}] + for visual_b64 in visual_b64_list: + content.append( + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{visual_b64}"}, + } + ) + + visual_model = self._visual_analysis_model or ( + self._llm_client.model + if self._llm_client + else "openrouter/anthropic/claude-sonnet-4.5" + ) + _llm_extra: Dict[str, Any] = {} + if self._llm_client and visual_model == self._llm_client.model: + _llm_extra = ( + getattr(self._llm_client, "litellm_kwargs", {}) or {} + ) + elif self._visual_analysis_model: + try: + from openspace.host_detection import build_llm_kwargs + + visual_model, _llm_extra = build_llm_kwargs(visual_model) + except Exception as e: + logger.debug( + f"Failed to resolve dedicated visual model credentials: {e}" + ) + _llm_extra = {} + + response = await asyncio.wait_for( + litellm.acompletion( + model=visual_model, + messages=[{"role": "user", "content": content}], + timeout=self._visual_analysis_timeout, + **_llm_extra, + ), + timeout=self._visual_analysis_timeout + 5, + ) + + analysis = response.choices[0].message.content.strip() + + original_content = result.content or "(no text output)" + enhanced_content = ( + f"{original_content}\n\n**Visual content**: {analysis}" + ) + + enhanced_result = ToolResult( + status=result.status, + content=enhanced_content, + error=result.error, + metadata={ + **metadata, + "visual_analyzed": True, + "visual_analysis": analysis, + }, + execution_time=result.execution_time, + ) + + logger.info( + f"Enhanced {tool_name} result with visual analysis " + f"({num_screenshots} screenshot(s))" + ) + return enhanced_result + + except asyncio.TimeoutError: + logger.warning( + f"Visual analysis timed out for {tool_name}, returning original result" + ) + return result + except Exception as e: + logger.warning( + f"Failed to analyze visual content for {tool_name}: {e}" + ) + return result + + @staticmethod + def _select_key_screenshots( + screenshots: List[bytes], + max_count: int = 3, + ) -> List[bytes]: + """Select key screenshots from a sequence, preferring first/last/evenly-spaced.""" + if len(screenshots) <= max_count: + return screenshots + + selected_indices: set[int] = set() + + selected_indices.add(len(screenshots) - 1) + + if max_count >= 2: + selected_indices.add(0) + + remaining_slots = max_count - len(selected_indices) + if remaining_slots > 0: + available_indices = [ + i + for i in range(1, len(screenshots) - 1) + if i not in selected_indices + ] + + if available_indices: + step = max(1, len(available_indices) // (remaining_slots + 1)) + for i in range(remaining_slots): + idx = min((i + 1) * step, len(available_indices) - 1) + if idx < len(available_indices): + selected_indices.add(available_indices[idx]) + + selected = [screenshots[i] for i in sorted(selected_indices)] + + logger.debug( + f"Selected {len(selected)} screenshots at indices " + f"{sorted(selected_indices)} from total of {len(screenshots)}" + ) + + return selected diff --git a/openspace/communication/__init__.py b/openspace/communication/__init__.py new file mode 100644 index 0000000..5e5ac01 --- /dev/null +++ b/openspace/communication/__init__.py @@ -0,0 +1,27 @@ +from openspace.communication.config import CommunicationConfig, load_communication_config +from openspace.communication.session_store import SessionStore, build_session_key +from openspace.communication.types import ( + AttachmentKind, + ChannelAttachment, + ChannelMessage, + ChannelPlatform, + ChannelReply, + ChannelSession, + ChannelSource, + SendResult, +) + +__all__ = [ + "AttachmentKind", + "ChannelAttachment", + "ChannelMessage", + "ChannelPlatform", + "ChannelReply", + "ChannelSession", + "ChannelSource", + "CommunicationConfig", + "SendResult", + "SessionStore", + "build_session_key", + "load_communication_config", +] diff --git a/openspace/communication/adapters/__init__.py b/openspace/communication/adapters/__init__.py new file mode 100644 index 0000000..649009a --- /dev/null +++ b/openspace/communication/adapters/__init__.py @@ -0,0 +1,9 @@ +from openspace.communication.adapters.base import BaseChannelAdapter +from openspace.communication.adapters.feishu import FeishuAdapter +from openspace.communication.adapters.whatsapp import WhatsAppAdapter + +__all__ = [ + "BaseChannelAdapter", + "FeishuAdapter", + "WhatsAppAdapter", +] diff --git a/openspace/communication/adapters/base.py b/openspace/communication/adapters/base.py new file mode 100644 index 0000000..fb0fece --- /dev/null +++ b/openspace/communication/adapters/base.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Awaitable, Callable, Optional + +from openspace.utils.logging import Logger + +from openspace.communication.types import ChannelMessage, ChannelPlatform, SendResult + +logger = Logger.get_logger(__name__) + +MessageHandler = Callable[[ChannelMessage], Awaitable[None]] + + +class BaseChannelAdapter(ABC): + platform: ChannelPlatform + + def __init__(self, platform: ChannelPlatform): + self.platform = platform + self._message_handler: Optional[MessageHandler] = None + self._connected = False + + @property + def is_connected(self) -> bool: + return self._connected + + def set_message_handler(self, handler: MessageHandler) -> None: + self._message_handler = handler + + async def dispatch_message(self, message: ChannelMessage) -> None: + if self._message_handler is None: + logger.warning("Dropping %s message because no handler is attached", self.platform.value) + return + await self._message_handler(message) + + def register_http_routes(self, app: Any) -> None: + """Optional hook for adapters that need inbound HTTP routes.""" + + def validate_configuration(self) -> None: + """Optional hook for adapter-specific startup validation.""" + + def get_lock_identity(self) -> Optional[tuple[str, str]]: + """Return an optional (scope, identity) tuple for gateway-scoped locking.""" + return None + + @abstractmethod + async def connect(self) -> bool: + raise NotImplementedError + + @abstractmethod + async def disconnect(self) -> None: + raise NotImplementedError + + @abstractmethod + async def send_text( + self, + chat_id: str, + content: str, + *, + reply_to_message_id: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + ) -> SendResult: + raise NotImplementedError diff --git a/openspace/communication/adapters/feishu.py b/openspace/communication/adapters/feishu.py new file mode 100644 index 0000000..3c11063 --- /dev/null +++ b/openspace/communication/adapters/feishu.py @@ -0,0 +1,901 @@ +from __future__ import annotations + +import asyncio +import contextlib +import hashlib +import hmac +import json +import threading +import time +from collections import OrderedDict, deque +from pathlib import Path +from typing import Any, Optional + +from aiohttp import web + +from openspace.communication.adapters.base import BaseChannelAdapter +from openspace.communication.attachment_cache import AttachmentCache +from openspace.communication.config import FeishuConfig +from openspace.communication.policy import is_authorized +from openspace.communication.types import ( + AttachmentKind, + ChannelMessage, + ChannelPlatform, + ChannelSource, + SendResult, +) +from openspace.utils.logging import Logger + +logger = Logger.get_logger(__name__) + +_FEISHU_WEBHOOK_MAX_BODY_BYTES = 1 * 1024 * 1024 +_FEISHU_WEBHOOK_READ_TIMEOUT_SECONDS = 30 +_FEISHU_WEBHOOK_RATE_WINDOW_SECONDS = 60 +_FEISHU_WEBHOOK_RATE_LIMIT_MAX = 120 +_FEISHU_WEBHOOK_RATE_MAX_KEYS = 4096 +_FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS = 6 * 60 * 60 +_FEISHU_DEDUP_CACHE_SIZE = 2048 +_FEISHU_DEDUP_TTL_SECONDS = 24 * 60 * 60 + +try: + import lark_oapi as lark + from lark_oapi.api.im.v1 import ( + CreateMessageRequest, + CreateMessageRequestBody, + GetMessageRequest, + GetMessageResourceRequest, + ReplyMessageRequest, + ReplyMessageRequestBody, + ) + from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN + + FEISHU_AVAILABLE = True +except ImportError: + FEISHU_AVAILABLE = False + lark = None # type: ignore[assignment] + CreateMessageRequest = None # type: ignore[assignment] + CreateMessageRequestBody = None # type: ignore[assignment] + GetMessageRequest = None # type: ignore[assignment] + GetMessageResourceRequest = None # type: ignore[assignment] + ReplyMessageRequest = None # type: ignore[assignment] + ReplyMessageRequestBody = None # type: ignore[assignment] + FEISHU_DOMAIN = None # type: ignore[assignment] + LARK_DOMAIN = None # type: ignore[assignment] + + +class FeishuAdapter(BaseChannelAdapter): + MAX_MESSAGE_LENGTH = 8000 + _REPLY_CONTEXT_MAX_LEN = 200 + + def __init__( + self, + config: FeishuConfig, + attachment_cache: AttachmentCache, + *, + runtime_dir: Optional[Path] = None, + ): + super().__init__(ChannelPlatform.FEISHU) + self.config = config + self.attachment_cache = attachment_cache + self.runtime_dir = ( + Path(runtime_dir).expanduser().resolve() + if runtime_dir is not None + else attachment_cache.base_dir.parent.resolve() + ) + self._client: Any = None + self._bot_open_id = config.bot_open_id + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._ws_client: Any = None + self._ws_thread: Optional[threading.Thread] = None + self._running = False + self._dedup_state_path = self.runtime_dir / "feishu_seen_message_ids.json" + self._seen_message_ids: OrderedDict[str, float] = OrderedDict() + self._recent_sent_message_ids: OrderedDict[str, None] = OrderedDict() + self._rate_windows: dict[str, deque[float]] = {} + self._webhook_anomalies: dict[str, tuple[int, str, float]] = {} + self._dedup_dirty = False + self._load_seen_message_ids() + + def register_http_routes(self, app: Any) -> None: + if self.config.connection_mode == "webhook": + app.router.add_post(self.config.webhook_path, self._handle_webhook) + + def validate_configuration(self) -> None: + if self.config.connection_mode == "webhook" and not _optional_str(self.config.verification_token): + raise ValueError("Feishu webhook mode requires verification_token") + + def get_lock_identity(self) -> Optional[tuple[str, str]]: + app_id = _optional_str(self.config.app_id) + if not app_id: + return None + return ("feishu-app", app_id) + + async def connect(self) -> bool: + self.validate_configuration() + if not FEISHU_AVAILABLE: + logger.error("Feishu adapter requires lark-oapi") + return False + if not self.config.app_id or not self.config.app_secret: + logger.error("Feishu adapter missing app_id/app_secret") + return False + domain = FEISHU_DOMAIN if self.config.domain != "lark" else LARK_DOMAIN + self._client = ( + lark.Client.builder() + .app_id(self.config.app_id) + .app_secret(self.config.app_secret) + .domain(domain) + .log_level(lark.LogLevel.WARNING) + .build() + ) + self._running = True + self._loop = asyncio.get_running_loop() + if not self._bot_open_id: + self._bot_open_id = await asyncio.to_thread(self._fetch_bot_open_id) + if self.config.connection_mode == "websocket": + self._start_websocket_client() + self._connected = False + else: + self._connected = True + logger.info( + "Feishu adapter connected via %s mode", + self.config.connection_mode, + ) + return True + + async def disconnect(self) -> None: + self._running = False + self._connected = False + if self._ws_thread is not None and self._ws_thread.is_alive(): + await asyncio.to_thread(self._ws_thread.join, 5) + self._ws_thread = None + self._persist_seen_message_ids() + self._client = None + + async def send_text( + self, + chat_id: str, + content: str, + *, + reply_to_message_id: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + ) -> SendResult: + if not self._client: + return SendResult(success=False, error="Feishu client not initialized") + + last_message_id: Optional[str] = None + for chunk in _split_text(content, self.MAX_MESSAGE_LENGTH): + payload = json.dumps({"text": chunk}, ensure_ascii=False) + if reply_to_message_id: + body = ( + ReplyMessageRequestBody.builder() + .msg_type("text") + .content(payload) + .build() + ) + request = ( + ReplyMessageRequest.builder() + .message_id(reply_to_message_id) + .request_body(body) + .build() + ) + response = await asyncio.to_thread(self._client.im.v1.message.reply, request) + else: + body = ( + CreateMessageRequestBody.builder() + .receive_id(chat_id) + .msg_type("text") + .content(payload) + .build() + ) + request = ( + CreateMessageRequest.builder() + .receive_id_type("chat_id") + .request_body(body) + .build() + ) + response = await asyncio.to_thread(self._client.im.v1.message.create, request) + + if not response.success(): + return SendResult( + success=False, + error=f"[{response.code}] {response.msg}", + raw_response=response, + ) + last_message_id = getattr(getattr(response, "data", None), "message_id", None) + if last_message_id: + self._remember_sent_message_id(last_message_id) + return SendResult(success=True, message_id=last_message_id) + + async def _handle_webhook(self, request: web.Request) -> web.Response: + remote_ip = _client_ip_from_request(request) + rate_key = f"{self.config.app_id}:{self.config.webhook_path}:{remote_ip}" + if not self._check_webhook_rate_limit(rate_key): + self._record_webhook_anomaly(remote_ip, "429") + return web.Response(status=429, text="Rate limit exceeded") + + content_length = request.content_length or 0 + if content_length > _FEISHU_WEBHOOK_MAX_BODY_BYTES: + self._record_webhook_anomaly(remote_ip, "413") + return web.Response(status=413, text="Payload too large") + + try: + async with asyncio.timeout(_FEISHU_WEBHOOK_READ_TIMEOUT_SECONDS): + body_bytes = await request.read() + except TimeoutError: + self._record_webhook_anomaly(remote_ip, "408") + return web.Response(status=408, text="Request timeout") + + if len(body_bytes) > _FEISHU_WEBHOOK_MAX_BODY_BYTES: + self._record_webhook_anomaly(remote_ip, "413") + return web.Response(status=413, text="Payload too large") + + try: + payload = json.loads(body_bytes.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + self._record_webhook_anomaly(remote_ip, "400") + return web.json_response({"code": 400, "msg": "invalid json"}, status=400) + + incoming_token = str((payload.get("header") or {}).get("token") or payload.get("token") or "") + if not incoming_token or not hmac.compare_digest(incoming_token, self.config.verification_token or ""): + self._record_webhook_anomaly(remote_ip, "401-token") + return web.Response(status=401, text="Invalid verification token") + + if self.config.encrypt_key and not _is_webhook_signature_valid( + encrypt_key=self.config.encrypt_key, + headers=request.headers, + body_bytes=body_bytes, + ): + self._record_webhook_anomaly(remote_ip, "401-signature") + return web.Response(status=401, text="Invalid signature") + if payload.get("encrypt"): + self._record_webhook_anomaly(remote_ip, "400-encrypted") + return web.json_response( + {"code": 400, "msg": "encrypted webhook payloads are not supported"}, + status=400, + ) + + self._clear_webhook_anomaly(remote_ip) + + if payload.get("type") == "url_verification": + return web.json_response({"challenge": payload.get("challenge", "")}) + + event_type = str((payload.get("header") or {}).get("event_type") or "") + if event_type == "im.message.receive_v1": + await self._handle_message_event(payload) + return web.json_response({"code": 0, "msg": "ok"}) + + def _start_websocket_client(self) -> None: + assert lark is not None + + handler = ( + lark.EventDispatcherHandler.builder( + self.config.encrypt_key or "", + self.config.verification_token or "", + ) + .register_p2_im_message_receive_v1(self._on_message_sync) + .build() + ) + self._ws_client = lark.ws.Client( + self.config.app_id, + self.config.app_secret, + event_handler=handler, + log_level=lark.LogLevel.INFO, + ) + + def _run_ws_forever() -> None: + import lark_oapi.ws.client as lark_ws_client + + ws_loop = asyncio.new_event_loop() + asyncio.set_event_loop(ws_loop) + lark_ws_client.loop = ws_loop + try: + while self._running: + try: + self._ws_client.start() + except Exception as exc: + self._connected = False + logger.warning("Feishu WebSocket client error: %s", exc) + if self._running: + time.sleep(5) + finally: + self._connected = False + ws_loop.close() + + self._ws_thread = threading.Thread( + target=_run_ws_forever, + daemon=True, + name="openspace-feishu-ws", + ) + self._ws_thread.start() + + def _on_message_sync(self, data: Any) -> None: + if not self._loop or not self._running: + return + self._connected = True + asyncio.run_coroutine_threadsafe( + self._handle_websocket_message(data), + self._loop, + ) + + async def _handle_message_event(self, payload: dict[str, Any]) -> None: + normalized = await self._normalize_webhook_payload(payload) + if normalized is not None: + await self.dispatch_message(normalized) + + async def _handle_websocket_message(self, data: Any) -> None: + normalized = await self._normalize_websocket_event(data) + if normalized is not None: + await self.dispatch_message(normalized) + + async def _normalize_webhook_payload(self, payload: dict[str, Any]) -> Optional[ChannelMessage]: + event = payload.get("event") or {} + message = event.get("message") or {} + sender = event.get("sender") or {} + sender_id = sender.get("sender_id") or {} + if str(sender.get("sender_type", "")).lower() == "bot": + return None + return await self._normalize_inbound_message( + message_id=_optional_str(message.get("message_id")), + chat_id=_optional_str(message.get("chat_id")), + chat_type=_optional_str(message.get("chat_type")) or "p2p", + sender_uid=( + _optional_str(sender_id.get("open_id")) + or _optional_str(sender_id.get("user_id")) + or _optional_str(sender_id.get("union_id")) + ), + sender_name=( + _optional_str(sender_id.get("name")) + or _optional_str(sender.get("sender_name")) + ), + thread_id=_optional_str(message.get("thread_id")), + message_type=_optional_str(message.get("message_type")) or "", + content=_safe_json_loads(message.get("content", "{}")), + mentions=list(message.get("mentions") or []), + reply_to_message_id=( + _optional_str(message.get("parent_id")) + or _optional_str(message.get("upper_message_id")) + ), + metadata={"webhook_payload": payload}, + resolve_mentions=False, + ) + + async def _normalize_websocket_event(self, data: Any) -> Optional[ChannelMessage]: + event = getattr(data, "event", None) + message = getattr(event, "message", None) + sender = getattr(event, "sender", None) + if message is None or sender is None: + return None + if str(getattr(sender, "sender_type", "")).lower() == "bot": + return None + + sender_id = getattr(sender, "sender_id", None) + return await self._normalize_inbound_message( + message_id=_optional_str(getattr(message, "message_id", None)), + chat_id=_optional_str(getattr(message, "chat_id", None)), + chat_type=_optional_str(getattr(message, "chat_type", None)) or "p2p", + sender_uid=( + _optional_str(getattr(sender_id, "open_id", None)) + or _optional_str(getattr(sender_id, "user_id", None)) + or _optional_str(getattr(sender_id, "union_id", None)) + ), + sender_name=_optional_str(getattr(sender, "sender_name", None)), + thread_id=_optional_str(getattr(message, "thread_id", None)), + message_type=_optional_str(getattr(message, "message_type", None)) or "", + content=_safe_json_loads(getattr(message, "content", "{}")), + mentions=list(getattr(message, "mentions", None) or []), + reply_to_message_id=( + _optional_str(getattr(message, "parent_id", None)) + or _optional_str(getattr(message, "upper_message_id", None)) + ), + metadata={"websocket_event": True}, + resolve_mentions=True, + ) + + async def _normalize_inbound_message( + self, + *, + message_id: Optional[str], + chat_id: Optional[str], + chat_type: str, + sender_uid: Optional[str], + sender_name: Optional[str], + thread_id: Optional[str], + message_type: str, + content: dict[str, Any], + mentions: list[Any], + reply_to_message_id: Optional[str], + metadata: dict[str, Any], + resolve_mentions: bool, + ) -> Optional[ChannelMessage]: + if not message_id or not chat_id: + return None + if self._is_message_seen(message_id): + logger.debug("Skipping duplicate Feishu message %s", message_id) + return None + + source = ChannelSource( + platform=ChannelPlatform.FEISHU, + chat_id=chat_id, + chat_type="dm" if str(chat_type).lower() == "p2p" else "group", + user_id=sender_uid, + user_name=sender_name, + chat_name=chat_id, + thread_id=thread_id, + ) + session_key = _build_session_key_hint(source) + normalized_type = str(message_type or "").strip().lower() + mentions_bot = self._mentions_bot(mentions) + + text = "" + if normalized_type == "text": + text = str(content.get("text", "")).strip() + if resolve_mentions: + text = _resolve_mentions(text, mentions) + elif normalized_type == "post": + text = _extract_post_text(content) + + prefilter_message = ChannelMessage( + source=source, + text=text, + message_id=message_id, + reply_to_message_id=reply_to_message_id, + mentions_bot=mentions_bot, + metadata=metadata, + ) + if not self._passes_prefilter(prefilter_message): + self._remember_message_seen(message_id) + return None + + attachments = [] + if normalized_type == "image": + attachment = await self._download_attachment( + session_key=session_key, + message_id=message_id, + file_key=str(content.get("image_key", "")).strip(), + file_name=str(content.get("image_key", "image")).strip() + ".png", + kind=AttachmentKind.IMAGE, + resource_type="image", + ) + if attachment is not None: + attachments.append(attachment) + elif normalized_type == "file": + attachment = await self._download_attachment( + session_key=session_key, + message_id=message_id, + file_key=str(content.get("file_key", "")).strip(), + file_name=str(content.get("file_name", "document")).strip(), + kind=AttachmentKind.DOCUMENT, + resource_type="file", + ) + if attachment is not None: + attachments.append(attachment) + + prefilter_message.attachments = attachments + prefilter_message.reply_to_text = await self._fetch_message_text(reply_to_message_id) + self._remember_message_seen(message_id) + return prefilter_message + + def _passes_prefilter(self, message: ChannelMessage) -> bool: + if not is_authorized(message, self.config): + logger.info("Rejected Feishu message from unauthorized user %s", message.source.user_id) + return False + if message.source.chat_type == "dm": + return self.config.allow_dm + if not self.config.allow_groups: + return False + if self.config.group_policy == "disabled": + return False + if self.config.group_policy == "mention_only": + return message.mentions_bot + if self.config.group_policy == "reply_or_mention": + return message.mentions_bot or self._is_reply_to_recent_bot_message( + message.reply_to_message_id + ) + return True + + def _is_reply_to_recent_bot_message(self, message_id: Optional[str]) -> bool: + if not message_id: + return False + return message_id in self._recent_sent_message_ids + + def _remember_sent_message_id(self, message_id: str) -> None: + self._recent_sent_message_ids.pop(message_id, None) + self._recent_sent_message_ids[message_id] = None + while len(self._recent_sent_message_ids) > _FEISHU_DEDUP_CACHE_SIZE: + self._recent_sent_message_ids.popitem(last=False) + + async def _download_attachment( + self, + *, + session_key: str, + message_id: str, + file_key: str, + file_name: str, + kind: AttachmentKind, + resource_type: str, + ): + if not self._client or not file_key: + return None + request = ( + GetMessageResourceRequest.builder() + .message_id(message_id) + .file_key(file_key) + .type(resource_type) + .build() + ) + response = await asyncio.to_thread(self._client.im.v1.message_resource.get, request) + if not response.success(): + logger.warning( + "Failed to download Feishu attachment: code=%s msg=%s", + response.code, + response.msg, + ) + return None + try: + file_data = await asyncio.to_thread( + _read_attachment_body, + response.file, + self.attachment_cache.max_attachment_bytes, + ) + except ValueError as exc: + logger.warning( + "Rejected Feishu attachment for session %s: %s", + session_key, + exc, + ) + return None + return self.attachment_cache.save_bytes( + session_key=session_key, + data=file_data, + filename=file_name, + kind=kind, + ) + + def _fetch_bot_open_id(self) -> Optional[str]: + if not self._client or not lark: + return None + try: + request = ( + lark.BaseRequest.builder() + .http_method(lark.HttpMethod.GET) + .uri("/open-apis/bot/v3/info") + .token_types({lark.AccessTokenType.APP}) + .build() + ) + response = self._client.request(request) + if not response.success(): + logger.warning( + "Failed to fetch Feishu bot info: code=%s msg=%s", + response.code, + response.msg, + ) + return None + payload = json.loads(response.raw.content) + bot = (payload.get("data") or payload).get("bot") or {} + return _optional_str(bot.get("open_id")) + except Exception as exc: + logger.warning("Failed to resolve Feishu bot open_id: %s", exc) + return None + + async def _fetch_message_text(self, message_id: Optional[str]) -> Optional[str]: + if not self._client or not message_id or GetMessageRequest is None: + return None + + request = GetMessageRequest.builder().message_id(message_id).build() + try: + response = await asyncio.to_thread(self._client.im.v1.message.get, request) + except Exception as exc: + logger.debug("Failed to fetch Feishu parent message %s: %s", message_id, exc) + return None + if not response.success(): + return None + + data = getattr(response, "data", None) + message_obj = None + items = getattr(data, "items", None) + if items: + message_obj = items[0] + elif data is not None: + message_obj = getattr(data, "message", None) or data + if message_obj is None: + return None + + body = getattr(message_obj, "body", None) + raw_content = getattr(body, "content", None) if body is not None else getattr(message_obj, "content", None) + message_type = ( + getattr(message_obj, "msg_type", None) + or getattr(message_obj, "message_type", None) + or "" + ) + content = _safe_json_loads(raw_content) + text = "" + if str(message_type).lower() == "text": + text = str(content.get("text", "")).strip() + elif str(message_type).lower() == "post": + text = _extract_post_text(content) + if not text: + return None + if len(text) > self._REPLY_CONTEXT_MAX_LEN: + text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..." + return text + + def _mentions_bot(self, mentions: list[Any]) -> bool: + if not mentions: + return False + if not self._bot_open_id: + return False + + for mention in mentions: + mention_id = (mention.get("id") or {}) if isinstance(mention, dict) else getattr(mention, "id", None) + open_id = ( + _optional_str(mention_id.get("open_id")) + if isinstance(mention_id, dict) + else _optional_str(getattr(mention_id, "open_id", None)) + ) + if open_id == self._bot_open_id: + return True + return False + + def _load_seen_message_ids(self) -> None: + if not self._dedup_state_path.exists(): + return + try: + payload = json.loads(self._dedup_state_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + logger.warning("Failed to load Feishu dedup cache from %s", self._dedup_state_path) + return + entries = payload.get("message_ids", {}) if isinstance(payload, dict) else {} + now = time.time() + valid: list[tuple[str, float]] = [] + if isinstance(entries, dict): + for message_id, seen_at in entries.items(): + normalized_id = _optional_str(message_id) + if not normalized_id: + continue + try: + timestamp = float(seen_at) + except (TypeError, ValueError): + continue + if now - timestamp <= _FEISHU_DEDUP_TTL_SECONDS: + valid.append((normalized_id, timestamp)) + for message_id, seen_at in sorted(valid, key=lambda item: item[1])[-_FEISHU_DEDUP_CACHE_SIZE:]: + self._seen_message_ids[message_id] = seen_at + + def _persist_seen_message_ids(self) -> None: + if not self._dedup_dirty: + return + self._dedup_state_path.parent.mkdir(parents=True, exist_ok=True) + payload = {"message_ids": dict(self._seen_message_ids)} + try: + self._dedup_state_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except OSError: + logger.warning("Failed to persist Feishu dedup cache to %s", self._dedup_state_path) + return + self._dedup_dirty = False + + def _is_message_seen(self, message_id: str) -> bool: + now = time.time() + self._prune_seen_message_ids(now) + return message_id in self._seen_message_ids + + def _remember_message_seen(self, message_id: str) -> None: + now = time.time() + self._prune_seen_message_ids(now) + if message_id in self._seen_message_ids: + self._seen_message_ids.move_to_end(message_id) + return + self._seen_message_ids[message_id] = now + self._seen_message_ids.move_to_end(message_id) + while len(self._seen_message_ids) > _FEISHU_DEDUP_CACHE_SIZE: + self._seen_message_ids.popitem(last=False) + self._dedup_dirty = True + self._persist_seen_message_ids() + + def _mark_message_seen(self, message_id: str) -> bool: + if self._is_message_seen(message_id): + return True + self._remember_message_seen(message_id) + return False + + def _prune_seen_message_ids(self, now: Optional[float] = None) -> None: + current = now or time.time() + stale = [ + message_id + for message_id, seen_at in self._seen_message_ids.items() + if current - seen_at > _FEISHU_DEDUP_TTL_SECONDS + ] + for message_id in stale: + self._seen_message_ids.pop(message_id, None) + self._dedup_dirty = True + + def _check_webhook_rate_limit(self, rate_key: str) -> bool: + now = time.time() + window = self._rate_windows.get(rate_key) + if window is None: + if len(self._rate_windows) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS: + stale_keys = [ + key + for key, timestamps in self._rate_windows.items() + if not timestamps or now - timestamps[-1] > _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS + ] + for key in stale_keys: + self._rate_windows.pop(key, None) + if rate_key not in self._rate_windows and len(self._rate_windows) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS: + return False + window = deque() + self._rate_windows[rate_key] = window + cutoff = now - _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS + while window and window[0] < cutoff: + window.popleft() + if len(window) >= _FEISHU_WEBHOOK_RATE_LIMIT_MAX: + return False + window.append(now) + return True + + def _record_webhook_anomaly(self, remote_ip: str, status: str) -> None: + now = time.time() + current = self._webhook_anomalies.get(remote_ip) + if current and now - current[2] < _FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS: + self._webhook_anomalies[remote_ip] = (current[0] + 1, status, current[2]) + return + self._webhook_anomalies[remote_ip] = (1, status, now) + + def _clear_webhook_anomaly(self, remote_ip: str) -> None: + self._webhook_anomalies.pop(remote_ip, None) + + +def _safe_json_loads(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + try: + return json.loads(str(value or "{}")) + except json.JSONDecodeError: + return {} + + +def _optional_str(value: Any) -> Optional[str]: + if value is None: + return None + value = str(value).strip() + return value or None + + +def _client_ip_from_request(request: web.Request) -> str: + forwarded = str(request.headers.get("x-forwarded-for", "") or "").split(",")[0].strip() + if forwarded: + return forwarded + peer = request.transport.get_extra_info("peername") if request.transport else None + if isinstance(peer, tuple) and peer: + return str(peer[0]) + return request.remote or "unknown" + + +def _resolve_mentions(text: str, mentions: list[Any]) -> str: + if not text or not mentions: + return text + + resolved = text + for mention in mentions: + key = _optional_str( + mention.get("key") if isinstance(mention, dict) else getattr(mention, "key", None) + ) + if not key or key not in resolved: + continue + name = _optional_str( + mention.get("name") if isinstance(mention, dict) else getattr(mention, "name", None) + ) or "user" + resolved = resolved.replace(key, f"@{name}") + return resolved + + +def _split_text(content: str, limit: int) -> list[str]: + text = content.strip() + if not text: + return [""] + if len(text) <= limit: + return [text] + chunks = [] + remaining = text + while remaining: + chunk = remaining[:limit] + if len(remaining) > limit: + split_at = chunk.rfind("\n") + if split_at < limit // 3: + split_at = chunk.rfind(" ") + if split_at >= limit // 3: + chunk = chunk[:split_at] + chunks.append(chunk.strip()) + remaining = remaining[len(chunk):].lstrip() + return [chunk for chunk in chunks if chunk] + + +def _is_webhook_signature_valid(*, encrypt_key: str, headers: Any, body_bytes: bytes) -> bool: + timestamp = str(headers.get("x-lark-request-timestamp", "") or "") + nonce = str(headers.get("x-lark-request-nonce", "") or "") + signature = str(headers.get("x-lark-signature", "") or "") + if not timestamp or not nonce or not signature: + return False + content = f"{timestamp}{nonce}{encrypt_key}{body_bytes.decode('utf-8', errors='replace')}" + expected = hashlib.sha256(content.encode("utf-8")).hexdigest() + return hmac.compare_digest(signature, expected) + + +def _build_session_key_hint(source: ChannelSource) -> str: + parts = [source.platform.value, source.chat_id] + if source.thread_id: + parts.append(source.thread_id) + return "__".join(part.replace("/", "_") for part in parts if part) + + +def _extract_post_text(content: dict[str, Any]) -> str: + texts: list[str] = [] + + def _walk(value: Any) -> None: + if isinstance(value, dict): + title = _optional_str(value.get("title")) + if title: + texts.append(title) + tag = _optional_str(value.get("tag")) + if tag == "at": + user_name = _optional_str(value.get("user_name")) or "user" + texts.append(f"@{user_name}") + else: + text = _optional_str(value.get("text")) + if text: + texts.append(text) + for nested in value.values(): + _walk(nested) + elif isinstance(value, list): + for item in value: + _walk(item) + + _walk(content) + deduped: list[str] = [] + for text in texts: + if text not in deduped: + deduped.append(text) + return "\n".join(deduped).strip() + + +def _read_attachment_body(raw_file: Any, max_bytes: int) -> bytes: + if raw_file is None: + return b"" + + if isinstance(raw_file, bytes): + data = raw_file + elif isinstance(raw_file, bytearray): + data = bytes(raw_file) + elif hasattr(raw_file, "read"): + chunks: list[bytes] = [] + total = 0 + try: + while True: + chunk = raw_file.read(65536) + if not chunk: + break + if isinstance(chunk, str): + chunk = chunk.encode("utf-8") + elif isinstance(chunk, bytearray): + chunk = bytes(chunk) + elif not isinstance(chunk, bytes): + chunk = bytes(chunk) + total += len(chunk) + if total > max_bytes: + raise ValueError( + f"attachment size {total} exceeds limit {max_bytes}" + ) + chunks.append(chunk) + finally: + close = getattr(raw_file, "close", None) + if callable(close): + close() + data = b"".join(chunks) + else: + data = bytes(raw_file) + + if len(data) > max_bytes: + raise ValueError(f"attachment size {len(data)} exceeds limit {max_bytes}") + return data diff --git a/openspace/communication/adapters/whatsapp.py b/openspace/communication/adapters/whatsapp.py new file mode 100644 index 0000000..7218965 --- /dev/null +++ b/openspace/communication/adapters/whatsapp.py @@ -0,0 +1,462 @@ +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import re +import secrets +import shutil +import subprocess +import uuid +from pathlib import Path +from typing import Any, Optional + +import aiohttp + +from openspace.communication.adapters.base import BaseChannelAdapter +from openspace.communication.attachment_cache import AttachmentCache +from openspace.communication.config import WhatsAppConfig +from openspace.communication.types import ( + AttachmentKind, + ChannelMessage, + ChannelPlatform, + ChannelSource, + SendResult, +) +from openspace.utils.logging import Logger + +logger = Logger.get_logger(__name__) + + +class WhatsAppAdapter(BaseChannelAdapter): + def __init__( + self, + config: WhatsAppConfig, + attachment_cache: AttachmentCache, + *, + runtime_dir: Optional[Path] = None, + poll_interval_seconds: float = 1.0, + ): + super().__init__(ChannelPlatform.WHATSAPP) + self.config = config + self.attachment_cache = attachment_cache + self.runtime_dir = ( + Path(runtime_dir).expanduser().resolve() + if runtime_dir is not None + else attachment_cache.base_dir.parent.resolve() + ) + self._poll_interval_seconds = poll_interval_seconds + self._http_session: Optional[aiohttp.ClientSession] = None + self._ws: Optional[aiohttp.ClientWebSocketResponse] = None + self._receiver_task: Optional[asyncio.Task] = None + self._bridge_process: Optional[subprocess.Popen] = None + self._pending_requests: dict[str, asyncio.Future[dict[str, Any]]] = {} + self._auth_event = asyncio.Event() + self._status_event = asyncio.Event() + self._bridge_state = "disconnected" + + def validate_configuration(self) -> None: + if self.config.bridge.enforce_loopback and self.config.bridge.host not in {"127.0.0.1", "localhost"}: + raise ValueError("WhatsApp bridge host must stay on loopback") + + def get_lock_identity(self) -> Optional[tuple[str, str]]: + return ("whatsapp-session", str(self._session_dir().resolve())) + + async def connect(self) -> bool: + self.validate_configuration() + if self._http_session is None: + self._http_session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=20), + ) + + for attempt in range(2): + try: + await self._open_control_socket() + except Exception as exc: + logger.info("WhatsApp bridge connection attempt %s failed: %s", attempt + 1, exc) + if attempt == 0: + await self._start_bridge_process() + await asyncio.sleep(1) + continue + return False + break + + for _ in range(20): + if self._bridge_state == "connected": + self._connected = True + return True + await asyncio.sleep(1) + logger.error("WhatsApp bridge control channel opened but WhatsApp session did not connect") + return False + + async def disconnect(self) -> None: + self._connected = False + self._bridge_state = "disconnected" + self._status_event.clear() + self._auth_event.clear() + + if self._receiver_task is not None: + self._receiver_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._receiver_task + self._receiver_task = None + + if self._ws is not None: + await self._ws.close() + self._ws = None + + for future in self._pending_requests.values(): + if not future.done(): + future.set_exception(RuntimeError("WhatsApp bridge disconnected")) + self._pending_requests.clear() + + if self._http_session is not None: + await self._http_session.close() + self._http_session = None + + if self._bridge_process is not None and self._bridge_process.poll() is None: + self._bridge_process.terminate() + try: + self._bridge_process.wait(timeout=10) + except subprocess.TimeoutExpired: + self._bridge_process.kill() + self._bridge_process = None + + async def send_text( + self, + chat_id: str, + content: str, + *, + reply_to_message_id: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + ) -> SendResult: + if self._ws is None: + return SendResult(success=False, error="WhatsApp bridge not initialized") + + last_message_id: Optional[str] = None + for chunk in _split_text(content, 60000): + try: + payload = await self._send_command( + { + "type": "send", + "to": chat_id, + "text": chunk, + "replyToMessageId": reply_to_message_id, + } + ) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + last_message_id = _optional_str(payload.get("messageId")) or last_message_id + return SendResult(success=True, message_id=last_message_id) + + async def send_media( + self, + chat_id: str, + *, + file_path: str, + mimetype: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to_message_id: Optional[str] = None, + ) -> SendResult: + if self._ws is None: + return SendResult(success=False, error="WhatsApp bridge not initialized") + try: + payload = await self._send_command( + { + "type": "send_media", + "to": chat_id, + "filePath": file_path, + "mimetype": mimetype, + "caption": caption, + "fileName": file_name, + "replyToMessageId": reply_to_message_id, + } + ) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + return SendResult(success=True, message_id=_optional_str(payload.get("messageId"))) + + async def _open_control_socket(self) -> None: + if self._http_session is None: + raise RuntimeError("WhatsApp bridge HTTP session is not initialized") + if self._receiver_task is not None: + self._receiver_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._receiver_task + self._receiver_task = None + if self._ws is not None: + await self._ws.close() + self._ws = None + self._auth_event.clear() + self._status_event.clear() + ws = await self._http_session.ws_connect( + self.config.bridge.ws_url, + heartbeat=20, + autoping=True, + max_msg_size=4 * 1024 * 1024, + ) + self._ws = ws + self._receiver_task = asyncio.create_task(self._receive_loop(ws)) + await self._send_ws_json({"type": "auth", "token": self._effective_bridge_token()}) + await asyncio.wait_for(self._auth_event.wait(), timeout=5) + + async def _receive_loop(self, ws: aiohttp.ClientWebSocketResponse) -> None: + try: + async for msg in ws: + if msg.type != aiohttp.WSMsgType.TEXT: + if msg.type in { + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.ERROR, + }: + break + continue + try: + payload = json.loads(msg.data) + except json.JSONDecodeError: + logger.warning("Ignoring invalid WhatsApp bridge JSON: %r", msg.data[:200]) + continue + await self._handle_ws_payload(payload) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning("WhatsApp bridge receive loop stopped: %s", exc) + finally: + if self._ws is ws: + self._ws = None + self._connected = False + self._bridge_state = "disconnected" + self._status_event.clear() + for request_id, future in list(self._pending_requests.items()): + if not future.done(): + future.set_exception(RuntimeError("WhatsApp bridge disconnected")) + self._pending_requests.pop(request_id, None) + + async def _handle_ws_payload(self, payload: dict[str, Any]) -> None: + message_type = str(payload.get("type", "")).strip().lower() + if message_type == "auth_ok": + self._auth_event.set() + return + if message_type == "status": + self._bridge_state = str(payload.get("status", "")).strip().lower() or "disconnected" + self._connected = self._bridge_state == "connected" + self._status_event.set() + return + if message_type == "qr": + logger.info("WhatsApp bridge is waiting for QR scan") + return + if message_type == "ack": + request_id = _optional_str(payload.get("requestId")) + if request_id and request_id in self._pending_requests: + future = self._pending_requests.pop(request_id) + if not future.done(): + future.set_result(payload) + return + if message_type == "error": + request_id = _optional_str(payload.get("requestId")) + error = _optional_str(payload.get("error")) or "Unknown bridge error" + if request_id and request_id in self._pending_requests: + future = self._pending_requests.pop(request_id) + if not future.done(): + future.set_exception(RuntimeError(error)) + else: + logger.warning("WhatsApp bridge error: %s", error) + return + if message_type == "message": + message = await self._normalize_event(payload) + if message is not None: + await self.dispatch_message(message) + + async def _send_command(self, payload: dict[str, Any]) -> dict[str, Any]: + if self._ws is None: + raise RuntimeError("WhatsApp bridge control channel is not connected") + request_id = uuid.uuid4().hex[:12] + future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future() + self._pending_requests[request_id] = future + try: + await self._send_ws_json({**payload, "requestId": request_id}) + return await asyncio.wait_for(future, timeout=20) + finally: + self._pending_requests.pop(request_id, None) + + async def _send_ws_json(self, payload: dict[str, Any]) -> None: + if self._ws is None: + raise RuntimeError("WhatsApp bridge control channel is not connected") + await self._ws.send_str(json.dumps(payload, ensure_ascii=False)) + + async def _normalize_event(self, event: dict[str, Any]) -> Optional[ChannelMessage]: + chat_id = str(event.get("chatId", "")).strip() + message_id = str(event.get("messageId", "")).strip() + sender_id = str(event.get("senderId", "")).strip() + if not chat_id or not message_id: + return None + normalized_sender_id = _normalize_whatsapp_identifier(sender_id) + + source = ChannelSource( + platform=ChannelPlatform.WHATSAPP, + chat_id=chat_id, + chat_type="group" if event.get("isGroup") else "dm", + user_id=normalized_sender_id or sender_id or None, + user_name=_optional_str(event.get("senderName")), + chat_name=_optional_str(event.get("chatName")), + ) + + session_key = _build_session_key_hint(source) + attachments = [] + media_type = str(event.get("mediaType", "")).strip().lower() + attachment_kind = AttachmentKind.IMAGE if media_type == "image" else AttachmentKind.DOCUMENT + for media_path in event.get("mediaUrls") or []: + attachment = self.attachment_cache.copy_local_file( + session_key=session_key, + source_path=str(media_path), + kind=attachment_kind, + ) + if attachment is not None: + attachments.append(attachment) + + body = str(event.get("body", "") or "").strip() + return ChannelMessage( + source=source, + text=body, + message_id=message_id, + attachments=attachments, + reply_to_message_id=_optional_str(event.get("replyToMessageId")), + mentions_bot=bool(event.get("mentionsBot")), + metadata={ + "bridge_event": event, + "raw_user_id": sender_id or None, + "auth_candidates": [ + candidate + for candidate in ( + sender_id or None, + normalized_sender_id or None, + f"+{normalized_sender_id}" if normalized_sender_id else None, + ) + if candidate + ], + }, + ) + + async def _start_bridge_process(self) -> None: + if self._bridge_process is not None and self._bridge_process.poll() is None: + return + + bridge_script = self._resolve_bridge_script() + bridge_dir = bridge_script.parent + session_dir = self._session_dir() + session_dir.mkdir(parents=True, exist_ok=True) + self._outbound_media_root().mkdir(parents=True, exist_ok=True) + + if self.config.bridge.auto_install_dependencies and not (bridge_dir / "node_modules").exists(): + subprocess.run( + ["npm", "install", "--silent"], + cwd=bridge_dir, + check=True, + ) + + env = os.environ.copy() + env["BRIDGE_TOKEN"] = self._effective_bridge_token() + env["BRIDGE_MEDIA_ROOT"] = str(self._outbound_media_root()) + if self.config.allowed_users: + env["WHATSAPP_ALLOWED_USERS"] = ",".join(self.config.allowed_users) + if self.config.reply_prefix is not None: + env["WHATSAPP_REPLY_PREFIX"] = self.config.reply_prefix + + self._bridge_process = subprocess.Popen( + [ + "node", + str(bridge_script), + "--host", + self.config.bridge.host, + "--port", + str(self.config.bridge.port), + "--session", + str(session_dir), + "--mode", + self.config.bridge.mode, + ], + cwd=str(bridge_dir), + env=env, + ) + + def _resolve_bridge_script(self) -> Path: + if self.config.bridge.script_path: + custom_path = Path(self.config.bridge.script_path).expanduser().resolve() + return custom_path / "bridge.js" if custom_path.is_dir() else custom_path + + source_dir = Path(__file__).resolve().parent.parent / "bridges" / "whatsapp" + target_dir = self.runtime_dir / "whatsapp-bridge" + target_dir.mkdir(parents=True, exist_ok=True) + for filename in ("bridge.js", "allowlist.js", "package.json"): + shutil.copy2(source_dir / filename, target_dir / filename) + return target_dir / "bridge.js" + + def _effective_bridge_token(self) -> str: + configured = _optional_str(self.config.bridge.token) + if configured: + return configured + + token_path = self.runtime_dir / "bridge_tokens" / "whatsapp.token" + if token_path.exists(): + token = token_path.read_text(encoding="utf-8").strip() + if token: + return token + token_path.parent.mkdir(parents=True, exist_ok=True) + token = secrets.token_urlsafe(32) + token_path.write_text(token, encoding="utf-8") + try: + token_path.chmod(0o600) + except OSError: + pass + return token + + def _session_dir(self) -> Path: + if self.config.bridge.session_dir: + return Path(self.config.bridge.session_dir).expanduser().resolve() + return (self.runtime_dir / "whatsapp" / "session").resolve() + + def _outbound_media_root(self) -> Path: + return (self.runtime_dir / "outbound_media").resolve() + + +def _split_text(content: str, limit: int) -> list[str]: + text = content.strip() + if not text: + return [""] + if len(text) <= limit: + return [text] + chunks = [] + remaining = text + while remaining: + chunk = remaining[:limit] + if len(remaining) > limit: + split_at = chunk.rfind("\n") + if split_at < limit // 3: + split_at = chunk.rfind(" ") + if split_at >= limit // 3: + chunk = chunk[:split_at] + chunks.append(chunk.strip()) + remaining = remaining[len(chunk):].lstrip() + return [chunk for chunk in chunks if chunk] + + +def _optional_str(value: Any) -> Optional[str]: + if value is None: + return None + value = str(value).strip() + return value or None + + +def _build_session_key_hint(source: ChannelSource) -> str: + parts = [source.platform.value, source.chat_id] + if source.thread_id: + parts.append(source.thread_id) + return "__".join(part.replace("/", "_") for part in parts if part) + + +def _normalize_whatsapp_identifier(value: Any) -> str: + normalized = re.sub(r":.*@", "@", str(value or "").strip()) + normalized = re.sub(r"@.*", "", normalized) + return normalized.lstrip("+") diff --git a/openspace/communication/attachment_cache.py b/openspace/communication/attachment_cache.py new file mode 100644 index 0000000..4c660fe --- /dev/null +++ b/openspace/communication/attachment_cache.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import shutil +import uuid +from pathlib import Path +from typing import Optional + +from openspace.utils.logging import Logger + +from .types import AttachmentKind, ChannelAttachment + +logger = Logger.get_logger(__name__) + + +class AttachmentCache: + def __init__( + self, + base_dir: Path, + *, + max_attachment_bytes: int = 25 * 1024 * 1024, + max_session_attachment_bytes: int = 100 * 1024 * 1024, + ): + self.base_dir = base_dir + self.max_attachment_bytes = max_attachment_bytes + self.max_session_attachment_bytes = max_session_attachment_bytes + self.base_dir.mkdir(parents=True, exist_ok=True) + + def session_dir(self, session_key: str) -> Path: + directory = self.base_dir / session_key / "attachments" + directory.mkdir(parents=True, exist_ok=True) + return directory + + def save_bytes( + self, + *, + session_key: str, + data: bytes, + filename: str, + kind: AttachmentKind, + mime_type: str = "", + ) -> Optional[ChannelAttachment]: + data_size = len(data) + if not self._within_limits(session_key, data_size): + return None + + directory = self.session_dir(session_key) + safe_name = _safe_name(filename) + target = directory / f"{uuid.uuid4().hex[:12]}_{safe_name}" + target.write_bytes(data) + return ChannelAttachment( + kind=kind, + path=str(target), + name=safe_name, + mime_type=mime_type, + size_bytes=len(data), + ) + + def copy_local_file( + self, + *, + session_key: str, + source_path: str, + kind: AttachmentKind, + preferred_name: Optional[str] = None, + mime_type: str = "", + ) -> Optional[ChannelAttachment]: + source = Path(source_path).expanduser() + if not source.exists(): + logger.warning("Attachment source does not exist: %s", source) + return None + source_size = source.stat().st_size + if not self._within_limits(session_key, source_size): + return None + + directory = self.session_dir(session_key) + safe_name = _safe_name(preferred_name or source.name) + target = directory / f"{uuid.uuid4().hex[:12]}_{safe_name}" + shutil.copy2(source, target) + return ChannelAttachment( + kind=kind, + path=str(target), + name=safe_name, + mime_type=mime_type, + size_bytes=target.stat().st_size, + metadata={"source_path": str(source)}, + ) + + def _within_limits(self, session_key: str, attachment_size: int) -> bool: + if attachment_size > self.max_attachment_bytes: + logger.warning( + "Rejecting attachment for session %s because %d bytes exceeds limit %d", + session_key, + attachment_size, + self.max_attachment_bytes, + ) + return False + + session_usage = self._session_usage_bytes(session_key) + if session_usage + attachment_size > self.max_session_attachment_bytes: + logger.warning( + "Rejecting attachment for session %s because session quota would exceed %d bytes", + session_key, + self.max_session_attachment_bytes, + ) + return False + return True + + def _session_usage_bytes(self, session_key: str) -> int: + directory = self.base_dir / session_key / "attachments" + if not directory.exists(): + return 0 + + total = 0 + for path in directory.iterdir(): + if path.is_file(): + total += path.stat().st_size + return total + + +def _safe_name(name: str) -> str: + value = (name or "attachment").replace("\x00", "").strip() + value = Path(value).name + return value or "attachment" diff --git a/openspace/communication/bridges/whatsapp/allowlist.js b/openspace/communication/bridges/whatsapp/allowlist.js new file mode 100644 index 0000000..c4a4948 --- /dev/null +++ b/openspace/communication/bridges/whatsapp/allowlist.js @@ -0,0 +1,71 @@ +import path from 'path'; +import { existsSync, readFileSync } from 'fs'; + +export function normalizeWhatsAppIdentifier(value) { + return String(value || '') + .trim() + .replace(/:.*@/, '@') + .replace(/@.*/, '') + .replace(/^\+/, ''); +} + +export function parseAllowedUsers(rawValue) { + return new Set( + String(rawValue || '') + .split(',') + .map((value) => normalizeWhatsAppIdentifier(value)) + .filter(Boolean) + ); +} + +function readMappingFile(sessionDir, identifier, suffix = '') { + const filePath = path.join(sessionDir, `lid-mapping-${identifier}${suffix}.json`); + if (!existsSync(filePath)) { + return null; + } + + try { + const parsed = JSON.parse(readFileSync(filePath, 'utf8')); + const normalized = normalizeWhatsAppIdentifier(parsed); + return normalized || null; + } catch { + return null; + } +} + +export function expandWhatsAppIdentifiers(identifier, sessionDir) { + const normalized = normalizeWhatsAppIdentifier(identifier); + if (!normalized) { + return new Set(); + } + + const resolved = new Set(); + const queue = [normalized]; + while (queue.length > 0) { + const current = queue.shift(); + if (!current || resolved.has(current)) { + continue; + } + resolved.add(current); + for (const suffix of ['', '_reverse']) { + const mapped = readMappingFile(sessionDir, current, suffix); + if (mapped && !resolved.has(mapped)) { + queue.push(mapped); + } + } + } + return resolved; +} + +export function matchesAllowedUser(senderId, allowedUsers, sessionDir) { + if (!allowedUsers || allowedUsers.size === 0) { + return true; + } + const aliases = expandWhatsAppIdentifiers(senderId, sessionDir); + for (const alias of aliases) { + if (allowedUsers.has(alias)) { + return true; + } + } + return false; +} diff --git a/openspace/communication/bridges/whatsapp/bridge.js b/openspace/communication/bridges/whatsapp/bridge.js new file mode 100644 index 0000000..56e3c2d --- /dev/null +++ b/openspace/communication/bridges/whatsapp/bridge.js @@ -0,0 +1,577 @@ +#!/usr/bin/env node +import { + DisconnectReason, + downloadMediaMessage, + fetchLatestBaileysVersion, + makeWASocket, + useMultiFileAuthState, +} from '@whiskeysockets/baileys'; +import { Boom } from '@hapi/boom'; +import pino from 'pino'; +import path from 'path'; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + statSync, + writeFileSync, +} from 'fs'; +import { randomBytes } from 'crypto'; +import qrcode from 'qrcode-terminal'; +import { WebSocketServer, WebSocket } from 'ws'; + +import { + matchesAllowedUser, + normalizeWhatsAppIdentifier, + parseAllowedUsers, +} from './allowlist.js'; + +const args = process.argv.slice(2); + +function getArg(name, defaultValue) { + const index = args.indexOf(`--${name}`); + return index !== -1 && args[index + 1] ? args[index + 1] : defaultValue; +} + +const PORT = parseInt(getArg('port', '3000'), 10); +const HOST = getArg('host', '127.0.0.1'); +const BIND_HOST = HOST === 'localhost' ? '127.0.0.1' : HOST; +const SESSION_DIR = path.resolve( + getArg('session', path.join(process.env.HOME || '~', '.openspace', 'whatsapp', 'session')) +); +const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); +const BRIDGE_TOKEN = String(process.env.BRIDGE_TOKEN || '').trim(); +const DEFAULT_REPLY_PREFIX = 'OpenSpace\n────────────\n'; +const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined + ? DEFAULT_REPLY_PREFIX + : process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n'); +const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || ''); + +const IMAGE_CACHE_DIR = path.join(SESSION_DIR, '..', 'image_cache'); +const DOCUMENT_CACHE_DIR = path.join(SESSION_DIR, '..', 'document_cache'); +const AUDIO_CACHE_DIR = path.join(SESSION_DIR, '..', 'audio_cache'); + +const MAX_RECENT_SENT = 50; +const MAX_RECENT_INBOUND = 512; +const AUTH_TIMEOUT_MS = 5000; + +if (!BRIDGE_TOKEN) { + console.error('BRIDGE_TOKEN is required'); + process.exit(1); +} +if (!['127.0.0.1', 'localhost'].includes(HOST)) { + console.error(`Refusing to bind WhatsApp bridge to non-loopback host: ${HOST}`); + process.exit(1); +} + +mkdirSync(SESSION_DIR, { recursive: true }); +mkdirSync(IMAGE_CACHE_DIR, { recursive: true }); +mkdirSync(DOCUMENT_CACHE_DIR, { recursive: true }); +mkdirSync(AUDIO_CACHE_DIR, { recursive: true }); + +const logger = pino({ level: 'warn' }); +const clients = new Set(); +const recentlySentIds = new Set(); +const recentInboundById = new Map(); + +let sock = null; +let connectionState = 'disconnected'; +let reconnectTimer = null; + +function broadcast(payload) { + const encoded = JSON.stringify(payload); + for (const ws of clients) { + if (ws.readyState === WebSocket.OPEN && ws._authed) { + ws.send(encoded); + } + } +} + +function recordRecentOutbound(messageId) { + if (!messageId) { + return; + } + recentlySentIds.delete(messageId); + recentlySentIds.add(messageId); + while (recentlySentIds.size > MAX_RECENT_SENT) { + recentlySentIds.delete(recentlySentIds.values().next().value); + } +} + +function recordRecentInbound(messageId, rawMessage) { + if (!messageId || !rawMessage) { + return; + } + recentInboundById.delete(messageId); + recentInboundById.set(messageId, rawMessage); + while (recentInboundById.size > MAX_RECENT_INBOUND) { + const firstKey = recentInboundById.keys().next().value; + recentInboundById.delete(firstKey); + } +} + +function currentStatusPayload() { + return { type: 'status', status: connectionState }; +} + +function buildQuotedOptions(replyToMessageId) { + if (!replyToMessageId) { + return {}; + } + const quoted = recentInboundById.get(String(replyToMessageId).trim()); + return quoted ? { quoted } : {}; +} + +function formatOutgoingMessage(message) { + if (WHATSAPP_MODE !== 'self-chat') { + return message; + } + return REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : message; +} + +function buildLidMap() { + const mapping = {}; + try { + for (const fileName of readdirSync(SESSION_DIR)) { + const match = fileName.match(/^lid-mapping-(.+)\.json$/); + if (!match) { + continue; + } + const value = JSON.parse(readFileSync(path.join(SESSION_DIR, fileName), 'utf8')); + const normalized = normalizeWhatsAppIdentifier(value); + if (normalized) { + mapping[normalized] = match[1]; + mapping[match[1]] = normalized; + } + } + } catch { + return {}; + } + return mapping; +} + +let lidToPhone = buildLidMap(); + +function normalizeId(value) { + return normalizeWhatsAppIdentifier(value); +} + +function getMyIdentifiers() { + const ids = new Set(); + if (sock?.user?.id) { + ids.add(normalizeId(sock.user.id)); + } + if (sock?.user?.lid) { + ids.add(normalizeId(sock.user.lid)); + } + return ids; +} + +function getMessageContainer(message) { + return message?.message || {}; +} + +function extractContextInfo(message) { + const container = getMessageContainer(message); + return ( + container.extendedTextMessage?.contextInfo + || container.imageMessage?.contextInfo + || container.videoMessage?.contextInfo + || container.documentMessage?.contextInfo + || container.audioMessage?.contextInfo + || container.conversation?.contextInfo + || null + ); +} + +async function cacheMedia(rawMessage, mediaMessage, targetDir, prefix, fallbackExt) { + const buffer = await downloadMediaMessage( + rawMessage, + 'buffer', + {}, + { logger, reuploadRequest: sock.updateMediaMessage } + ); + mkdirSync(targetDir, { recursive: true }); + const mime = mediaMessage.mimetype || ''; + const ext = mime.includes('/') ? `.${mime.split('/')[1].split(';')[0]}` : fallbackExt; + const filePath = path.join(targetDir, `${prefix}_${randomBytes(6).toString('hex')}${ext || fallbackExt}`); + writeFileSync(filePath, buffer); + return filePath; +} + +function resolveAllowedFilePath(filePath) { + const mediaRootRaw = String(process.env.BRIDGE_MEDIA_ROOT || '').trim(); + if (!mediaRootRaw) { + throw new Error('BRIDGE_MEDIA_ROOT is not configured'); + } + const mediaRoot = realpathSync(mediaRootRaw); + const resolvedPath = realpathSync(String(filePath || '')); + const relative = path.relative(mediaRoot, resolvedPath); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`File path escapes bridge media root: ${filePath}`); + } + const stat = statSync(resolvedPath); + if (!stat.isFile()) { + throw new Error(`File is not a regular file: ${filePath}`); + } + return resolvedPath; +} + +async function sendText(to, text, replyToMessageId) { + if (!sock || connectionState !== 'connected') { + throw new Error('Not connected to WhatsApp'); + } + const sent = await sock.sendMessage( + to, + { text: formatOutgoingMessage(text) }, + buildQuotedOptions(replyToMessageId) + ); + recordRecentOutbound(sent?.key?.id); + return sent; +} + +async function sendMedia(to, filePath, mimetype, caption, fileName, replyToMessageId) { + if (!sock || connectionState !== 'connected') { + throw new Error('Not connected to WhatsApp'); + } + + const resolvedPath = resolveAllowedFilePath(filePath); + if (!existsSync(resolvedPath)) { + throw new Error(`File not found: ${resolvedPath}`); + } + const buffer = readFileSync(resolvedPath); + const normalizedMime = String(mimetype || '').toLowerCase(); + let payload; + if (normalizedMime.startsWith('image/')) { + payload = { image: buffer, caption: caption || undefined }; + } else if (normalizedMime.startsWith('video/')) { + payload = { video: buffer, caption: caption || undefined }; + } else if (normalizedMime.startsWith('audio/')) { + payload = { + audio: buffer, + mimetype: normalizedMime || 'audio/ogg; codecs=opus', + ptt: normalizedMime.includes('ogg') || normalizedMime.includes('opus'), + }; + } else { + payload = { + document: buffer, + fileName: fileName || path.basename(resolvedPath), + caption: caption || undefined, + }; + } + + const sent = await sock.sendMessage( + to, + payload, + buildQuotedOptions(replyToMessageId) + ); + recordRecentOutbound(sent?.key?.id); + return sent; +} + +async function handleInboundMessage(rawMessage) { + if (!rawMessage?.message) { + return; + } + + const chatId = rawMessage.key?.remoteJid || ''; + const senderId = rawMessage.key?.participant || chatId; + const isGroup = chatId.endsWith('@g.us'); + const senderNumber = senderId.replace(/@.*/, ''); + + if (rawMessage.key?.fromMe) { + if (isGroup || chatId.includes('status')) { + return; + } + if (WHATSAPP_MODE === 'bot') { + return; + } + + const myIds = getMyIdentifiers(); + const chatNumber = normalizeId(chatId); + if (!myIds.has(chatNumber)) { + return; + } + } + + if (!rawMessage.key?.fromMe && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) { + return; + } + + const container = getMessageContainer(rawMessage); + const contextInfo = extractContextInfo(rawMessage); + const mentionedIds = (contextInfo?.mentionedJid || []).map((value) => normalizeId(value)); + const mentionsBot = mentionedIds.some((value) => getMyIdentifiers().has(value)); + const replyToMessageId = contextInfo?.stanzaId || null; + + let body = ''; + let hasMedia = false; + let mediaType = ''; + const mediaUrls = []; + + if (container.conversation) { + body = container.conversation; + } else if (container.extendedTextMessage?.text) { + body = container.extendedTextMessage.text; + } else if (container.imageMessage) { + body = container.imageMessage.caption || ''; + hasMedia = true; + mediaType = 'image'; + try { + mediaUrls.push(await cacheMedia(rawMessage, container.imageMessage, IMAGE_CACHE_DIR, 'img', '.jpg')); + } catch (error) { + console.error('[bridge] Failed to download image:', error.message); + } + } else if (container.videoMessage) { + body = container.videoMessage.caption || ''; + hasMedia = true; + mediaType = 'video'; + try { + mediaUrls.push(await cacheMedia(rawMessage, container.videoMessage, DOCUMENT_CACHE_DIR, 'vid', '.mp4')); + } catch (error) { + console.error('[bridge] Failed to download video:', error.message); + } + } else if (container.audioMessage || container.pttMessage) { + hasMedia = true; + mediaType = container.pttMessage ? 'ptt' : 'audio'; + try { + const audioMessage = container.pttMessage || container.audioMessage; + mediaUrls.push(await cacheMedia(rawMessage, audioMessage, AUDIO_CACHE_DIR, 'aud', '.ogg')); + } catch (error) { + console.error('[bridge] Failed to download audio:', error.message); + } + } else if (container.documentMessage) { + body = container.documentMessage.caption || ''; + hasMedia = true; + mediaType = 'document'; + try { + mediaUrls.push( + await cacheMedia( + rawMessage, + container.documentMessage, + DOCUMENT_CACHE_DIR, + 'doc', + path.extname(container.documentMessage.fileName || '') || '.bin' + ) + ); + } catch (error) { + console.error('[bridge] Failed to download document:', error.message); + } + } + + const messageId = rawMessage.key?.id; + if (messageId && recentlySentIds.has(messageId)) { + recentlySentIds.delete(messageId); + return; + } + + if (messageId) { + recordRecentInbound(messageId, rawMessage); + } + + const normalizedSenderId = lidToPhone[normalizeId(senderId)] || senderId; + const event = { + type: 'message', + messageId, + chatId, + senderId: normalizedSenderId, + senderName: rawMessage.pushName || senderNumber, + chatName: isGroup ? chatId.split('@')[0] : (rawMessage.pushName || senderNumber), + isGroup, + body, + hasMedia, + mediaType, + mediaUrls, + replyToMessageId, + mentionedIds, + mentionsBot, + timestamp: rawMessage.messageTimestamp, + }; + broadcast(event); +} + +async function startSocket() { + const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR); + const { version } = await fetchLatestBaileysVersion(); + + sock = makeWASocket({ + version, + auth: state, + logger, + printQRInTerminal: false, + browser: ['OpenSpace', 'Chrome', '120.0'], + syncFullHistory: false, + markOnlineOnConnect: false, + getMessage: async () => ({ conversation: '' }), + }); + + sock.ev.on('creds.update', () => { + saveCreds(); + lidToPhone = buildLidMap(); + }); + + sock.ev.on('connection.update', (update) => { + const { connection, lastDisconnect, qr } = update; + if (qr) { + console.log('\nScan this QR code with WhatsApp on your phone:\n'); + qrcode.generate(qr, { small: true }); + console.log('\nWaiting for scan...\n'); + broadcast({ type: 'qr', qr }); + } + + if (connection === 'close') { + const reason = new Boom(lastDisconnect?.error)?.output?.statusCode; + connectionState = 'disconnected'; + broadcast(currentStatusPayload()); + if (reason === DisconnectReason.loggedOut) { + console.log('Logged out. Delete session and restart to re-authenticate.'); + process.exit(1); + } + clearTimeout(reconnectTimer); + reconnectTimer = setTimeout( + () => startSocket().catch((error) => console.error('WhatsApp reconnect failed:', error)), + reason === 515 ? 1000 : 3000 + ); + } else if (connection === 'open') { + connectionState = 'connected'; + console.log('WhatsApp connected'); + broadcast(currentStatusPayload()); + } + }); + + sock.ev.on('messages.upsert', async ({ messages, type }) => { + if (type !== 'notify' && type !== 'append') { + return; + } + for (const message of messages) { + try { + await handleInboundMessage(message); + } catch (error) { + console.error('[bridge] Failed to normalize inbound message:', error); + } + } + }); +} + +function startBridgeServer() { + const wss = new WebSocketServer({ host: BIND_HOST, port: PORT }); + console.log(`OpenSpace WhatsApp bridge listening on ws://${BIND_HOST}:${PORT} (mode: ${WHATSAPP_MODE})`); + + wss.on('connection', (ws, request) => { + if (request.headers.origin) { + ws.close(4003, 'Origin header is not allowed'); + return; + } + + ws._authed = false; + clients.add(ws); + const authTimeout = setTimeout(() => { + if (!ws._authed) { + ws.close(4001, 'Authentication timeout'); + } + }, AUTH_TIMEOUT_MS); + + ws.once('message', (raw) => { + try { + const payload = JSON.parse(raw.toString()); + if (payload.type !== 'auth' || payload.token !== BRIDGE_TOKEN) { + ws.close(4003, 'Invalid bridge token'); + return; + } + ws._authed = true; + clearTimeout(authTimeout); + ws.send(JSON.stringify({ type: 'auth_ok' })); + ws.send(JSON.stringify(currentStatusPayload())); + + ws.on('message', async (commandRaw) => { + try { + const command = JSON.parse(commandRaw.toString()); + const requestId = command.requestId || null; + let sent = null; + if (command.type === 'send') { + sent = await sendText(command.to, command.text || '', command.replyToMessageId); + } else if (command.type === 'send_media') { + sent = await sendMedia( + command.to, + command.filePath, + command.mimetype, + command.caption, + command.fileName, + command.replyToMessageId + ); + } else { + throw new Error(`Unsupported bridge command: ${command.type}`); + } + ws.send(JSON.stringify({ + type: 'ack', + requestId, + messageId: sent?.key?.id || null, + })); + } catch (error) { + ws.send(JSON.stringify({ + type: 'error', + requestId: (() => { + try { + return JSON.parse(commandRaw.toString()).requestId || null; + } catch { + return null; + } + })(), + error: error?.message || String(error), + })); + } + }); + } catch { + ws.close(4003, 'Invalid auth payload'); + } + }); + + ws.on('close', () => { + clearTimeout(authTimeout); + clients.delete(ws); + }); + ws.on('error', () => { + clearTimeout(authTimeout); + clients.delete(ws); + }); + }); + + return wss; +} + +async function shutdown(server) { + clearTimeout(reconnectTimer); + for (const ws of clients) { + try { + ws.close(); + } catch {} + } + clients.clear(); + if (server) { + await new Promise((resolve) => server.close(resolve)); + } + if (sock) { + try { + sock.end(new Error('Bridge shutdown')); + } catch {} + sock = null; + } +} + +const server = startBridgeServer(); +startSocket().catch((error) => { + console.error('Failed to start WhatsApp socket:', error); + process.exit(1); +}); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, async () => { + try { + await shutdown(server); + } finally { + process.exit(0); + } + }); +} diff --git a/openspace/communication/bridges/whatsapp/package.json b/openspace/communication/bridges/whatsapp/package.json new file mode 100644 index 0000000..0279e20 --- /dev/null +++ b/openspace/communication/bridges/whatsapp/package.json @@ -0,0 +1,17 @@ +{ + "name": "openspace-whatsapp-bridge", + "version": "1.0.0", + "description": "WhatsApp bridge for OpenSpace using Baileys", + "private": true, + "type": "module", + "scripts": { + "start": "node bridge.js" + }, + "dependencies": { + "@hapi/boom": "^10.0.1", + "@whiskeysockets/baileys": "7.0.0-rc.9", + "pino": "^9.0.0", + "qrcode-terminal": "^0.12.0", + "ws": "^8.18.0" + } +} diff --git a/openspace/communication/config.py b/openspace/communication/config.py new file mode 100644 index 0000000..787839d --- /dev/null +++ b/openspace/communication/config.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, Field, field_validator, model_validator + +from openspace.host_detection import load_runtime_env +from openspace.utils.logging import Logger + +logger = Logger.get_logger(__name__) + + +class GatewayServerConfig(BaseModel): + host: str = "127.0.0.1" + port: int = Field(8765, ge=1, le=65535) + health_path: str = "/health" + + @field_validator("health_path") + @classmethod + def validate_health_path(cls, value: str) -> str: + value = value.strip() or "/health" + if not value.startswith("/"): + value = "/" + value + return value + + +class AgentExecutionConfig(BaseModel): + max_iterations: int = Field(20, ge=1, le=200) + enable_recording: bool = True + recording_backends: List[str] = Field(default_factory=lambda: ["shell"]) + backend_scope: Optional[List[str]] = None + grounding_config_path: Optional[str] = None + workspace_root: Optional[str] = None + llm_timeout: float = Field(120.0, ge=1.0, le=3600.0) + + +class SessionProcessingConfig(BaseModel): + history_max_turns: int = Field(12, ge=1, le=100) + max_parallel_sessions: int = Field(2, ge=1, le=64) + idle_ttl_seconds: int = Field(900, ge=30, le=86400) + per_session_queue_size: int = Field(32, ge=1, le=512) + whatsapp_poll_interval_seconds: float = Field(1.0, ge=0.1, le=60.0) + max_attachment_bytes: int = Field(25 * 1024 * 1024, ge=1, le=512 * 1024 * 1024) + max_session_attachment_bytes: int = Field( + 100 * 1024 * 1024, + ge=1, + le=10 * 1024 * 1024 * 1024, + ) + + +class ChannelAccessConfig(BaseModel): + enabled: bool = False + allow_all_users: bool = False + allowed_users: List[str] = Field(default_factory=list) + allow_dm: bool = True + allow_groups: bool = True + group_policy: Literal["disabled", "mention_only", "reply_or_mention", "all"] = "reply_or_mention" + + +class WhatsAppBridgeConfig(BaseModel): + host: str = "127.0.0.1" + port: int = Field(3000, ge=1, le=65535) + script_path: Optional[str] = None + session_dir: Optional[str] = None + mode: Literal["self-chat", "bot"] = "self-chat" + auto_install_dependencies: bool = True + token: Optional[str] = None + enforce_loopback: bool = True + + @model_validator(mode="after") + def validate_loopback_constraints(self) -> "WhatsAppBridgeConfig": + host = self.host.strip().lower() or "127.0.0.1" + if self.enforce_loopback and host not in {"127.0.0.1", "localhost"}: + raise ValueError( + "WhatsApp bridge host must be loopback when enforce_loopback is enabled" + ) + self.host = host + return self + + @property + def base_url(self) -> str: + return f"http://{self.host}:{self.port}" + + @property + def ws_url(self) -> str: + return f"ws://{self.listen_host}:{self.port}" + + @property + def listen_host(self) -> str: + return "127.0.0.1" if self.host == "localhost" else self.host + + +class WhatsAppConfig(ChannelAccessConfig): + bridge: WhatsAppBridgeConfig = Field(default_factory=WhatsAppBridgeConfig) + reply_prefix: Optional[str] = None + + +class FeishuConfig(ChannelAccessConfig): + app_id: Optional[str] = None + app_secret: Optional[str] = None + domain: Literal["feishu", "lark"] = "feishu" + connection_mode: Literal["webhook", "websocket"] = "webhook" + verification_token: Optional[str] = None + encrypt_key: Optional[str] = None + bot_open_id: Optional[str] = None + webhook_path: str = "/feishu/webhook" + + @model_validator(mode="after") + def validate_webhook_requirements(self) -> "FeishuConfig": + if self.enabled and self.connection_mode == "webhook" and not (self.verification_token or "").strip(): + raise ValueError("Feishu webhook mode requires verification_token") + return self + + @field_validator("webhook_path") + @classmethod + def validate_webhook_path(cls, value: str) -> str: + value = value.strip() or "/feishu/webhook" + if not value.startswith("/"): + value = "/" + value + return value + + @model_validator(mode="after") + def validate_webhook_security(self) -> "FeishuConfig": + if self.enabled and self.connection_mode == "webhook": + token = (self.verification_token or "").strip() + if not token: + raise ValueError( + "Feishu webhook mode requires verification_token when enabled" + ) + self.verification_token = token + if self.encrypt_key is not None: + self.encrypt_key = self.encrypt_key.strip() or None + if self.bot_open_id is not None: + self.bot_open_id = self.bot_open_id.strip() or None + return self + + +class CommunicationConfig(BaseModel): + data_dir: str = Field( + default_factory=lambda: str( + Path(__file__).resolve().parents[2] / "logs" / "communication" + ) + ) + server: GatewayServerConfig = Field(default_factory=GatewayServerConfig) + agent: AgentExecutionConfig = Field(default_factory=AgentExecutionConfig) + sessions: SessionProcessingConfig = Field(default_factory=SessionProcessingConfig) + whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) + feishu: FeishuConfig = Field(default_factory=FeishuConfig) + + @property + def openspace(self) -> AgentExecutionConfig: + return self.agent + + @property + def runtime(self) -> SessionProcessingConfig: + return self.sessions + + @property + def data_path(self) -> Path: + return Path(self.data_dir).expanduser().resolve() + + @property + def sessions_dir(self) -> Path: + return self.data_path / "sessions" + + @property + def bridge_assets_dir(self) -> Path: + return Path(__file__).resolve().parent / "bridges" / "whatsapp" + + @property + def runtime_status_path(self) -> Path: + return self.data_path / "runtime_status.json" + + @property + def locks_dir(self) -> Path: + return self.data_path / "locks" + + @property + def bridge_tokens_dir(self) -> Path: + return self.data_path / "bridge_tokens" + + @property + def whatsapp_bridge_token_path(self) -> Path: + return self.bridge_tokens_dir / "whatsapp.token" + + @property + def outbound_media_dir(self) -> Path: + return self.data_path / "outbound_media" + + @property + def feishu_seen_message_ids_path(self) -> Path: + return self.data_path / "feishu_seen_message_ids.json" + + @property + def enabled_platforms(self) -> List[str]: + platforms: List[str] = [] + if self.whatsapp.enabled: + platforms.append("whatsapp") + if self.feishu.enabled: + platforms.append("feishu") + return platforms + + +def load_communication_config(path: Optional[str] = None) -> CommunicationConfig: + load_runtime_env() + config_path = _resolve_config_path(path) + raw: Dict[str, Any] = {} + + if config_path and config_path.is_file(): + with open(config_path, "r", encoding="utf-8") as handle: + raw = json.load(handle) or {} + raw = _normalize_legacy_keys(raw) + logger.info("Loaded communication config: %s", config_path) + + config = CommunicationConfig.model_validate(raw) + _apply_env_overrides(config) + return CommunicationConfig.model_validate(config.model_dump(mode="python")) + + +def _resolve_config_path(path: Optional[str]) -> Optional[Path]: + explicit_path = Path(path).expanduser() if path else None + if explicit_path is not None: + if not explicit_path.is_file(): + raise FileNotFoundError(f"Communication config file not found: {explicit_path}") + return explicit_path + + env_path = ( + Path(os.environ["OPENSPACE_COMMUNICATION_CONFIG"]).expanduser() + if os.environ.get("OPENSPACE_COMMUNICATION_CONFIG") + else None + ) + if env_path is not None: + if not env_path.is_file(): + raise FileNotFoundError(f"Communication config file not found: {env_path}") + return env_path + + default_path = Path(__file__).resolve().parents[1] / "config" / "config_communication.json" + return default_path if default_path.is_file() else None + + +def _apply_env_overrides(config: CommunicationConfig) -> None: + _maybe_set_bool(config.whatsapp, "enabled", os.getenv("WHATSAPP_ENABLED")) + _maybe_set_bool(config.whatsapp, "allow_all_users", os.getenv("WHATSAPP_ALLOW_ALL_USERS")) + _maybe_set_list(config.whatsapp, "allowed_users", os.getenv("WHATSAPP_ALLOWED_USERS")) + _maybe_set_bool(config.whatsapp, "allow_dm", os.getenv("WHATSAPP_ALLOW_DM")) + _maybe_set_bool(config.whatsapp, "allow_groups", os.getenv("WHATSAPP_ALLOW_GROUPS")) + _maybe_set_str(config.whatsapp, "group_policy", os.getenv("WHATSAPP_GROUP_POLICY")) + _maybe_set_str(config.whatsapp.bridge, "host", os.getenv("WHATSAPP_BRIDGE_HOST")) + _maybe_set_int(config.whatsapp.bridge, "port", os.getenv("WHATSAPP_BRIDGE_PORT")) + _maybe_set_str(config.whatsapp.bridge, "script_path", os.getenv("WHATSAPP_BRIDGE_SCRIPT")) + _maybe_set_str(config.whatsapp.bridge, "session_dir", os.getenv("WHATSAPP_SESSION_DIR")) + _maybe_set_str(config.whatsapp.bridge, "mode", os.getenv("WHATSAPP_MODE")) + _maybe_set_str(config.whatsapp.bridge, "token", os.getenv("WHATSAPP_BRIDGE_TOKEN")) + _maybe_set_bool(config.whatsapp.bridge, "enforce_loopback", os.getenv("WHATSAPP_BRIDGE_ENFORCE_LOOPBACK")) + _maybe_set_str(config.whatsapp, "reply_prefix", os.getenv("WHATSAPP_REPLY_PREFIX")) + + _maybe_set_bool(config.feishu, "enabled", os.getenv("FEISHU_ENABLED")) + _maybe_set_bool(config.feishu, "allow_all_users", os.getenv("FEISHU_ALLOW_ALL_USERS")) + _maybe_set_list(config.feishu, "allowed_users", os.getenv("FEISHU_ALLOWED_USERS")) + _maybe_set_bool(config.feishu, "allow_dm", os.getenv("FEISHU_ALLOW_DM")) + _maybe_set_bool(config.feishu, "allow_groups", os.getenv("FEISHU_ALLOW_GROUPS")) + _maybe_set_str(config.feishu, "group_policy", os.getenv("FEISHU_GROUP_POLICY")) + _maybe_set_str(config.feishu, "app_id", os.getenv("FEISHU_APP_ID")) + _maybe_set_str(config.feishu, "app_secret", os.getenv("FEISHU_APP_SECRET")) + _maybe_set_str(config.feishu, "verification_token", os.getenv("FEISHU_VERIFICATION_TOKEN")) + _maybe_set_str(config.feishu, "encrypt_key", os.getenv("FEISHU_ENCRYPT_KEY")) + _maybe_set_str(config.feishu, "bot_open_id", os.getenv("FEISHU_BOT_OPEN_ID")) + _maybe_set_str(config.feishu, "domain", os.getenv("FEISHU_DOMAIN")) + _maybe_set_str(config.feishu, "connection_mode", os.getenv("FEISHU_CONNECTION_MODE")) + _maybe_set_str(config.feishu, "webhook_path", os.getenv("FEISHU_WEBHOOK_PATH")) + + _maybe_set_str(config, "data_dir", os.getenv("OPENSPACE_COMMUNICATION_DATA_DIR")) + _maybe_set_str(config.server, "host", os.getenv("OPENSPACE_COMMUNICATION_HOST")) + _maybe_set_int(config.server, "port", os.getenv("OPENSPACE_COMMUNICATION_PORT")) + _maybe_set_int( + config.agent, + "max_iterations", + os.getenv("OPENSPACE_COMMUNICATION_MAX_ITERATIONS") or os.getenv("OPENSPACE_MAX_ITERATIONS"), + ) + _maybe_set_bool( + config.agent, + "enable_recording", + os.getenv("OPENSPACE_COMMUNICATION_ENABLE_RECORDING") or os.getenv("OPENSPACE_ENABLE_RECORDING"), + ) + _maybe_set_list( + config.agent, + "recording_backends", + os.getenv("OPENSPACE_COMMUNICATION_RECORDING_BACKENDS"), + ) + _maybe_set_list( + config.agent, + "backend_scope", + os.getenv("OPENSPACE_COMMUNICATION_BACKEND_SCOPE") or os.getenv("OPENSPACE_BACKEND_SCOPE"), + ) + _maybe_set_str( + config.agent, + "grounding_config_path", + os.getenv("OPENSPACE_COMMUNICATION_GROUNDING_CONFIG_PATH") or os.getenv("OPENSPACE_CONFIG_PATH"), + ) + _maybe_set_str(config.agent, "workspace_root", os.getenv("OPENSPACE_COMMUNICATION_WORKSPACE_ROOT")) + _maybe_set_float(config.agent, "llm_timeout", os.getenv("OPENSPACE_COMMUNICATION_LLM_TIMEOUT")) + _maybe_set_int(config.sessions, "history_max_turns", os.getenv("OPENSPACE_COMMUNICATION_HISTORY_TURNS")) + _maybe_set_int(config.sessions, "max_parallel_sessions", os.getenv("OPENSPACE_COMMUNICATION_MAX_PARALLEL")) + _maybe_set_int(config.sessions, "idle_ttl_seconds", os.getenv("OPENSPACE_COMMUNICATION_IDLE_TTL")) + _maybe_set_int(config.sessions, "per_session_queue_size", os.getenv("OPENSPACE_COMMUNICATION_QUEUE_SIZE")) + _maybe_set_int( + config.sessions, + "max_attachment_bytes", + os.getenv("OPENSPACE_COMMUNICATION_MAX_ATTACHMENT_BYTES"), + ) + _maybe_set_int( + config.sessions, + "max_session_attachment_bytes", + os.getenv("OPENSPACE_COMMUNICATION_MAX_SESSION_ATTACHMENT_BYTES"), + ) + _maybe_set_float( + config.sessions, + "whatsapp_poll_interval_seconds", + os.getenv("OPENSPACE_COMMUNICATION_WHATSAPP_POLL_INTERVAL"), + ) + + +def _normalize_legacy_keys(raw: Dict[str, Any]) -> Dict[str, Any]: + normalized = dict(raw) + if "agent" not in normalized and "openspace" in normalized: + normalized["agent"] = normalized["openspace"] + if "sessions" not in normalized and "runtime" in normalized: + normalized["sessions"] = normalized["runtime"] + return normalized + + +def _maybe_set_bool(target: Any, field_name: str, raw: Optional[str]) -> None: + if raw is None: + return + lowered = raw.strip().lower() + if lowered in {"true", "1", "yes", "on"}: + setattr(target, field_name, True) + elif lowered in {"false", "0", "no", "off"}: + setattr(target, field_name, False) + + +def _maybe_set_int(target: Any, field_name: str, raw: Optional[str]) -> None: + if raw is None or not raw.strip(): + return + try: + setattr(target, field_name, int(raw)) + except ValueError: + logger.warning("Invalid integer for %s: %r", field_name, raw) + + +def _maybe_set_list(target: Any, field_name: str, raw: Optional[str]) -> None: + if raw is None: + return + values = [item.strip() for item in raw.split(",") if item.strip()] + setattr(target, field_name, values) + + +def _maybe_set_float(target: Any, field_name: str, raw: Optional[str]) -> None: + if raw is None or not raw.strip(): + return + try: + setattr(target, field_name, float(raw)) + except ValueError: + logger.warning("Invalid float for %s: %r", field_name, raw) + + +def _maybe_set_str(target: Any, field_name: str, raw: Optional[str]) -> None: + if raw is None: + return + value = raw.strip() + if value: + setattr(target, field_name, value) diff --git a/openspace/communication/gateway.py b/openspace/communication/gateway.py new file mode 100644 index 0000000..737ae77 --- /dev/null +++ b/openspace/communication/gateway.py @@ -0,0 +1,577 @@ +from __future__ import annotations + +import argparse +import asyncio +import os +from pathlib import Path +from typing import Any, Dict, Optional + +import requests +from aiohttp import web + +from openspace.communication.adapters import FeishuAdapter, WhatsAppAdapter +from openspace.communication.adapters.base import BaseChannelAdapter +from openspace.communication.attachment_cache import AttachmentCache +from openspace.communication.config import CommunicationConfig, load_communication_config +from openspace.communication.gateway_runtime import RuntimeStatusStore, ScopedLock, ScopedLockManager +from openspace.communication.policy import ( + build_attachment_instruction, + is_authorized, + should_accept_message, +) +from openspace.communication.runtime_manager import SessionRuntimeManager +from openspace.communication.session_store import SessionStore +from openspace.communication.types import ChannelMessage, ChannelPlatform, ChannelSession +from openspace.host_detection import build_grounding_config_path, build_llm_kwargs, load_runtime_env +from openspace.tool_layer import OpenSpace, OpenSpaceConfig +from openspace.utils.logging import Logger + +logger = Logger.get_logger(__name__) + + +def _append_no_proxy_hosts(*hosts: str) -> None: + for env_name in ("NO_PROXY", "no_proxy"): + current = os.environ.get(env_name, "") + entries = [entry.strip() for entry in current.split(",") if entry.strip()] + updated = False + for host in hosts: + if host not in entries: + entries.append(host) + updated = True + if updated: + os.environ[env_name] = ",".join(entries) + + +def _configure_ollama_process_env(model: str) -> None: + if not model.lower().startswith("ollama/"): + return + + _append_no_proxy_hosts("127.0.0.1", "localhost") + for env_name in ( + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ): + if os.environ.get(env_name): + logger.info("Clearing %s for local Ollama access", env_name) + os.environ.pop(env_name, None) + + +class CommunicationGateway: + def __init__(self, config: CommunicationConfig): + self.config = config + workspace_root = ( + Path(config.agent.workspace_root).expanduser().resolve() + if config.agent.workspace_root + else None + ) + self.session_store = SessionStore( + config.sessions_dir, + workspace_root=workspace_root, + ) + self.attachment_cache = AttachmentCache( + config.sessions_dir, + max_attachment_bytes=config.sessions.max_attachment_bytes, + max_session_attachment_bytes=config.sessions.max_session_attachment_bytes, + ) + self.runtime_manager = SessionRuntimeManager(config, self._create_openspace_runtime) + self._session_queues: Dict[str, asyncio.Queue[ChannelMessage]] = {} + self._session_workers: Dict[str, asyncio.Task] = {} + self._adapters: Dict[ChannelPlatform, BaseChannelAdapter] = {} + self._web_app: Optional[web.Application] = None + self._web_runner: Optional[web.AppRunner] = None + self._web_site: Optional[web.TCPSite] = None + self._running = False + self._runtime_manager_started = False + self._runtime_status = RuntimeStatusStore(self._runtime_status_path) + self._lock_manager = ScopedLockManager(self._locks_dir) + self._acquired_locks: list[ScopedLock] = [] + + async def start(self) -> None: + if self._running: + return + + self.config.data_path.mkdir(parents=True, exist_ok=True) + self._locks_dir.mkdir(parents=True, exist_ok=True) + self._bridge_tokens_dir.mkdir(parents=True, exist_ok=True) + self._outbound_media_dir.mkdir(parents=True, exist_ok=True) + + try: + self._build_adapters() + for adapter in self._adapters.values(): + validate_configuration = getattr(adapter, "validate_configuration", None) + if callable(validate_configuration): + validate_configuration() + self._acquire_adapter_locks() + self._write_runtime_status("starting") + await self.runtime_manager.start() + self._runtime_manager_started = True + + self._web_app = web.Application() + self._web_app.router.add_get(self.config.server.health_path, self._handle_health) + for adapter in self._adapters.values(): + adapter.register_http_routes(self._web_app) + + self._web_runner = web.AppRunner(self._web_app) + await self._web_runner.setup() + self._web_site = web.TCPSite( + self._web_runner, + self.config.server.host, + self.config.server.port, + ) + await self._web_site.start() + + for adapter in self._adapters.values(): + connected = await adapter.connect() + if not connected: + raise RuntimeError( + f"Communication adapter failed to connect: {adapter.platform.value}" + ) + + self._running = True + self._write_runtime_status("running") + logger.info( + "Communication gateway started on %s:%s for platforms=%s", + self.config.server.host, + self.config.server.port, + ",".join(self.config.enabled_platforms) or "(none)", + ) + except Exception as exc: + await self._rollback_start(exc) + raise + + async def stop(self) -> None: + if not self._running and not self._has_live_resources(): + return + + self._write_runtime_status("stopping") + self._running = False + await self._stop_session_workers() + await self._disconnect_adapters() + await self._cleanup_web_runner() + await self._stop_runtime_manager() + self._release_locks() + self._write_runtime_status("stopped") + logger.info("Communication gateway stopped") + + def _build_adapters(self) -> None: + adapters: Dict[ChannelPlatform, BaseChannelAdapter] = {} + if self.config.whatsapp.enabled: + adapter = self._instantiate_adapter( + WhatsAppAdapter, + self.config.whatsapp, + self.attachment_cache, + runtime_dir=self.config.data_path, + poll_interval_seconds=self.config.sessions.whatsapp_poll_interval_seconds, + ) + adapter.set_message_handler(self.handle_message) + adapters[ChannelPlatform.WHATSAPP] = adapter + if self.config.feishu.enabled: + adapter = self._instantiate_adapter( + FeishuAdapter, + self.config.feishu, + self.attachment_cache, + runtime_dir=self.config.data_path, + ) + adapter.set_message_handler(self.handle_message) + adapters[ChannelPlatform.FEISHU] = adapter + self._adapters = adapters + + @staticmethod + def _instantiate_adapter(adapter_cls: Any, *args: Any, **kwargs: Any) -> BaseChannelAdapter: + try: + return adapter_cls(*args, **kwargs) + except TypeError as exc: + if "unexpected keyword argument" not in str(exc): + raise + compatibility_kwargs = dict(kwargs) + compatibility_kwargs.pop("runtime_dir", None) + return adapter_cls(*args, **compatibility_kwargs) + + def _acquire_adapter_locks(self) -> None: + self._release_locks() + for adapter in self._adapters.values(): + get_lock_identity = getattr(adapter, "get_lock_identity", None) + binding = get_lock_identity() if callable(get_lock_identity) else None + if binding is None: + continue + scope, identity = binding + lock = self._lock_manager.acquire( + scope=scope, + identity=identity, + metadata={"platform": adapter.platform.value}, + ) + self._acquired_locks.append(lock) + + def _release_locks(self) -> None: + while self._acquired_locks: + self._lock_manager.release(self._acquired_locks.pop()) + + def _write_runtime_status( + self, + gateway_state: str, + *, + fatal_error: Optional[str] = None, + ) -> None: + platform_states = { + adapter.platform.value: {"connected": adapter.is_connected} + for adapter in self._adapters.values() + } + self._runtime_status.write( + gateway_state=gateway_state, + platforms=platform_states, + config_path=str(self.config.data_path), + fatal_error=fatal_error, + ) + + async def _rollback_start(self, exc: Exception) -> None: + logger.error("Communication gateway startup failed: %s", exc, exc_info=True) + self._running = False + await self._disconnect_adapters() + await self._cleanup_web_runner() + try: + await self._stop_runtime_manager() + finally: + self._release_locks() + self._write_runtime_status("failed", fatal_error=str(exc)) + + async def _stop_session_workers(self) -> None: + worker_tasks = list(self._session_workers.values()) + self._session_workers.clear() + for task in worker_tasks: + task.cancel() + if worker_tasks: + await asyncio.gather(*worker_tasks, return_exceptions=True) + self._session_queues.clear() + + async def _disconnect_adapters(self) -> None: + adapters = list(self._adapters.values()) + self._adapters.clear() + for adapter in adapters: + try: + await adapter.disconnect() + except Exception: + logger.warning( + "Failed to disconnect adapter during cleanup: %s", + getattr(adapter.platform, "value", "unknown"), + exc_info=True, + ) + + async def _cleanup_web_runner(self) -> None: + if self._web_runner is None: + return + try: + await self._web_runner.cleanup() + finally: + self._web_runner = None + self._web_site = None + self._web_app = None + + async def _stop_runtime_manager(self) -> None: + if not self._runtime_manager_started: + return + try: + await self.runtime_manager.stop() + finally: + self._runtime_manager_started = False + + def _has_live_resources(self) -> bool: + return any( + ( + self._runtime_manager_started, + bool(self._adapters), + self._web_runner is not None, + bool(self._acquired_locks), + bool(self._session_workers), + bool(self._session_queues), + ) + ) + + @property + def _runtime_status_path(self) -> Path: + return getattr(self.config, "runtime_status_path", self.config.data_path / "runtime_status.json") + + @property + def _locks_dir(self) -> Path: + return getattr(self.config, "locks_dir", self.config.data_path / "locks") + + @property + def _bridge_tokens_dir(self) -> Path: + return getattr(self.config, "bridge_tokens_dir", self.config.data_path / "bridge_tokens") + + @property + def _outbound_media_dir(self) -> Path: + return getattr(self.config, "outbound_media_dir", self.config.data_path / "outbound_media") + + async def handle_message(self, message: ChannelMessage) -> None: + session = self.session_store.get_or_create_session(message.source) + queue = self._session_queues.get(session.session_key) + if queue is None: + queue = asyncio.Queue(maxsize=self.config.sessions.per_session_queue_size) + self._session_queues[session.session_key] = queue + worker = self._session_workers.get(session.session_key) + if worker is None or worker.done(): + self._session_workers[session.session_key] = asyncio.create_task( + self._session_worker(session, queue) + ) + await queue.put(message) + + async def _session_worker( + self, + session: ChannelSession, + queue: asyncio.Queue[ChannelMessage], + ) -> None: + session_key = session.session_key + try: + while True: + try: + message = await asyncio.wait_for( + queue.get(), + timeout=self.config.sessions.idle_ttl_seconds, + ) + except asyncio.TimeoutError: + if queue.empty(): + logger.info("Retiring idle communication worker: %s", session_key) + return + continue + + try: + await self._process_message(session, message) + except Exception as exc: + logger.error( + "Failed to process %s message for session %s: %s", + message.source.platform.value, + session.session_key, + exc, + exc_info=True, + ) + adapter = self._adapters.get(message.source.platform) + if adapter: + await adapter.send_text( + message.source.chat_id, + f"OpenSpace communication error: {exc}", + ) + finally: + queue.task_done() + finally: + current_task = asyncio.current_task() + if self._session_workers.get(session_key) is current_task: + self._session_workers.pop(session_key, None) + if queue.empty(): + if ( + self._session_queues.get(session_key) is queue + and session_key not in self._session_workers + ): + self._session_queues.pop(session_key, None) + elif self._running and session_key not in self._session_workers: + self._session_workers[session_key] = asyncio.create_task( + self._session_worker(session, queue) + ) + + async def _process_message(self, session: ChannelSession, message: ChannelMessage) -> None: + platform_config = self._get_platform_config(message.source.platform) + if not is_authorized(message, platform_config): + logger.info( + "Rejected %s message from unauthorized user %s", + message.source.platform.value, + message.source.user_id, + ) + return + + reply_to_bot = self.session_store.is_reply_to_assistant( + session, + message.reply_to_message_id, + ) + if not should_accept_message(message, platform_config, reply_to_bot): + logger.debug( + "Skipped %s group message that did not satisfy policy", + message.source.platform.value, + ) + return + + history = self.session_store.load_history( + session, + self.config.sessions.history_max_turns, + ) + if not message.text.strip(): + message.text = build_attachment_instruction(message) + + self.session_store.append_user_message(session, message) + + result = await self.runtime_manager.execute_turn( + session=session, + message=message, + conversation_history=history, + channel_context=message.to_channel_context(session.session_key), + ) + response_text = self._extract_response_text(result) + + adapter = self._adapters.get(message.source.platform) + if adapter is None: + raise RuntimeError(f"No adapter registered for {message.source.platform.value}") + + send_result = await adapter.send_text( + message.source.chat_id, + response_text, + reply_to_message_id=message.message_id, + ) + if not send_result.success: + logger.warning( + "Failed to send %s response for session %s: %s", + message.source.platform.value, + session.session_key, + send_result.error, + ) + self.session_store.append_assistant_message( + session, + content=response_text, + platform_message_id=send_result.message_id, + metadata={ + "task_id": result.get("task_id"), + "status": result.get("status"), + "send_success": send_result.success, + "send_error": send_result.error, + }, + ) + + async def _handle_health(self, request: web.Request) -> web.Response: + runtime_status = await self.runtime_manager.status() + gateway_status = self._runtime_status.read() or {} + return web.json_response( + { + "status": "ok" if self._running else "starting", + "gateway": gateway_status, + "platforms": { + platform.value: { + "connected": adapter.is_connected, + } + for platform, adapter in self._adapters.items() + }, + "runtime": runtime_status, + "sessions": len(self.session_store.list_sessions()), + } + ) + + async def _create_openspace_runtime(self, session: ChannelSession) -> OpenSpace: + load_runtime_env() + env_model = os.environ.get("OPENSPACE_MODEL", "") + model, llm_kwargs = build_llm_kwargs(env_model) + llm_kwargs = dict(llm_kwargs) + if model.lower().startswith("ollama/"): + llm_kwargs["api_base"] = os.environ.get("OLLAMA_API_BASE", "").strip() or "http://127.0.0.1:11434" + llm_kwargs["api_key"] = os.environ.get("OLLAMA_API_KEY", "").strip() or llm_kwargs.get("api_key") or "ollama" + llm_kwargs.pop("extra_headers", None) + backend_scope = self.config.agent.backend_scope + grounding_config_path = ( + self.config.agent.grounding_config_path + or build_grounding_config_path() + ) + recording_dir = self.config.data_path / "recordings" + openspace_config = OpenSpaceConfig( + llm_model=model, + llm_kwargs=llm_kwargs, + workspace_dir=session.workspace_dir, + grounding_max_iterations=self.config.agent.max_iterations, + enable_recording=self.config.agent.enable_recording, + recording_backends=self.config.agent.recording_backends, + recording_log_dir=str(recording_dir), + backend_scope=backend_scope, + grounding_config_path=grounding_config_path, + llm_timeout=self.config.agent.llm_timeout, + ) + runtime = OpenSpace(openspace_config) + await runtime.initialize() + return runtime + + def _get_platform_config(self, platform: ChannelPlatform) -> Any: + if platform == ChannelPlatform.WHATSAPP: + return self.config.whatsapp + if platform == ChannelPlatform.FEISHU: + return self.config.feishu + raise ValueError(f"Unsupported platform: {platform}") + + @staticmethod + def _extract_response_text(result: Dict[str, Any]) -> str: + response = str(result.get("response", "")).strip() + if response: + return response + error = str(result.get("error", "")).strip() + if error: + return f"OpenSpace error: {error}" + return "OpenSpace completed the task but returned no response." + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="OpenSpace communication gateway", + ) + parser.add_argument( + "--config", + type=str, + help="Path to the communication JSON config file", + ) + subparsers = parser.add_subparsers(dest="command") + run_parser = subparsers.add_parser("run", help="Start the communication gateway") + run_parser.add_argument( + "--config", + type=str, + help="Path to the communication JSON config file", + ) + health_parser = subparsers.add_parser("health", help="Check the running gateway health endpoint") + health_parser.add_argument( + "--config", + type=str, + help="Path to the communication JSON config file", + ) + health_parser.add_argument("--host", type=str, default=None) + health_parser.add_argument("--port", type=int, default=None) + return parser + + +async def _run_gateway(config_path: Optional[str]) -> int: + config = load_communication_config(config_path) + _configure_ollama_process_env(os.environ.get("OPENSPACE_MODEL", "")) + gateway = CommunicationGateway(config) + try: + await gateway.start() + except Exception as exc: + logger.error("Failed to start communication gateway: %s", exc) + return 1 + + try: + while True: + await asyncio.sleep(3600) + except (asyncio.CancelledError, KeyboardInterrupt): + pass + finally: + await gateway.stop() + return 0 + + +def _check_health(config_path: Optional[str], host: Optional[str], port: Optional[int]) -> int: + config = load_communication_config(config_path) + url = f"http://{host or config.server.host}:{port or config.server.port}{config.server.health_path}" + response = requests.get(url, timeout=5) + response.raise_for_status() + print(response.text) + return 0 + + +async def main(argv: Optional[list[str]] = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + command = args.command or "run" + if command == "health": + return _check_health(args.config, args.host, args.port) + return await _run_gateway(args.config) + + +def run_main() -> None: + raise SystemExit(asyncio.run(main())) + + +if __name__ == "__main__": + run_main() diff --git a/openspace/communication/gateway_runtime.py b/openspace/communication/gateway_runtime.py new file mode 100644 index 0000000..cbcdbc4 --- /dev/null +++ b/openspace/communication/gateway_runtime.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import hashlib +import json +import os +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from openspace.utils.logging import Logger + +logger = Logger.get_logger(__name__) + +_GATEWAY_KIND = "openspace-communication-gateway" + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _scope_hash(identity: str) -> str: + return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16] + + +def _process_start_time(pid: int) -> Optional[int]: + stat_path = Path(f"/proc/{pid}/stat") + try: + return int(stat_path.read_text(encoding="utf-8").split()[21]) + except (FileNotFoundError, IndexError, PermissionError, ValueError, OSError): + return None + + +def _is_pid_alive(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + else: + return True + + +def _build_process_record() -> dict[str, Any]: + pid = os.getpid() + return { + "pid": pid, + "kind": _GATEWAY_KIND, + "argv": list(sys.argv), + "start_time": _process_start_time(pid), + } + + +def _record_matches_live_process(record: dict[str, Any]) -> bool: + try: + pid = int(record["pid"]) + except (KeyError, TypeError, ValueError): + return False + if not _is_pid_alive(pid): + return False + + recorded_start_time = record.get("start_time") + live_start_time = _process_start_time(pid) + if recorded_start_time is None or live_start_time is None: + return True + return live_start_time == recorded_start_time + + +def _read_json(path: Path) -> Optional[dict[str, Any]]: + if not path.exists(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +class LockConflictError(RuntimeError): + def __init__(self, scope: str, identity: str, record: Optional[dict[str, Any]] = None): + super().__init__(f"Communication gateway lock is already held for {scope}:{identity}") + self.scope = scope + self.identity = identity + self.record = record or {} + + +@dataclass +class ScopedRuntimeLock: + path: Path + record: dict[str, Any] + released: bool = False + + @classmethod + def acquire( + cls, + *, + locks_dir: Path, + scope: str, + identity: str, + metadata: Optional[dict[str, Any]] = None, + ) -> "ScopedRuntimeLock": + locks_dir.mkdir(parents=True, exist_ok=True) + lock_path = locks_dir / f"{scope}-{_scope_hash(identity)}.lock" + record = { + **_build_process_record(), + "scope": scope, + "identity": identity, + "metadata": metadata or {}, + "created_at": _utcnow_iso(), + "updated_at": _utcnow_iso(), + } + + while True: + existing = _read_json(lock_path) + if existing is not None: + if _record_matches_live_process(existing): + raise LockConflictError(scope, identity, existing) + try: + lock_path.unlink() + except FileNotFoundError: + continue + except OSError as exc: + raise RuntimeError(f"Failed to remove stale gateway lock {lock_path}: {exc}") from exc + elif lock_path.exists(): + try: + lock_path.unlink() + except FileNotFoundError: + continue + except OSError as exc: + raise RuntimeError( + f"Failed to remove malformed gateway lock {lock_path}: {exc}" + ) from exc + continue + + try: + fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError: + continue + + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(record, handle, ensure_ascii=False, indent=2) + except Exception: + try: + lock_path.unlink(missing_ok=True) + except OSError: + pass + raise + return cls(path=lock_path, record=record) + + def release(self) -> None: + if self.released: + return + try: + current = _read_json(self.path) + if current and current.get("pid") == self.record.get("pid"): + self.path.unlink(missing_ok=True) + except OSError as exc: + logger.warning("Failed to release communication gateway lock %s: %s", self.path, exc) + finally: + self.released = True + + +class GatewayRuntimeTracker: + def __init__(self, status_path: Path): + self.status_path = status_path + + def write_status( + self, + *, + gateway_state: str, + platforms: dict[str, dict[str, Any]], + fatal_error: Optional[str] = None, + exit_reason: Optional[str] = None, + config_path: Optional[str] = None, + sessions: Optional[int] = None, + ) -> None: + payload = { + **_build_process_record(), + "gateway_state": gateway_state, + "fatal_error": fatal_error, + "exit_reason": exit_reason, + "config_path": config_path, + "platforms": platforms, + "sessions": sessions, + "updated_at": _utcnow_iso(), + } + _write_json(self.status_path, payload) + + def read_status(self) -> Optional[dict[str, Any]]: + return _read_json(self.status_path) + + +class ScopedLockManager: + def __init__(self, locks_dir: Path): + self.locks_dir = locks_dir + + def acquire( + self, + scope: str, + identity: str, + metadata: Optional[dict[str, Any]] = None, + ) -> "ScopedLock": + return ScopedRuntimeLock.acquire( + locks_dir=self.locks_dir, + scope=scope, + identity=identity, + metadata=metadata, + ) + + @staticmethod + def release(lock: "ScopedLock") -> None: + lock.release() + + +class RuntimeStatusStore: + def __init__(self, status_path: Path): + self._tracker = GatewayRuntimeTracker(status_path) + + def write( + self, + *, + gateway_state: str, + platforms: dict[str, dict[str, Any]], + fatal_error: Optional[str] = None, + exit_reason: Optional[str] = None, + config_path: Optional[str] = None, + sessions: Optional[int] = None, + ) -> None: + self._tracker.write_status( + gateway_state=gateway_state, + platforms=platforms, + fatal_error=fatal_error, + exit_reason=exit_reason, + config_path=config_path, + sessions=sessions, + ) + + def read(self) -> Optional[dict[str, Any]]: + return self._tracker.read_status() + + +ScopedLock = ScopedRuntimeLock diff --git a/openspace/communication/policy.py b/openspace/communication/policy.py new file mode 100644 index 0000000..fb24d0c --- /dev/null +++ b/openspace/communication/policy.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Any + +from openspace.communication.types import ChannelMessage + + +def build_attachment_instruction(message: ChannelMessage) -> str: + attachment_paths = ", ".join(attachment.path for attachment in message.attachments) + return ( + "Please inspect the attached files and help the user based on their contents. " + f"Attachment paths: {attachment_paths}" + ) + + +def is_authorized(message: ChannelMessage, platform_config: Any) -> bool: + if getattr(platform_config, "allow_all_users", False): + return True + + allowed_users = { + entry.strip() + for entry in getattr(platform_config, "allowed_users", []) + if entry and entry.strip() + } + if not allowed_users: + return False + + user_candidates = { + candidate.strip() + for candidate in ( + message.source.user_id, + message.source.user_name, + message.metadata.get("raw_user_id") if isinstance(message.metadata, dict) else None, + ) + if candidate and candidate.strip() + } + if isinstance(message.metadata, dict): + for candidate in message.metadata.get("auth_candidates", []) or []: + if isinstance(candidate, str) and candidate.strip(): + user_candidates.add(candidate.strip()) + return bool(user_candidates & allowed_users) + + +def should_accept_message( + message: ChannelMessage, + platform_config: Any, + reply_to_bot: bool, +) -> bool: + if message.source.chat_type == "dm": + return bool(getattr(platform_config, "allow_dm", True)) + if not getattr(platform_config, "allow_groups", True): + return False + + group_policy = str(getattr(platform_config, "group_policy", "reply_or_mention")) + if group_policy == "disabled": + return False + if group_policy == "all": + return True + if group_policy == "mention_only": + return message.mentions_bot + return message.mentions_bot or reply_to_bot diff --git a/openspace/communication/runtime_manager.py b/openspace/communication/runtime_manager.py new file mode 100644 index 0000000..5148fbe --- /dev/null +++ b/openspace/communication/runtime_manager.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import asyncio +import contextlib +import uuid +from time import monotonic +from typing import Any, Awaitable, Callable, Dict, Optional + +from openspace.tool_layer import OpenSpace +from openspace.utils.logging import Logger + +from .config import CommunicationConfig +from .types import ChannelMessage, ChannelSession + +logger = Logger.get_logger(__name__) + + +OpenSpaceFactory = Callable[[ChannelSession], Awaitable[OpenSpace]] + + +class SessionRuntime: + def __init__(self, session: ChannelSession, openspace_factory: OpenSpaceFactory): + self.session = session + self._openspace_factory = openspace_factory + self._openspace: Optional[OpenSpace] = None + self._lock = asyncio.Lock() + self.last_used_monotonic = monotonic() + + @property + def openspace(self) -> Optional[OpenSpace]: + return self._openspace + + async def ensure_initialized(self) -> OpenSpace: + if self._openspace is None: + self._openspace = await self._openspace_factory(self.session) + self.last_used_monotonic = monotonic() + return self._openspace + + async def execute_turn( + self, + *, + message: ChannelMessage, + conversation_history: list[dict[str, str]], + channel_context: dict[str, Any], + max_iterations: Optional[int] = None, + ) -> Dict[str, Any]: + async with self._lock: + openspace = await self.ensure_initialized() + self.last_used_monotonic = monotonic() + task_id = f"comm_{self.session.session_key}_{uuid.uuid4().hex[:10]}" + result = await openspace.execute( + task=message.text, + context={ + "conversation_history": conversation_history, + "channel_context": channel_context, + "session_key": self.session.session_key, + }, + workspace_dir=self.session.workspace_dir, + max_iterations=max_iterations, + task_id=task_id, + ) + self.last_used_monotonic = monotonic() + return result + + async def close(self) -> None: + if self._openspace is not None: + await self._openspace.cleanup() + self._openspace = None + + def is_idle(self, idle_ttl_seconds: int) -> bool: + if self._lock.locked(): + return False + return (monotonic() - self.last_used_monotonic) >= idle_ttl_seconds + + +class SessionRuntimeManager: + def __init__( + self, + config: CommunicationConfig, + openspace_factory: OpenSpaceFactory, + ): + self.config = config + self._openspace_factory = openspace_factory + self._runtimes: Dict[str, SessionRuntime] = {} + self._lock = asyncio.Lock() + self._semaphore = asyncio.Semaphore(config.sessions.max_parallel_sessions) + self._eviction_task: Optional[asyncio.Task] = None + + async def start(self) -> None: + if self._eviction_task is None: + self._eviction_task = asyncio.create_task(self._evict_idle_loop()) + + async def stop(self) -> None: + if self._eviction_task is not None: + self._eviction_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._eviction_task + self._eviction_task = None + + async with self._lock: + runtimes = list(self._runtimes.values()) + self._runtimes.clear() + for runtime in runtimes: + await runtime.close() + + async def execute_turn( + self, + *, + session: ChannelSession, + message: ChannelMessage, + conversation_history: list[dict[str, str]], + channel_context: dict[str, Any], + ) -> Dict[str, Any]: + runtime = await self._get_or_create_runtime(session) + async with self._semaphore: + return await runtime.execute_turn( + message=message, + conversation_history=conversation_history, + channel_context=channel_context, + max_iterations=self.config.agent.max_iterations, + ) + + async def status(self) -> Dict[str, Any]: + async with self._lock: + return { + "active_runtimes": len(self._runtimes), + "session_keys": sorted(self._runtimes.keys()), + } + + async def _get_or_create_runtime(self, session: ChannelSession) -> SessionRuntime: + async with self._lock: + runtime = self._runtimes.get(session.session_key) + if runtime is None: + runtime = SessionRuntime(session, self._openspace_factory) + self._runtimes[session.session_key] = runtime + return runtime + + async def _evict_idle_loop(self) -> None: + while True: + await asyncio.sleep(30) + stale_keys: list[str] = [] + async with self._lock: + for session_key, runtime in self._runtimes.items(): + if runtime.is_idle(self.config.sessions.idle_ttl_seconds): + stale_keys.append(session_key) + runtimes = [self._runtimes.pop(key) for key in stale_keys] + for runtime in runtimes: + logger.info("Evicting idle communication runtime: %s", runtime.session.session_key) + await runtime.close() diff --git a/openspace/communication/session_store.py b/openspace/communication/session_store.py new file mode 100644 index 0000000..4c66e39 --- /dev/null +++ b/openspace/communication/session_store.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import json +import re +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from openspace.utils.logging import Logger + +from .types import ChannelAttachment, ChannelMessage, ChannelSession, ChannelSource + +logger = Logger.get_logger(__name__) + + +class SessionStore: + def __init__( + self, + sessions_dir: Path, + *, + workspace_root: Optional[Path] = None, + ): + self.sessions_dir = sessions_dir + self.workspace_root = workspace_root + self.sessions_dir.mkdir(parents=True, exist_ok=True) + if self.workspace_root is not None: + self.workspace_root.mkdir(parents=True, exist_ok=True) + + def get_or_create_session(self, source: ChannelSource) -> ChannelSession: + session_key = build_session_key(source) + session_dir = self.sessions_dir / session_key + session_dir.mkdir(parents=True, exist_ok=True) + + metadata_path = session_dir / "session.json" + transcript_path = session_dir / "transcript.jsonl" + attachments_dir = session_dir / "attachments" + workspace_dir = ( + self.workspace_root / session_key + if self.workspace_root is not None + else session_dir / "workspace" + ) + attachments_dir.mkdir(parents=True, exist_ok=True) + workspace_dir.mkdir(parents=True, exist_ok=True) + + now = _utcnow_iso() + if metadata_path.exists(): + with open(metadata_path, "r", encoding="utf-8") as handle: + data = json.load(handle) + session = ChannelSession.from_dict(data) + session.source = source + session.updated_at = now + else: + session = ChannelSession( + session_key=session_key, + source=source, + session_dir=str(session_dir), + workspace_dir=str(workspace_dir), + attachments_dir=str(attachments_dir), + transcript_path=str(transcript_path), + metadata_path=str(metadata_path), + created_at=now, + updated_at=now, + ) + + self._write_session_metadata(session) + return session + + def append_user_message(self, session: ChannelSession, message: ChannelMessage) -> None: + self._append_transcript_entry( + session, + { + "entry_id": uuid.uuid4().hex, + "role": "user", + "content": message.text, + "platform_message_id": message.message_id, + "reply_to_message_id": message.reply_to_message_id, + "reply_to_text": message.reply_to_text, + "mentions_bot": message.mentions_bot, + "attachments": [attachment.to_context_dict() for attachment in message.attachments], + "source": message.source.to_dict(), + "metadata": message.metadata, + "timestamp": message.received_at.isoformat(), + }, + ) + + def append_assistant_message( + self, + session: ChannelSession, + *, + content: str, + platform_message_id: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> None: + self._append_transcript_entry( + session, + { + "entry_id": uuid.uuid4().hex, + "role": "assistant", + "content": content, + "platform_message_id": platform_message_id, + "metadata": metadata or {}, + "timestamp": _utcnow_iso(), + }, + ) + + def load_history(self, session: ChannelSession, max_turns: int) -> List[Dict[str, str]]: + entries = self._read_transcript_entries(session) + if not entries: + return [] + + selected: List[Dict[str, str]] = [] + user_messages = 0 + for entry in reversed(entries): + role = entry.get("role") + if role not in {"user", "assistant"}: + continue + if role == "assistant" and not _assistant_entry_visible_in_history(entry): + continue + content = str(entry.get("content", "")).strip() + if not content: + continue + selected.append({"role": role, "content": content}) + if role == "user": + user_messages += 1 + if user_messages >= max_turns: + break + selected.reverse() + return selected + + def is_reply_to_assistant(self, session: ChannelSession, message_id: Optional[str]) -> bool: + if not message_id: + return False + for entry in reversed(self._read_transcript_entries(session)): + if entry.get("platform_message_id") == message_id: + return entry.get("role") == "assistant" and _assistant_entry_visible_in_history(entry) + return False + + def list_sessions(self) -> List[ChannelSession]: + sessions: List[ChannelSession] = [] + for metadata_path in sorted(self.sessions_dir.glob("*/session.json")): + try: + with open(metadata_path, "r", encoding="utf-8") as handle: + sessions.append(ChannelSession.from_dict(json.load(handle))) + except Exception as exc: + logger.warning("Failed to load session metadata %s: %s", metadata_path, exc) + return sessions + + def _append_transcript_entry(self, session: ChannelSession, entry: Dict[str, Any]) -> None: + transcript_path = Path(session.transcript_path) + transcript_path.parent.mkdir(parents=True, exist_ok=True) + with open(transcript_path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(entry, ensure_ascii=False) + "\n") + session.updated_at = _utcnow_iso() + self._write_session_metadata(session) + + def _write_session_metadata(self, session: ChannelSession) -> None: + with open(session.metadata_path, "w", encoding="utf-8") as handle: + json.dump(session.to_dict(), handle, ensure_ascii=False, indent=2) + + def _read_transcript_entries(self, session: ChannelSession) -> List[Dict[str, Any]]: + transcript_path = Path(session.transcript_path) + if not transcript_path.exists(): + return [] + entries: List[Dict[str, Any]] = [] + with open(transcript_path, "r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + logger.warning("Skipping malformed transcript line in %s", transcript_path) + return entries + + +def build_session_key(source: ChannelSource) -> str: + parts = [source.platform.value, _sanitize(source.chat_id)] + if source.thread_id: + parts.append(_sanitize(source.thread_id)) + return "__".join(part for part in parts if part) + + +def _sanitize(value: str) -> str: + value = re.sub(r"[^a-zA-Z0-9._-]+", "-", str(value).strip()) + value = value.strip("-._") + return value or "unknown" + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _assistant_entry_visible_in_history(entry: Dict[str, Any]) -> bool: + metadata = entry.get("metadata") + if not isinstance(metadata, dict): + return True + return metadata.get("send_success") is not False diff --git a/openspace/communication/types.py b/openspace/communication/types.py new file mode 100644 index 0000000..34ef037 --- /dev/null +++ b/openspace/communication/types.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Dict, List, Optional + + +class ChannelPlatform(str, Enum): + WHATSAPP = "whatsapp" + FEISHU = "feishu" + + +class AttachmentKind(str, Enum): + IMAGE = "image" + DOCUMENT = "document" + FILE = "file" + + +@dataclass(slots=True) +class ChannelAttachment: + kind: AttachmentKind + path: str + name: str = "" + mime_type: str = "" + size_bytes: Optional[int] = None + source_url: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_context_dict(self) -> Dict[str, Any]: + return { + "kind": self.kind.value, + "path": self.path, + "name": self.name, + "mime_type": self.mime_type, + "size_bytes": self.size_bytes, + "source_url": self.source_url, + "metadata": self.metadata, + } + + +@dataclass(slots=True) +class ChannelSource: + platform: ChannelPlatform + chat_id: str + chat_type: str = "dm" + user_id: Optional[str] = None + user_name: Optional[str] = None + chat_name: Optional[str] = None + thread_id: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "platform": self.platform.value, + "chat_id": self.chat_id, + "chat_type": self.chat_type, + "user_id": self.user_id, + "user_name": self.user_name, + "chat_name": self.chat_name, + "thread_id": self.thread_id, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ChannelSource": + return cls( + platform=ChannelPlatform(str(data["platform"])), + chat_id=str(data["chat_id"]), + chat_type=str(data.get("chat_type", "dm")), + user_id=_optional_str(data.get("user_id")), + user_name=_optional_str(data.get("user_name")), + chat_name=_optional_str(data.get("chat_name")), + thread_id=_optional_str(data.get("thread_id")), + ) + + +@dataclass(slots=True) +class ChannelMessage: + source: ChannelSource + text: str + message_id: str + attachments: List[ChannelAttachment] = field(default_factory=list) + reply_to_message_id: Optional[str] = None + reply_to_text: Optional[str] = None + mentions_bot: bool = False + metadata: Dict[str, Any] = field(default_factory=dict) + received_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def to_channel_context(self, session_key: str) -> Dict[str, Any]: + return { + "platform": self.source.platform.value, + "chat_id": self.source.chat_id, + "chat_type": self.source.chat_type, + "chat_name": self.source.chat_name, + "thread_id": self.source.thread_id, + "user_id": self.source.user_id, + "user_name": self.source.user_name, + "session_key": session_key, + "message_id": self.message_id, + "reply_to_message_id": self.reply_to_message_id, + "reply_to_text": self.reply_to_text, + "attachments": [attachment.to_context_dict() for attachment in self.attachments], + } + + +@dataclass(slots=True) +class ChannelReply: + content: str + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class SendResult: + success: bool + message_id: Optional[str] = None + error: Optional[str] = None + raw_response: Any = None + + +@dataclass(slots=True) +class ChannelSession: + session_key: str + source: ChannelSource + session_dir: str + workspace_dir: str + attachments_dir: str + transcript_path: str + metadata_path: str + created_at: str + updated_at: str + + def to_dict(self) -> Dict[str, Any]: + return { + "session_key": self.session_key, + "source": self.source.to_dict(), + "session_dir": self.session_dir, + "workspace_dir": self.workspace_dir, + "attachments_dir": self.attachments_dir, + "transcript_path": self.transcript_path, + "metadata_path": self.metadata_path, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ChannelSession": + return cls( + session_key=str(data["session_key"]), + source=ChannelSource.from_dict(data["source"]), + session_dir=str(data["session_dir"]), + workspace_dir=str(data["workspace_dir"]), + attachments_dir=str(data["attachments_dir"]), + transcript_path=str(data["transcript_path"]), + metadata_path=str(data["metadata_path"]), + created_at=str(data["created_at"]), + updated_at=str(data["updated_at"]), + ) + + +def _optional_str(value: Any) -> Optional[str]: + if value is None: + return None + value = str(value).strip() + return value or None diff --git a/openspace/config/README.md b/openspace/config/README.md index f2e0875..a65aac3 100644 --- a/openspace/config/README.md +++ b/openspace/config/README.md @@ -33,6 +33,8 @@ Set via `.env`, MCP config `env` block, or system environment. | `OPENSPACE_MODEL` | LLM model | `openrouter/anthropic/claude-sonnet-4.5` | | `OPENSPACE_LLM_API_KEY` | LLM API key (Tier 1 override) | — | | `OPENSPACE_LLM_API_BASE` | LLM API base URL | — | +| `OLLAMA_API_BASE` | Local Ollama endpoint for `ollama/*` models | `http://127.0.0.1:11434` | +| `OLLAMA_API_KEY` | Placeholder key for Ollama-compatible clients | `ollama` | | `OPENSPACE_LLM_EXTRA_HEADERS` | Extra LLM headers (JSON) | — | | `OPENSPACE_LLM_CONFIG` | Arbitrary litellm kwargs (JSON) | — | | `OPENSPACE_API_KEY` | Cloud API key ([open-space.cloud](https://open-space.cloud)) | — | @@ -91,6 +93,7 @@ Layered system — later files override earlier ones: | `config_mcp.json` | MCP servers OpenSpace connects to as a client | | `config_security.json` | Security policies, blocked commands, sandboxing | | `config_dev.json` | Dev overrides — copy from `config_dev.json.example` (highest priority) | +| `config_communication.json` | Communication gateway settings for WhatsApp and Feishu. Use `agent` for per-message OpenSpace execution and `sessions` for queue/history limits. LLM model stays in `openspace/.env`. | ### Agent config (`config_agents.json`) @@ -123,3 +126,41 @@ Layered system — later files override earlier ones: | `blocked_commands` | Platform-specific blacklists (common/linux/darwin/windows) | `rm -rf`, `shutdown`, `dd`, etc. | | `sandbox_enabled` | Enable sandboxing for all operations | `false` | | Per-backend overrides | Shell, MCP, GUI, Web each have independent security policies | Inherit global | + +## 6. Communication Gateway + +The tracked communication config is safe-by-default: loopback-only, channels disabled, and deny-by-default access control. Copy the example config, fill in credentials and `allowed_users`, then explicitly enable the channels you want. The gateway model is not configured here; it inherits `OPENSPACE_MODEL` from `openspace/.env`. + +```bash +cp openspace/config/config_communication.json.example openspace/config/config_communication.json +``` + +Install the Feishu SDK extra when you need Feishu support: + +```bash +pip install -e '.[communication]' +``` + +Start the gateway with either entrypoint: + +```bash +openspace communication run --config openspace/config/config_communication.json +openspace-gateway --config openspace/config/config_communication.json +``` + +Check health: + +```bash +openspace communication health --config openspace/config/config_communication.json +``` + +Notes: + +- The tracked `config_communication.json` now stays local-only and deny-by-default. Keep credentials out of git and populate them from a private working copy or environment variables. +- Set `server.host` to `0.0.0.0` only when Feishu needs to reach the webhook from outside the machine, and pair that with a populated allowlist plus webhook verification secrets. +- Feishu now supports both `webhook` and `websocket` modes. `websocket` matches nanobot's long-connection setup and does not require a public webhook URL. +- WhatsApp requires Node.js and npm. The bundled bridge installs its dependencies on first start when `auto_install_dependencies` is enabled. +- Set `feishu.bot_open_id` if you want strict group mention gating and automatic bot identity discovery is unavailable in your deployment. +- Group chats are gated by `group_policy`. `reply_or_mention` is the default and only accepts messages that mention the bot or reply to a prior assistant message. +- `allowed_users` is enforced when `allow_all_users` is `false`. The secure default is deny-by-default until you populate the allowlist. +- Attachment caching is limited by `sessions.max_attachment_bytes` and `sessions.max_session_attachment_bytes` to bound disk usage per file and per session. diff --git a/openspace/config/config_communication.json.example b/openspace/config/config_communication.json.example new file mode 100644 index 0000000..32974d2 --- /dev/null +++ b/openspace/config/config_communication.json.example @@ -0,0 +1,65 @@ +{ + "data_dir": "./logs/communication", + "server": { + "host": "127.0.0.1", + "port": 8765, + "health_path": "/health" + }, + "agent": { + "max_iterations": 20, + "enable_recording": true, + "recording_backends": [ + "shell" + ], + "backend_scope": null, + "grounding_config_path": null, + "workspace_root": null, + "llm_timeout": 120.0 + }, + "sessions": { + "history_max_turns": 12, + "max_parallel_sessions": 2, + "idle_ttl_seconds": 900, + "per_session_queue_size": 32, + "whatsapp_poll_interval_seconds": 1.0, + "max_attachment_bytes": 26214400, + "max_session_attachment_bytes": 104857600 + }, + "whatsapp": { + "enabled": false, + "allow_all_users": false, + "allowed_users": [ + "15551234567" + ], + "allow_dm": true, + "allow_groups": true, + "group_policy": "reply_or_mention", + "reply_prefix": "OpenSpace\n────────────\n", + "bridge": { + "host": "127.0.0.1", + "port": 3000, + "script_path": null, + "session_dir": null, + "mode": "self-chat", + "auto_install_dependencies": true + } + }, + "feishu": { + "enabled": false, + "allow_all_users": false, + "allowed_users": [ + "ou_xxxxxxxxxxxxx" + ], + "allow_dm": true, + "allow_groups": true, + "group_policy": "reply_or_mention", + "app_id": "cli_xxxxxxxxxxxxx", + "app_secret": "xxxxxxxxxxxxx", + "domain": "feishu", + "connection_mode": "webhook", + "verification_token": "", + "encrypt_key": "", + "bot_open_id": "", + "webhook_path": "/feishu/webhook" + } +} diff --git a/openspace/host_detection/resolver.py b/openspace/host_detection/resolver.py index 90b095b..54115e5 100644 --- a/openspace/host_detection/resolver.py +++ b/openspace/host_detection/resolver.py @@ -75,6 +75,20 @@ def _pick_first_env(names: tuple[str, ...]) -> str: return "" +def _ensure_local_no_proxy() -> None: + required_hosts = ("127.0.0.1", "localhost") + for env_name in ("NO_PROXY", "no_proxy"): + current = os.environ.get(env_name, "") + entries = [entry.strip() for entry in current.split(",") if entry.strip()] + updated = False + for host in required_hosts: + if host not in entries: + entries.append(host) + updated = True + if updated: + os.environ[env_name] = ",".join(entries) + + def _infer_provider_name(model: str) -> Optional[str]: """Infer the provider name from a model string using PROVIDER_REGISTRY.""" from openspace.host_detection.nanobot import PROVIDER_REGISTRY @@ -222,6 +236,16 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: if not resolved_model: resolved_model = _DEFAULT_MODEL + # Ollama models must use the Ollama-native API base, even when unrelated + # OPENSPACE_LLM_* env vars are present for a different provider. + if resolved_model.lower().startswith("ollama/"): + ollama_base = os.environ.get("OLLAMA_API_BASE", "").strip() or "http://127.0.0.1:11434" + _ensure_local_no_proxy() + kwargs["api_base"] = ollama_base.rstrip("/") + kwargs["api_key"] = os.environ.get("OLLAMA_API_KEY", "").strip() or kwargs.get("api_key") or "ollama" + kwargs.pop("extra_headers", None) + source = "ollama runtime" + # Provider-specific adjustments for litellm routing if resolved_model and "minimax" in resolved_model.lower(): final_key = kwargs.get("api_key") diff --git a/openspace/tool_layer.py b/openspace/tool_layer.py index c2eaca9..7764e1b 100644 --- a/openspace/tool_layer.py +++ b/openspace/tool_layer.py @@ -104,7 +104,6 @@ class OpenSpace: return logger.info("Initializing OpenSpace...") - try: self._llm_client = LLMClient( model=self.config.llm_model, @@ -313,7 +312,10 @@ class OpenSpace: Args: task: Task instruction - context: Additional context + context: Additional context. Communication callers may pass: + - conversation_history: prior user/assistant turns + - channel_context: platform/chat metadata and attachments + - session_key: stable external session identifier workspace_dir: Working directory max_iterations: Max iterations override task_id: External task ID for recording/logging. If None, generates a random one. @@ -357,9 +359,11 @@ class OpenSpace: # Populated inside the try block; used by finally for analysis result: Dict[str, Any] = {} + execution_time = 0.0 + cancelled_exc: Optional[asyncio.CancelledError] = None try: - execution_context = context or {} + execution_context = dict(context) if context else {} execution_context["task_id"] = task_id execution_context["instruction"] = task @@ -531,6 +535,20 @@ class OpenSpace: logger.error(f"Task failed: {result.get('error', 'Unknown error')}") logger.info("="*60) + except asyncio.CancelledError as exc: + execution_time = asyncio.get_event_loop().time() - start_time + logger.warning("Task execution cancelled") + result = { + "status": "cancelled", + "error": "Task execution cancelled", + "response": "", + "execution_time": execution_time, + "task_id": task_id, + "iterations": 0, + "tool_executions": [], + } + cancelled_exc = exc + except Exception as e: execution_time = asyncio.get_event_loop().time() - start_time tb = traceback.format_exc(limit=10) @@ -569,14 +587,15 @@ class OpenSpace: except Exception as e: logger.warning(f"Failed to stop recording: {e}") - # Run execution analysis + evolution BEFORE building the return - # value, so evolved_skills is populated. - await self._maybe_analyze_execution( - task_id, recording_dir, result - ) + if cancelled_exc is None: + # Run execution analysis + evolution BEFORE building the return + # value, so evolved_skills is populated. + await self._maybe_analyze_execution( + task_id, recording_dir, result + ) - # Trigger quality evolution periodically - await self._maybe_evolve_quality() + # Trigger quality evolution periodically + await self._maybe_evolve_quality() final_result = { **result, @@ -588,8 +607,10 @@ class OpenSpace: self._running = False self._task_done.set() - - return final_result + + if cancelled_exc is not None: + raise cancelled_exc + return final_result # Skills helpers def _init_skill_registry(self) -> Optional[SkillRegistry]: diff --git a/pyproject.toml b/pyproject.toml index 06c199c..38eba3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "flask>=3.1.0", "pyautogui>=0.9.54", "pydantic>=2.12.0", + "aiohttp>=3.10.0", "requests>=2.32.0", ] @@ -57,8 +58,15 @@ dev = [ "mypy>=1.0.0", ] +communication = [ + "lark-oapi>=1.4.20", +] + all = [ - "openspace[macos,linux,windows,dev]", + "openspace[macos]; sys_platform == 'darwin'", + "openspace[linux]; sys_platform == 'linux'", + "openspace[windows]; sys_platform == 'win32'", + "openspace[communication,dev]", ] [project.urls] @@ -72,6 +80,7 @@ openspace-mcp = "openspace.mcp_server:run_mcp_server" openspace-download-skill = "openspace.cloud.cli.download_skill:main" openspace-upload-skill = "openspace.cloud.cli.upload_skill:main" openspace-dashboard = "openspace.dashboard_server:main" +openspace-gateway = "openspace.communication.gateway:run_main" [tool.setuptools] packages = {find = {where = ["."], include = ["openspace*"]}} @@ -80,6 +89,7 @@ packages = {find = {where = ["."], include = ["openspace*"]}} openspace = [ "config/*.json", "config/*.json.example", + "communication/bridges/whatsapp/*", "local_server/config.json", "local_server/README.md", ] From 681c3a8b05eb5ee1e321dcf5e0015c11be58bb7f Mon Sep 17 00:00:00 2001 From: spidercatfly Date: Thu, 9 Apr 2026 22:16:06 +0800 Subject: [PATCH 43/45] update readme --- README.md | 9 +++++++++ README_CN.md | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/README.md b/README.md index c4e486c..83eecee 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ ## 📢 News +- **2026-04-09** 💬 Multi-channel **communication gateway**. OpenSpace can now receive and respond to messages from external platforms. Ships with **WhatsApp** (Baileys bridge + QR auth) and **Feishu** (HTTP webhook) adapters, session management, attachment caching, and allowlist-based access control. See [`openspace/config/README.md`](openspace/config/README.md) for setup. - **2026-04-07** 🌐 OpenSpace MCP now supports standalone **SSE** and **streamable HTTP** startup, making it easier for remote hosts to connect over HTTP instead of stdio and bypass stdio-bound MCP server timeout bottlenecks. See the [host integration guide](openspace/host_skills/README.md) for setup details. - **2026-04-06** 🛠️ Fixed multiple runtime issues across grounding, MCP serving, skill evolution, and persistence, improving execution stability and recovery in long-running workflows. - **2026-04-05** 🧭 Cleaned up LLM credential resolution: centralized `.env` loading, improved host config auto-detection, and made provider-native env handling more consistent. @@ -521,6 +522,14 @@ OpenSpace/ │ │ ├── auth.py # API key management │ │ └── cli/ # CLI tools (download_skill, upload_skill) │ │ +│ ├── 💬 communication/ # Multi-Channel Communication Gateway +│ │ ├── gateway.py # Message routing, session management, reply dispatch +│ │ ├── adapters/ # Platform adapters (WhatsApp, Feishu) +│ │ ├── bridges/ # Non-Python runtimes (WhatsApp Baileys bridge) +│ │ ├── config.py # Communication config loader +│ │ ├── session_store.py # Per-channel session persistence +│ │ └── types.py # ChannelMessage, ChannelSource, SendResult +│ │ │ ├── 🔧 platform/ # Platform abstraction (system info, screenshots) │ ├── 🔧 host_detection/ # Auto-detect nanobot / openclaw credentials │ ├── 🔧 host_skills/ # SKILL.md definitions for agent integration diff --git a/README_CN.md b/README_CN.md index b115ab0..1014b1b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -25,6 +25,7 @@ ## 📢 最新动态 +- **2026-04-09** 💬 多渠道**通信网关**上线。OpenSpace 现可接收并回复外部平台消息。内置 **WhatsApp**(Baileys bridge + 扫码认证)与**飞书**(HTTP webhook)适配器,支持会话管理、附件缓存和白名单访问控制。配置方式见 [`openspace/config/README.md`](openspace/config/README.md)。 - **2026-04-07** 🌐 OpenSpace MCP 新增独立 **SSE** 与 **streamable HTTP** 启动方式,便于远端 host 通过 HTTP 接入,绕过基于 stdio 的 MCP server timeout 瓶颈。具体接入方式见 [host integration 文档](openspace/host_skills/README.md)。 - **2026-04-06** 🛠️ 修复多项运行时问题,覆盖 grounding、MCP 服务、skill 进化与持久化链路,长流程执行的稳定性与恢复能力进一步提升。 - **2026-04-05** 🧭 LLM 凭证解析清理完成:统一 `.env` 加载逻辑,改进宿主配置自动识别,并让 provider 原生环境变量处理更一致。 @@ -521,6 +522,14 @@ OpenSpace/ │ │ ├── auth.py # API 密钥管理 │ │ └── cli/ # CLI 工具(download_skill、upload_skill) │ │ +│ ├── 💬 communication/ # 多渠道通信网关 +│ │ ├── gateway.py # 消息路由、会话管理、回复分发 +│ │ ├── adapters/ # 平台适配器(WhatsApp、飞书) +│ │ ├── bridges/ # 非 Python 运行时(WhatsApp Baileys bridge) +│ │ ├── config.py # 通信配置加载 +│ │ ├── session_store.py # 按频道的会话持久化 +│ │ └── types.py # ChannelMessage, ChannelSource, SendResult +│ │ │ ├── 🔧 platform/ # 平台抽象(系统信息、截图) │ ├── 🔧 host_detection/ # 自动检测 nanobot / openclaw 凭证 │ ├── 🔧 host_skills/ # 面向 Agent 集成的 SKILL.md 定义 From c49696ed7da538ee26c9ea3107500c5f8fa7f271 Mon Sep 17 00:00:00 2001 From: Dennis-yxchen Date: Thu, 9 Apr 2026 22:36:09 +0800 Subject: [PATCH 44/45] Fix cloud visibility compatibility --- openspace/cloud/client.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/openspace/cloud/client.py b/openspace/cloud/client.py index f97b2ac..fdd31ac 100644 --- a/openspace/cloud/client.py +++ b/openspace/cloud/client.py @@ -101,9 +101,26 @@ class OpenSpaceClient: _, data = self._request("GET", path, timeout=timeout) return json.loads(data.decode("utf-8")) + @staticmethod + def _normalize_visibility_value(value: Any) -> Any: + """Treat legacy/group-shared non-public skills as private locally.""" + if value == "group_only": + return "private" + return value + + @classmethod + def _normalize_record_payload(cls, payload: Dict[str, Any]) -> Dict[str, Any]: + normalized = dict(payload) + if "visibility" in normalized: + normalized["visibility"] = cls._normalize_visibility_value( + normalized.get("visibility") + ) + return normalized + def fetch_record(self, record_id: str) -> Dict[str, Any]: """GET /records/{record_id} — fetch record metadata.""" - return self._get_json(f"/records/{urllib.parse.quote(record_id)}") + data = self._get_json(f"/records/{urllib.parse.quote(record_id)}") + return self._normalize_record_payload(data) def download_artifact(self, record_id: str) -> bytes: """GET /records/{record_id}/download — download artifact zip bytes.""" @@ -134,7 +151,10 @@ class OpenSpaceClient: path = f"/records/metadata?{urllib.parse.urlencode(params)}" data = self._get_json(path, timeout=15) - all_items.extend(data.get("items", [])) + all_items.extend( + self._normalize_record_payload(item) + for item in data.get("items", []) + ) if not data.get("has_more"): break @@ -169,7 +189,8 @@ class OpenSpaceClient: extra_headers={"Content-Type": "application/json"}, timeout=30, ) - return json.loads(response_body.decode("utf-8")) + items = json.loads(response_body.decode("utf-8")) + return [self._normalize_record_payload(item) for item in items] def stage_artifact(self, skill_dir: Path) -> tuple[str, int]: """POST /artifacts/stage — upload skill files. @@ -295,7 +316,7 @@ class OpenSpaceClient: parents = parent_skill_ids or [] self._validate_origin_parents(origin, parents) - api_visibility = "group_only" if visibility == "private" else "public" + api_visibility = visibility # Step 1: Stage logger.info(f"upload_skill: staging files for '{name}'") From 79a98abda708cf1014b948be892a40d2cb70f327 Mon Sep 17 00:00:00 2001 From: xlrrrr Date: Fri, 10 Apr 2026 20:57:17 +0800 Subject: [PATCH 45/45] fix: CAPTURED skills write to correct host agent skill dir --- openspace/mcp_server.py | 15 ++++++ openspace/skill_engine/evolver.py | 84 +++++++++++++++++++++++++++---- openspace/tool_layer.py | 17 ++++++- 3 files changed, 106 insertions(+), 10 deletions(-) diff --git a/openspace/mcp_server.py b/openspace/mcp_server.py index 9730a34..bfacc5b 100644 --- a/openspace/mcp_server.py +++ b/openspace/mcp_server.py @@ -578,6 +578,20 @@ async def execute_task( if skill_dirs: await _auto_register_skill_dirs(skill_dirs) + # Determine where CAPTURED skills should be written. + # Prefer the explicit skill_dirs parameter (= calling host agent's dir), + # then fall back to the first env-based host skill dir. + capture_skill_dir: str | None = None + if skill_dirs: + capture_skill_dir = skill_dirs[0] + elif host_skill_dirs_raw: + first_env = next( + (d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()), + None, + ) + if first_env: + capture_skill_dir = first_env + # Cloud search + import (if requested) imported_skills: List[Dict[str, Any]] = [] if search_scope == "all": @@ -588,6 +602,7 @@ async def execute_task( task=task, workspace_dir=workspace_dir, max_iterations=max_iterations, + capture_skill_dir=capture_skill_dir, ) # Write .upload_meta.json for each evolved skill diff --git a/openspace/skill_engine/evolver.py b/openspace/skill_engine/evolver.py index 818d8bf..a13826e 100644 --- a/openspace/skill_engine/evolver.py +++ b/openspace/skill_engine/evolver.py @@ -145,6 +145,11 @@ class EvolutionContext: # Available tools for agent loop (read_file, web_search, shell, MCP, etc.) available_tools: List["BaseTool"] = field(default_factory=list) + # For CAPTURED: preferred directory to write the new skill. + # Set from the calling host agent's skill directory so captured skills + # are written back to the correct host, not always to _skill_dirs[0]. + capture_dir: Optional[Path] = None + class SkillEvolver: """Execute skill evolution actions. @@ -259,13 +264,20 @@ class SkillEvolver: # Trigger 1: post-analysis async def process_analysis( - self, analysis: ExecutionAnalysis, + self, + analysis: ExecutionAnalysis, + capture_dir: Optional[Path] = None, ) -> List[SkillRecord]: """Process all evolution suggestions from a completed analysis. Called immediately after ``ExecutionAnalyzer.analyze_execution()``. Each suggestion becomes one evolution action, executed in parallel (throttled by semaphore). + + Args: + analysis: The completed execution analysis. + capture_dir: Preferred directory for CAPTURED skills (host agent's + skill dir). Falls back to ``registry._skill_dirs[0]`` when None. """ if not analysis.candidate_for_evolution: return [] @@ -273,7 +285,9 @@ class SkillEvolver: # Build contexts first (cheap, no LLM calls) contexts: List[EvolutionContext] = [] for suggestion in analysis.evolution_suggestions: - ctx = self._build_context_from_analysis(analysis, suggestion) + ctx = self._build_context_from_analysis( + analysis, suggestion, capture_dir=capture_dir, + ) if ctx is not None: contexts.append(ctx) @@ -948,13 +962,23 @@ class SkillEvolver: new_content = _set_frontmatter_field(new_content, "name", new_name) # Create new skill directory via create_skill (handles multi-file FULL) - skill_dirs = self._registry._skill_dirs - if not skill_dirs: - logger.warning("CAPTURED: no skill directories configured") - return None + # Priority chain for choosing the target skill root: + # 1. ctx.capture_dir — explicitly set from host agent's skill_dirs param + # 2. Infer from analysis — if this task used a skill from dir B, + # captured skills belong alongside it (same host agent context) + # 3. registry._skill_dirs[0] — ultimate fallback + base_dir: Optional[Path] = None + if ctx.capture_dir and ctx.capture_dir.is_dir(): + base_dir = ctx.capture_dir + else: + base_dir = self._infer_capture_dir_from_analysis(ctx) - # Directory name always matches the skill name - base_dir = skill_dirs[0] # Primary user skill directory + if base_dir is None: + skill_dirs = self._registry._skill_dirs + if not skill_dirs: + logger.warning("CAPTURED: no skill directories configured") + return None + base_dir = skill_dirs[0] target_dir = base_dir / new_name if target_dir.exists(): new_name = f"{new_name}-{uuid.uuid4().hex[:6]}" @@ -1016,6 +1040,45 @@ class SkillEvolver: logger.info(f"CAPTURED: {new_name} [{new_id}]") return new_record + def _infer_capture_dir_from_analysis( + self, ctx: EvolutionContext, + ) -> Optional[Path]: + """Infer the best skill root for a CAPTURED skill from analysis context. + + When ``capture_dir`` is not explicitly set (no ``skill_dirs`` param + from the host agent), we look at which skills were used during the + task that triggered the capture. If a used skill lives under one + of the registered skill roots, that root is a reasonable home for + the new captured skill (same host agent context). + """ + if not ctx.recent_analyses: + return None + + registry_roots = self._registry._skill_dirs + if not registry_roots: + return None + + for analysis in ctx.recent_analyses: + for judgment in analysis.skill_judgments: + if not judgment.skill_applied: + continue + rec = self._store.load_record(judgment.skill_id) + if not rec or not rec.path: + continue + skill_path = Path(rec.path).parent # e.g. /A/foo/ + for root in registry_roots: + try: + skill_path.relative_to(root) + logger.debug( + "CAPTURED: inferred capture dir %s from " + "applied skill %s", root, judgment.skill_id, + ) + return root + except ValueError: + continue + + return None + async def _run_evolution_loop( self, prompt: str, @@ -1362,13 +1425,15 @@ class SkillEvolver: self, analysis: ExecutionAnalysis, suggestion: EvolutionSuggestion, + capture_dir: Optional[Path] = None, ) -> Optional[EvolutionContext]: """Build EvolutionContext from a single analysis suggestion. Loads all target skills referenced by ``suggestion.target_skill_ids``. For FIX: exactly 1 parent required. For DERIVED: 1+ parents (multi-parent = merge). - For CAPTURED: parents list is empty. + For CAPTURED: parents list is empty; ``capture_dir`` controls where + the new skill is written (defaults to registry's first skill root). """ records: List[SkillRecord] = [] contents: List[str] = [] @@ -1412,6 +1477,7 @@ class SkillEvolver: source_task_id=analysis.task_id, recent_analyses=[analysis], available_tools=self._available_tools, + capture_dir=capture_dir, ) def _load_skill_content(self, record: SkillRecord) -> str: diff --git a/openspace/tool_layer.py b/openspace/tool_layer.py index 7764e1b..7ceb447 100644 --- a/openspace/tool_layer.py +++ b/openspace/tool_layer.py @@ -306,6 +306,7 @@ class OpenSpace: workspace_dir: Optional[str] = None, max_iterations: Optional[int] = None, task_id: Optional[str] = None, + capture_skill_dir: Optional[str] = None, ) -> Dict[str, Any]: """ Execute a task with OpenSpace. @@ -321,6 +322,9 @@ class OpenSpace: task_id: External task ID for recording/logging. If None, generates a random one. This allows external callers (e.g., OSWorld) to specify their own task ID so recordings can be easily matched with benchmark results. + capture_skill_dir: Preferred directory for CAPTURED skills. In multi-host-agent + scenarios, this should be the calling host agent's skill directory so + newly captured skills are written to the correct location. """ if not self._initialized: raise RuntimeError( @@ -351,6 +355,7 @@ class OpenSpace: self._running = True self._task_done.clear() self._last_evolved_skills = [] # Reset per-execution tracking + self._capture_skill_dir = capture_skill_dir start_time = asyncio.get_event_loop().time() # Use external task_id if provided, otherwise generate one if task_id is None: @@ -806,7 +811,17 @@ class OpenSpace: for s in analysis.evolution_suggestions ) logger.info(f"[Skill Evolution] Suggestions: {evo_summary}") - evolved_records = await self._skill_evolver.process_analysis(analysis) + + capture_dir = None + if getattr(self, "_capture_skill_dir", None): + from pathlib import Path as _P + _cd = _P(self._capture_skill_dir) + if _cd.is_dir(): + capture_dir = _cd + + evolved_records = await self._skill_evolver.process_analysis( + analysis, capture_dir=capture_dir, + ) # Track evolved skills for the caller for rec in evolved_records: