From e7792003a78cb917675eccc5bb4eaa216405d717 Mon Sep 17 00:00:00 2001 From: Jason Stedwell Date: Tue, 28 Jul 2026 22:51:33 -0500 Subject: [PATCH] =?UTF-8?q?2.1.1=20=E2=80=94=20MCP=20Phase=200:=20every=20?= =?UTF-8?q?high-level=20op=20returns=20an=20envelope=20(return-not-print)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal refactor per docs/MCP-SERVER-SPEC.md §4; CLI output and exit codes unchanged (wrappers reproduce them from the envelopes): - echo_ops: capture_op/resolve_op/link_op — duplicate gate and offline queueing are data (action: duplicate-gate + candidates; queued: true); the --json stdout-swallow hack is deleted (helper chatter -> stderr inside the cores); capture_op takes body_text= directly (the MCP path, no temp file). - echo_recall.recall_op — one structured result backs prose and --json. - echo_triage.list_op/route_op, echo_reflect.apply_op, echo_session.session_end_op — call capture_op internally, count gate outcomes from envelopes. - echo.scope_show_op/scope_set_op/load_op (+ shared _load_gather), and echo_doctor.run_op (checks as data). New eval/test_ops_api.py asserts the contract for every core: envelope shape AND stdout purity (a stray print would corrupt an MCP response stream). All six suites green. The 2.2 server build now starts directly at the app layer. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 28 ++ README.md | 3 +- docs/MCP-SERVER-SPEC.md | 2 +- echo-memory.plugin | Bin 192635 -> 195559 bytes .../.claude-plugin/plugin.json | 2 +- .../skills/echo-memory/scripts/echo.py | 246 +++++++++++------- .../skills/echo-memory/scripts/echo_doctor.py | 70 +++-- .../skills/echo-memory/scripts/echo_ops.py | 210 ++++++++------- .../skills/echo-memory/scripts/echo_recall.py | 94 ++++--- .../echo-memory/scripts/echo_reflect.py | 78 ++++-- .../echo-memory/scripts/echo_session.py | 65 +++-- .../skills/echo-memory/scripts/echo_triage.py | 103 +++++--- eval/test_ops_api.py | 182 +++++++++++++ 13 files changed, 738 insertions(+), 345 deletions(-) create mode 100644 eval/test_ops_api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cb9f21..01c909e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 2.1.1 + +### Changed — Phase 0 of the MCP build: every high-level op returns an envelope + +Internal refactor (MCP-SERVER-SPEC §4), no behavior change intended: each +high-level operation now has a core `*_op` function that **returns an envelope +dict and never prints to stdout** — the transport-agnostic seam the upcoming MCP +server (2.2) wraps. The CLI verbs are thin wrappers that print exactly what they +printed before and map envelope outcomes to the same exit codes. + +- `echo_ops`: `capture_op` / `resolve_op` / `link_op` — the duplicate gate and + offline queueing are now **data** (`action: duplicate-gate` with candidates; + `queued: true`), mapped to exit 76 / "queued" text only in the wrapper. The + `--json` stdout-swallowing hack is gone: helper chatter from the low-level + verbs routes to **stderr** wholesale inside the cores. `capture_op` accepts + `body_text=` directly (no temp file needed — the MCP path). +- `echo_recall.recall_op` — one structured result backs both the prose and + `--json` renderings (the duplicated prose path was deleted). +- `echo_triage.list_op` / `route_op`, `echo_reflect.apply_op`, + `echo_session.session_end_op` — same pattern; reflect/triage/session now call + `capture_op` internally and count gate outcomes from envelopes. +- `echo.scope_show_op` / `scope_set_op` / `load_op` (sections + brief digest as + data), `echo_doctor.run_op` (checks as a list). + +New suite `eval/test_ops_api.py` asserts the contract for every core: envelope +shape AND stdout purity (a stray print would corrupt an MCP response stream). +All existing suites pass unchanged. + ## 2.1.0 The "quick-wins train" from the 2026-07-28 review (`docs/IMPROVEMENT-PLANS.md` #1, diff --git a/README.md b/README.md index e17007f..bf9045b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# echo-memory — v2.1.0 +# echo-memory — v2.1.1 Persistent memory for Claude / CoWork sessions via the **ECHO** Obsidian vault, driven over the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api). The skill makes direct REST calls through a bundled validated client (`scripts/echo.py`). The whole toolchain is **pure Python** (stdlib only), so it runs identically on Windows, macOS, and Linux — no bash, no platform-specific `date`. @@ -421,6 +421,7 @@ From the credential-free harness (`eval/run_eval.py` against the deterministic m | Version | Highlights | |---------|-----------| +| **2.1.1** | **MCP Phase 0 — return-not-print.** Every high-level op gains a core `*_op` function returning an envelope dict with a stdout-purity guarantee (helper chatter → stderr); CLI verbs become thin wrappers with identical output and exit codes. Duplicate gate and offline queueing become data (`action: duplicate-gate` / `queued: true`). New `eval/test_ops_api.py` contract suite. This is the seam the 2.2 containerized MCP server wraps. | | **2.1.0** | **Quick-wins train: cheaper sessions, durable capture, one-call session end.** (1) **`load --brief`** — a token-budgeted cold-start digest (Fact/Pattern in full, last ~10 Observations, scope+freshness, last session's key sections, Agent-Log lines, inbox *count*; `ECHO_LOAD_BUDGET` default ~8000 chars) now injected by the SessionStart hook, replacing the full six-file dump that grew unboundedly; all `load` reads are fetched in parallel. (2) **Offline capture durability** — `capture` on an unreachable vault queues the *whole operation* as one semantic record; `flush` replays it **through capture** so routing/gate/aliasing re-run against the current index; a gate stop on replay is kept + flagged, never landed blind; `ensure_daily_log`/update-path writes ride the queue too. (3) **`session-end`** — one call (one lock) writes session log → Agent-Log line → reflect proposals → optional scope switch → **heartbeat last as the commit marker**; dry-run by default; `ECHO_NOW` pins the HHMM. Also fixes the `__main__` twin-module trap so helper-module `EchoError`s exit with their intended codes. +19 end-to-end checks. | | **2.0.0** | **Packaging & structure major — memory behavior unchanged.** Skills-only: the legacy `commands/` directory is deleted (breaking for pre-skills clients; the 1.6.0-verified skills are the sole entry points). Artifact policy: the repo tracks only the `echo-memory.plugin` pointer; the 15 historical versioned zips leave the tree and versioned builds ship as **Gitea releases** (one per `v` tag) from `v2.0.0` on; `.gitignore` blocks `*.plugin` except the pointer. README gains the "packaging for other agent runtimes" port note (build-target rule, never a second source tree). | | **1.6.0** | **Skills-format migration + lint blind-spot fix.** The eight slash commands gain `skills//SKILL.md` twins (same names, byte-identical bodies; a same-name skill takes precedence, so `commands/` coexists until its 2.0 deletion): per-skill `allowed-tools` end the permission prompts, `disable-model-invocation: true` makes `/echo-sweep` + `/echo-triage` operator-only. `vault_lint` checks retired patterns **before** routes, so a seed README inside a retired tree (`archive/…`) is flagged instead of being absolved by the leaf-README route (+ regression test). `plugin.json` gains `homepage`/`repository`. | diff --git a/docs/MCP-SERVER-SPEC.md b/docs/MCP-SERVER-SPEC.md index 5bc1f8d..bc84b14 100644 --- a/docs/MCP-SERVER-SPEC.md +++ b/docs/MCP-SERVER-SPEC.md @@ -75,7 +75,7 @@ monitor, PORT deploy/rollback. Server down ⇒ exactly today's behavior. | Tool prefix | `echo_` | Namespace safety next to other servers. | | Result shape | `structuredContent` (the `echo_output.envelope` dict) + a 1–3 line human text block | Envelope shape already exists. | | Statefulness | Stateless protocol; warm in-process caches | See §7. | -| Version target | **2.2** (1.6.0, 2.0.0, and the 2.1.0 quick-wins train all shipped 2026-07-28; `session-end` exists) | Remaining prerequisite: Phase 0 (return-not-print), the build session's first step. | +| Version target | **2.2** — ALL prerequisites shipped 2026-07-28 (`session-end` in 2.1.0; **Phase 0 in 2.1.1**, contract-tested by `eval/test_ops_api.py`) | The build session starts directly at §5 (the server app). | | Local stdio variant | **Dropped for v1** (documented, not built) | The CLI/skill fallback covers the no-server case; two transports = two test matrices for little gain. Phase 0 keeps the door open — the `*_op` core is transport-agnostic. | | Result budgets | Every read tool takes an explicit size budget (`budget_chars` / `max_chars`) and truncates with a marker + "call X for more" | The single biggest token lever: one right-sized answer instead of follow-up fetches. | | Tool profiles | `ECHO_MCP_TOOLS=core\|full` env: `core` exposes only load/recall/capture/triage_inbox/log_session/health | Tool schemas cost context on every surface that lists them; the claude.ai connector doesn't need the escape hatches. Desktop registers `full`. | diff --git a/echo-memory.plugin b/echo-memory.plugin index 8659cd4460e6fc252fb4f61a1ce49eecefa55d2d..6ed6d87c4f27a14143d7d18a11f11bc8a80cd5a4 100644 GIT binary patch delta 43443 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 z z!?{=1^;dp!W;c-2?sIh<8Qn~r#5U6kMB zNBXPQ$nr5LF~~G7t?-G}LX~ed=ZUh$^4OihHrg$8%kozG9XXGPEZL*>>;z>qTjaimFN#G*P3$F9$`&U=Gn#Xk{ zDcJIy(eJXwGFFc{R5icCz^<^#eIP})WB%&j0;W0~X&k;(cvnm9BWFto$Dy5P0MRu8 zJHm}v>?%a_O;l<05=}P~U8T5^r+d&IGa=wuk4b>5-!sL4nrXkC?upMMqfJzV@~Ie- zXGjDcT|iTvQHq2)tvBow_`I1#r)mNhgf`dBGT_jacFhyN5tnG8Oaux2epT>Nn(^_o zT4{~krcBO=QHnfYMs@_ZE)Rgo{g zqR)@N&VEGdfa24ojIi)CV-yo-w+%loAGNQ_a~{9*_|_$UiIa2Vd|WWKgU6yn^uzq} zRZ%-9jeFF+zdG)(eYZj#X`|i-VrS}TTuu^5W4>}8_e7~}-nV64n(&(R$}U^_oNW)T zUVABA?9Xr0(m-6alMh@+JDR!M->5?Dc8}sO;cA$-x!9G=;3?QSH5%q;6<6(Sf{C; zQ;*Vc1YQ?q`Bsee{9F2?Pb>P5``LgOM7f_!Di5aytb8Ae3FBmnrI_uh8VY;pHgb~y z07V9ma~yLceV2H?NUoxfppz*GW`uzFPIaJg5>udx@svcD9x(ec z?bM2RrqH?B-b&)jbq^W0ooieo%@8?R4u)2r7|h$vg#h9FNwQAAe{J7(a^Onl)a4?2oM=17(6vCkVH4x1G7Ive7Xg(gUQe z+1}P`N;8=4A;95YHDhtx{#})MVxx zNbT1L+Ie5wO0NzN1pyK%OnX7E>4~hq;XNPgTfXwI(Qs@Qhg#}O9HiOlVV1k9b>uYX z!70p@(u#I7GkT%ElygL;ixf#}8ImaTODvH*^h}@Q>&7ujM1?aPa1*6^-yeMD+|CIr z!Ro!hUsrUJoe!r=Vvgz~oJb9O+UpdPB$}-3~h6u8Ad~@pS5oZ z;)P@rE!{z1 zn{?{0tE0bCuiiUrS{lcGdNL`8r#P@g{XMcZim+=bKI?9ptDJL06|f;-IK1Z^%11gvLdcO!6=|Z5v^Q<(+PIzLSvG;O=_i;tfK)4R4>* zLoy_imZvn;IOmgW!RHZJbiF|7J7NVp59KrR%6_}roo(oGoJ9|jmwDdV*`U@Ahuvc3 z8 z^)M{mJowYE)XA#FDEA{`4H`%`ww)lf=l5lLM=@Q{n-BO^!K2cK4OYoEb)c~!^fUK~8BeAy#26Z-_2E9e@1llzH^N4LDV;3#}K-d}Oag0~qt zn&rdmeIDaq?$?{pI$6vhgQNOn2!3?EUKNt6tQBvaLYIzz68@g+UbOZkPM_ zOc+KK@XEQ;TdzLq^|$$EEY~;A-KB~Sp&ONbe*k^Q?T8w<#?jGw1~tyk|I}pc1kpJv zYH08`-Uil1@k_-Rj<}9>3art-7uuy^5Q^zXbchZAUR4b-wo}9fk!pVjyE9wO?B6uR zr}*=BOMg*RY+9oMZDq1{h{ad8P9n@@%}~JqS_&RnO!Z1sFjz0PY0)cD1b;LS+Eq@sDi+4s9XfvutsY(|ee*XdkG_9d%(XlavC{O0+mWsILp zhQ&*Z10NRI4&d9!KER=uKB<=}^D|i%3!(bm#^OGEx*f29J>xnrPU{7tXzi^(+Q!F^ zY1t6fY5LSkpm!i95`EOpmMmyJ{>|D(=m^wXAn3nQZeIb9&H#q6xZpRl01s>+`28#( z47&u*n+4~`8VMA zivTaMGyT6ZLJG?a5_n<}U;sj9{{_BU0=NLFd8jP#r#}|BWC)E;x(*Ek0o?x~sNnf^XfU+l{~%Og<_&0vJA%JT^ak`bh=IE|fcLQH zVCqdk4;T~w+my2jhy$Clf0gG=fFID+2df_fgy79>01>Ra1x1hy{|%8egrx*eZb4%h zWAtw@$u=Mg)EoaBa%>Fy3Y@zQCAOLTA1nfdn*E=G2o~7^jDa!pe?z*=U`fG?JAgER zZu#G^?k;q!Mppj~&+Y(HCI|aDz@mYv4xqLt-Txve9mhXV zO)dUaD&}DE!8}LMVVti2MZ%5%9w2o8ud;pwZ~*YX{wiF@01??gg)3lSX#c~$D-5{j z1ZqF#7+?e5Q2bTakD<=?sQ)V5C(v2FOZ!)eKY?2NCwt0&kCu$@4^5Elr3mZ^N0|XQR000O8001Fe<^Yix+yMXpCj$Tg8kbGq0v(Y!4u8j1 zV3*vsEWkQ+ZLoHyVFy;CZ8fr}Npv!!=zkw=Cqs|B*djlY-^a((1;8p`PpdVo$aGxx z)N>kF#RMEEWyfK{F01#|yT#jaPZ8TWr4xyHUDvIX3jNXLSQ+l?N6Z}Rsos*3C~|f( z75@JuOW7nB<)mOYiOE7iV9^K%hkqa)!FKcI7H%K1vkn6saPR`G+Gp^e|C$YY_`bcn zhmX6f3O0#zE~7_jIQ6g_rQ-m`J7rh{2P!q*gTW|6qB>D#f0+K0yY?>ZYe`P$uy84Z zCOR3x1xtSl=*EZSEPVQQ`2pTSn=p2;BtwoEf(z}E;V5fBraa9$*N;4;pnpnVX)pyd z)>%-7T;dTEy)FPDCMu7~34tA=cLw#<>Zbr5WAr3cHi?7uD5A1#r`7hXgZCi5;PZ-vbR=#k1L$?SRRWRl;C~lqQ{uxS)I`1s$kS zF!3PTrAiJK=Eat8CW*b;xm8-0jDwCn>3*NodU1O9KR# zaNhzNgE0cPF#-V*ehnETJPh7A001w-001JF0ki@hmoD=H2Y=0d{dU_%lJEa|iXQmR z6*wj-*>N&6bm&>fR$||AEFVkRxwEpC0!dK9m;@LAw5%xFGY@f}VPE0?e;;L^iX{Ld%xM+Wa-{Ciuc0!gIQnY7fJltj^}yd>>?Siugrh_ z{r{N!A~abPpMNhy^C4J9^B@oBeKSjwEE}wsLB2@Rm6(u=b&;7+p(lO_B-Pm<;AB8Xy>ZsN>D zbLt=>K+7wW#O5ziJWnpO{?00x9Uk>f5YNr4DBgTDmw!=yVdBJ01A?_@zIpxK;6pa} z>gc&S!`RQvJY0t{L7U0^l9(U{<_*u@e6|zCd6=%#kp6G{%Zq4s!NXbda}zAnFqmJN zH2i)OrD4{i=^T6rQ#=XgdzWdHW9aFR*({CL`Ov(~ z&5UN25`Suc2(!^nu%Y2c@!!H(9wl+#yf{97GsyBF-(+TX5zgMzQW!r@;=vDLnwaoo zlmj16#*g;*fx5sff@m4e4G(2!7Q`kFX@F*#B=2b!?`btRGeUmW>(h&&nI;>;#+=5V zr;OUqKYrYsC2=exFkzY|DIxkVFAiTF3|_r_eSd(pSgn&BD;}(YOydXC%O#LRv%d&( zLo-~3X~?L}Hq$ImG45nR3$;UI&T{@rt*Id`)-s9DX^fXC{Y&p;?<0acrxDJQG^K%< zv(f0xTo8)sy+IzN=V5LxXwj+1^Zw3)-pf}(jul!&A)$L63dA(eHg9>w3r`4ZdxL8uqJ}0u6@jU9i|x}0T^^pO;152G~WJB8g4QS zls6iaTbSioGeA8X?ILF%YXOgn}g%bz7QxNgI<}=vt(znSuO`kl}vt_ z@v}=BaG%M$Pu=je({Q?pmh+xj1w<|ZBbwgD@?8eB+f$ItfR=Qf(RQI}P(SqB1wBo} zd4|sm+8Y<<_|*}=oLvy0OvYZW_H>zC(n4i9?{$+$t1#K*8R7F87K+F`xs1b({+RdQdbuIaLhD51q;BJ|H`+0#1*V}H zXUXyd;d^@J!l7v-EAtqWG+2czqMSWxG5lMW#00hV&j(utFPt3y<@Lc^ z;zs=CtEWc?_Vb?){%R81o+<4qULb!aU*akqAdiUdH)~oG#euN9Gt01b*ijtiXTSk% z-+7mdgaI3W!w`82|9bHD<=1~DQ1fuXwDrL7niTua(EOqWE!~1SsCo0W|;j=$a zj*njz@BWdvn64-dh-hgVl2E~3D)%h5DYhClAPGZUhIV8gEJF+Q_~o|;hu5@&od&*ov|V@ii+( za`^lyFk}8oe}iwo9Xx*y;Xpgv1ex*jWFB03#-}|XvXEdCuCf5!V3#Jy{p8O{Z1r|_ zzRQC15EN`z3>>LrRh_?k4i_77FJD0B5?t;^222#g+!)*6=>c4p{=Ot`dhujS59<{4 zu^_pBgDHTm5odX{|HurUh=h9dsONxP1?xM3ohP%+ihvF9!v8utd~JLc0omRZRFLgC zoZszXnK6hj=TX|-0eXlaU6`m;CZQV#P!?#Zr@Coh^`jn$wWVAto)B!x*tk%v7l>7X&iXyO87tm(Siu8zeMX&)9cBwfhwIMch z-tT1}Bxq^po0Gu+L`p0e!!|!sr)feI5G*s6LQl|w(6+ofJ>@>3#^khfqIjBov{3FF zl4P_am&v))#XMY+!h8FUe17oi;P}9Q_;X^R>4vnH58-24+VwJ;5lrwkEO})of>{%S zSfUW&b#0(v#tLmoOMMd&XXF{gq=Bn8NuP)jX9KdbW?@TzKSVm- zmwEUxf8w?FlaUTUTSuA)d9qS7$tOF5dE0$ssrf!6B~dK5ohT8ys8cQA1e5y_YQ&G! z{g1@dk~BYc`mCy8EjpENRS|v_TW(34ESM+IIs?K@@*#rRf#)RL4zSL%gy?Ey_6=Do z|L^~j&>*cXA1t74LYnmOkZ1vat26*L>@fx4;TLAU0TE*Jo(JHbvtXTX(h%=dRG;7f zLFYJ76K)Ow4~cfMJLuyBlMqd%YSsoB0V2ulr?hx~mR%JObK=E7 zYVn=z0OFX%1M1#>-lWTAG#$zid0K^flMiKnnVsG-1A0TM`oYI=wjs)rH^4Iph;8sAxy zQ*9NpQ0@^$v=~{i{%8ZO9oq=avVS`R-DUG$Y6Y5WhAuu!IK=aKDGmL%XHdvwO-#zX92PAB@bw zqy2|FlV@LlW5#ZSq2OqT(L6S2aN^lfd8a!-xqVENp(nCB_8Bu?da5?qn3%(m7BV7D zV@R5i?>QD4k*W89DDyXC9g+L3FWNSEl`J!Vk0yD~yy>)}hUTet6=wN{ zL@6t-@JEr*gPrd<3t+Lqd1C?s={P1p{g1_}EiEh8=3;*rNjEV7*lGbiglQifLiB-6 zJlwHX;FjrW6U^>2o!qmUbywPS#xKUx8=9AUhXeMU^~GN)O{Q0cDfi9GS2X>Yc+a;_ z|94?cj%n+Ee)J#oyT_z0F{VGzkVrK0BD{He{DR*0CM2bZu*LXcj1BRbgwhUeaO(Sj zh>IDb|8^5&UHFS~HnL?&Zb7V1CbScli@sTr_5r&RG~QYggCZF|)_ooXy@x9;vb@1EO;J5SR-@X|A z_S?~KkN(ZGT`;OABrbih2F6ykw=7s-%HEzg{2Ogv-<7Px?>8j7=*=|mF;(Fcg02I<84ZFNqiH zkJR|XUPjN^c(M%cFClw)&Ee|=tR$4!qv%f73&wh2!MEoDw+c;Pb`qG zNMhN@)(RHHELp7x78B_JLu@F2d&$-Z zYk?QZlC;W-HqslyfY4oz*}1wvluZg9bM8aHJ9}GHoszp(j2aHujm+!AWAp6r_17=I z`R?t(bDm?I*y(djuE0T3MHkI8d(Q~*Du5RrZDCq@q5`FaD77|Xi+5f~(_#>R#F;k$ zUb|D4etoU8l5G+5Volubr^hLNo`V;Pd0fHP$^C7;(!HS)K@&uSFr39X2M2OU4Ol%)x z@Enp6_TN1+AMpDYGTWA^`2*=ryUOmysvz}twtzdj1>U1apS1u~OnxdB(VnnFdIBsd zUld0884Zw@_#c|LYH9isFDJOk{Hpg@^fL*e?_;BsULDvb z#BIs?1yiEFHt3V+FDp1C9JFb4j(}xTfZIFCq5EUz9T*3K+bTu#Nf#6!S71&+bEar$ftos6UPgxWw{NfIS1giAxr^3zqA zo)dtvx%QrJXpPh82bSsHh}iO1WI@vqu8G^-5Ox0UcfPJ9_VD#h@8%9%j6O(zPa2>f zJ4}mN_nuefT^v<$XkA`|2Ky(Ev>~Svw;=;3pV+nKFB~Ho8)8{gojSW1a!6v!Vy<7Gcm_sXLC^lbpqx5yyLTzJglfK;rV?lWp25A{<fFFQ zsG^NYuE7l(P1pou^7S7ye?{OhiZ@{mSIl+Y{aojHuC_gV%cjHgDAR$!C~$xODwrQJ ztXEN;apA8NH*Z7I1z6MSo3GO3eHg!q)@n;*!%^o|9EdlJ3=427X3ukfXsywF8M-+k z&T37=oYd&J)ZAQ72YlDvu$g^`$hBZ z4cYHmC)CEe)n$=I7rR7759mXUN4m)Zl-ft0NFPesuQOtz^-rPfPas<**Y=^Qn58X0 zK`^K3NKhGBh{10Tj%nF{ttCq=l$y;$OlWQZfz@G_s)Xc6`;Ycu^GTdSz3*%^WMUVK z6cq2=CI(AkvjARoFpgcKrYT1(#OKAPvrp_I!p@n6%vjm(+e5BUX~;?@WZYOc?v}vI1luKr{a2 zuY!*gg)`QFnL9%PYxIlp`aeW#1>NUXLz<{>w`AYgUaf(`8JDU~vR_n4TlL_+WV?LK z%WYh4;0htq?(8<@$tG4fWB~(l9Py<6B3UyvQ2TcTBxJ>ZJAHP+Tiw5I**d$rb#{9< zJ#!+C!c*$|LA~oIn8jT?hwWyr<;@#ntGDzl{W<#+&3#gc!!htths5vHn3b*q4w06150X@{#pzJ{@-G05j(5re!9lEiVL^hweU z?QR=N^uG>&h)=P<+ncQ)JpBA&TU4=5(&)mt5{k8q&=47aQ*Z#&8^p1K`QHL$R10z(cbhW_ zj)7?+QPW`p9PBtNi0?IG=vo`(j;z@iW1l3}d+*YBv9qe$V&T1uuX!WU zuRL-*XE{c{$b85~)#o-aptN8E0{~p>8+Mg_e;WbS$pi7>iDW+coGjHv15anXF~W$g z*?_lypuA`N-LHdbiUbX4gs))2GqKas4iL>DI*|mwUGL%k!!HK=Uko1Zd%LBY!5tHF z_+Q0euc?4wtQ=I$WT=fTBk#BE8Qs>~5@T;AnBd zL?KBV!`Ecc&ySH#mj>z8*Zd~uW{rhGG-tejxEON6AotLiF8vAzqV$Ag7R&0Z){&&a zpaJ{B5f-^U+;dwrNCfC=K0#Eks_c>=9XVc^Ptq4%kt{FvX~)2Cz$(~{u4b9xVL88G ziCseO7TDh@M+#-&=NM_zvUL8eZ{S+@i#IYJ()M(7?0tym!(^ET>uBgEz}^SVc{kgC zkbpGVe;f(RtP2OK*EOS)Zt@CgYEB69XItQ9_A^XPl5~A>JFylEt^c$-%kS&{Esow! z*2_)idq%^%3_=IoHODuHN0?+0eyyYk@BN8EjIY19GisZ+C-3~!IZwWq2A}NGt-5h* z&fAjTo7DyhZzS5Sqm~cWoPjF+twY2_C;Gl@965Cr z#{9daQ%Fv<`a8tQOLBsp21WGGp16v#s@nVFp%Zp;rR=WhuOEm-o4^cDFLgMdQ?G+vy)%&+9Pp(e0TxC`P@ z%d3HIOa%=02GTxtDB>q!UA69+;wX!Fpy1-cE1) znC&S@vfHUkA`@XFV<%s=OU~h3$gjhGM8y3!=07yRUzgCA0Uv*ekJ}u+e)ZQ<7&<3z z2!{ zmNA?^TiwBuT!*=3e(%)f?08lM8I`lFh0k>{u^&fORFe6cah6wHUEx;ZVVk|xk;l$< zoY)b%mE%*qh{b==s+pWHe=Nag{*4LCUyxpUVQ3LHYb2oB19-hgm%pjGA=_zL2@!qu zY8v-X;Q^%lVn*;}6v3E(Hcxo^=FP$D=f5~t(9?6Be4#RoZqC5YIhQeXdgbhsJ2QRp zRzX1l{px^`d8`gMe6(BXrrY!D@eoIV#Zqq?Dr4vy1;m%_m;o38U6%%#0Uv*Lw-h7y z*iq`0d!f^X&>~BVO;XuO-XZ7eNk#_^s`}%xphS0*Gv3yAMU&dE^-bwIF4#d)Usd=* z-BnaSW_2@{o!rRBe?Z`~|M@yu-k1!s=fHO>sIBy9OAWus`df5 zGlt1kuv$(Qq$aEXvEouBY5bR@nE?=guTQ^yWdJ}uw0nHEbioIy!pc!qmXwYkJ<2%2 zGoFRgcb^?8y@rp}J3$T)wyn5K+%by)|A76jTzCd1i`9h0beQH7VAU^ThkdF0pr2D-yl~!f%z)P6Xp^4n2~Xs!p6Q$R zr8DH;qM8Hn$oHx8!(wFoljYG*ahAQq@=sBBZhc zl;YqS{Wv9I=z2xdtcbWcil8M8X#k^?Kb`EK+Q0&{t-)a6gl|Bw zA!j#hQrQCuRP(F7UR7nZz)$FnZ9rY%Q3W);*kdyjE2A1a5rU1IF#@rFQ_)SUK2;&x zh4=4-reR2XeI5EzW8~&t_PdeS*MUZ-z2>m&pKZdS|Bn9fHzl%q-}n{Vp&d5Ewdd?u zPhc*NY4aXZ73yyy!MP9!r@So_KFo(GLmi~u*HAc_?a2BNf8m&z4X4#2Wy(3K%yxmr zdKX&-R=#jt&J+8;va@J^JwU*;MmAg-AhRKY2ZQ&Lqth)`RqMRm`nC9)YxX_6Q*DBw ziWl34izJ1%ya=Cjm<3t*SWQ2BE)rB`J*%iB6~$ca%a&U51?Y^AIBS&z(tN~G%Xzr8 zfw7+%?Ac;L6quIg-b94uz^f}_yD50a3(XmW5{rz zF({Tv`f3g&tD!b> z2Zgv)4EtNC5XBYr^ObRQ2ZT3#0Akx(Ud#- zzwSg=T88D9CMc~6)9Jye45J-u96`mAllK!oW7{R)+Bh&#I&x3%(o5?S2(jVT^SUiN zeW--y=RZX8A{jT10OK1MM)7Y=O1)>ki+NIv*5XAz&;pW@Vv(pLL!AEWw+BZrkVPvx z2}wBc%OV?B;GGBQ6qkOT0U3WNil|Ze|X)Z(5Oxm!4 zizBq86iOnststvsd!f#v_Qn8F%Iq>whu1xhDPAUA6)Mf-Pz)KZPz)b)HonnvpO+C* zp#O4_aFnjdC`2_HY+_6{$WPx&;L;hq5+e{ZvVMUk`H zS50(16iXAo{LjFtT9l%0%-tn)<;kL!x^E}BFtteQaNbTN#LI}f^NGd`e9PCASoCqb zf9|cO)?S1%Y0D4J?%gY@7d|;d6<4hj&8ixHEh-!r25hp)ojf%JLM4c))y*uW@-vK) zNonB`tZM~zQyz;HpIvP16l5wsv5G~?E?>uMC7^3(C5s^(rSWZ` zy?_voIud^Rpx~$a?=F9G&ow(>x5cWclBX@~wb=J)E8@ zwVn7^8q3LPtD)9JWVTuP59F*INJOC1@D`2(7u!iHk1S3`5B7gg&68$*%j(~qQvKb( zN(BXkM3fb@NLMHBb4`+lRAcZ*+7-b}L$gA%1KwMG{YH<=&9zknr@t!A7q40Z(o>WOR?bxwazY4e0R8_pQ4|7dI2%_}K?oHJ!tkGR-C63AfwLSJSYAWz+ z7-$uPZSIy9PK(n>8Dj2b>v~=TPh!Lsc6UKIyNm@)>YdhxeMfxCO|7tJf^GxKn1MGw zQ)T2*F@~2*h&1ygy$jbboGp;Q^w^X(Jnha$v^{ag%@==o+*41)LBrwhWT7}IAp->+pg7f4@3O*9?;d%m zF@m-oUv}>>CdERpK8#zV72B&&74$R`5hR6)aln7N$!ko?ja;D)m2T&HwJ0AunDnij zNGMoyHEe$z@q<$U$5oLSds@EJbW!S%bRb5_4le8HgG8+=X%5gz2y^cH#E>Ejx z{X;zkT|Ze;o-S{cyxDTd+&y28jAE%?>K3-)nscmif(epAgUeltt3&KbVNm_#&ok>h7$kE$~+BT&ti2ebtX@iv6GeB$T?u$l{BJK%`8>e z0rALFCq<+?H>7rSu)DjeITyR#m8lbTW!aXmh<}Q9Tqe{MrT$>}PNr!TE_gQeqENQb zLf?O0zQBCS-9Bv$t;mt(l%Trteaw{`--q#Fx|x&8$z_;|OMK=LN-!Az;qbw5zc(~T z;ZlpSaH#}16jPDlBeL#hx!Gg5ZTzzfYe}zWVMt?T%un3l6te2rk~@ zlC}F%C76ez3n@&ms01cn3|K|JPH=z|s$zF@7B(>`l6P06ClLMYRDByvN7BDMEB${x z+NP1|b!0sR#Va&W^=KHigt#iUl+#_!&C{=r4qhKKR~0VIdZiUYIFjMD5T+vtn^H@& zS8yYiWYHU`!QCrPnpBcxKJa7mDA$2n)I5I%ZE9owu~ZpFBSb--t6n)5lEE$xQI&j)1iEu0GUKp`u!d1HsuU1j!OaMP-n$B(q!Q@_rs5n7u$GT0X zm`PO)=o+Cb0laG31c9mfXf6<68smqP!GrzLX>-A`-t{3@gpAL^;u?w+gX`TO1jXM8 zEGerauL~!MJJgae1O=tXRKS1Qb-kEY7nR;h^Jy43o1U6r?_5lqfnF}Y%KWVSwW6z> z?3YLYbx+ibsxRIp_`wCWNv%w&AG?A7#BVNyxfCi=SRM!%m zg)gxnoiYiQAhqY=EE3Pax51ou&mjt7i+an%c(maW| zE7$aDu^(fmcgoDHenzTiwHAo`2fSJ0Sa`-@;-Waj$B?N=uyC3j(>=HApL%lG@XAR@miwy8L}- zH*n$hf?cu`+SOMbTV#JGzV<9hX@Pxz@hQTm2aOU!Hyp!bNVxdy^p+7;iM`mC%9r&P zJ{}W%&ZBp6o8IE?d(c4LIefji@ydA!yn;2bW-6zEE89t~wkyy16wOAJeP~m(xAWQZ zZJKkT1G}8oK@X39K)`{ZhT^Qu-gokY?Jz`{z^#pydrcrjh zSBOec+$$*KMS0-pF1N&((hMUqYDR@GI=PgGH;P) z^7@U9{rP-nt&D%`TK>He1$MhWu76&Fni=*YG!DL ziDw;Qf}b!Mx0hy;d<#Gek14d>a=6P|+*oeq$Wg7o62iovJ^MoXGe({xef}ZyfC<@Y zU;{@A<(@f&t)hk^C&5Lly1`jkvW6|szPNmfYvaT`(fWUb77rAI2bfG0jzu1;?8ePS%C~Q8OSJBSwZ1H%X#6fU;dw zLZB1}NyzTN#M{O7k(|N6$Z7{1PDXg^v?tu0m{?9G=!~rn><_W|_`*qk z$@c?r3!i^oHa&i^aDcvx3mviOIj@t1GaJO*X)bkaOKQB$)8M4rsn!FZ zi{f=fD*L=%kw#Be46Cj(8g?Z~GnfnJw`r?`LcXF2z{%GIv3AWmYOmM~wK#>KA2 z*UQx8>KUmNs=?()`;VmLxr#AIZedz{6rDvD_@&n6@Y&IhiD#;DNbZni7bxH}p3<2f zPs{Xbm5PH-y>j^FNHBZEv%A2J<#H8zCO10kUA)35h$hSm7Y+FsVM#GP2>@hzHRNT< zeTaX;E^xkT{*!Ov(C}vGbsH^LkhI^wLCV0e5fY_Ms5PHWkdxHtzA2;es`vES@yo;4 zBU%f&$D`%m4(IAOds_yNg(MRvS~A9(knL9?WzD>`mukpOZAi+*^&fq?bK1E8ggHGf z%SjfaHqh&<{33}ztKUCq@BP4Z2YE8!2@HSu7LZSX;@$=F&ik*w|K~f;y$|FaUzm}N z_V(;gSRQTI?B@7_i**m++XI~qjsLA6lM7A-A85<)O5@(Gr74MM*lWgtfBy%$epo^= zqoTFFlUc5&6cw9~U_p+Y6i^)Zp0KAj*PeU+rRs_47cb~dfLXW-Xw{lx zb@}xxeQ!y2s=Qr#5BkpA#nV0J$;x6IFN&S>?}**iUMuXLdGT;0M=hW~s$5VJFyC;^lt9DfQv9q>GJ>WQE*5f2=v>kg`EVRm zvX^aK0C}?d82)7BBNO=)^?;9>#&MpO7U0=oy@Y~`4F|OG?B1P<@d5YVgJ0I!zeC= zHRn$_lC zvlytS1BXVDTF!@SB7h;myG(!LbI!cYaACsSsh%!7&bnl3NtoGNP>u~T+Z)Pnsd zYpegVouYgi3hjvt`n2c9uqHOs!A;#Q<}=axxIfyB=|0;QPQ?W;H-#-lA}L%-c-tA| zmJ@+I+Y=smh1GQD13D~$_2;7aq0Lj^tG49|z`DY{a5#@$#Hw!sGd+LdS)AUQ#afK0 zUTFzKXqp6kpTc>H`vLQ$-4>{frCf<-%5%V5W86fBt$y3vHZ6I5>#YXbgA*%ii99vE z2(^wXts6^QNwRlZ@avoqtZGNQdD@`x2AUf1N@buOWXsNApt6D8Yc|RYKr9VcHeqGQ z*Yga@#Ci;_fZCw;hNFM1>ebbY5uDUlJE~^-t3#}JO-7ZAp>>pEDHgxq7NXI&nqeBL z)wo zEx+|^vVW_FjPXZG9eU*aN~wgz#SQ2s93-JXugM23x)-m2% z1-}!VS5bCr(xbr??=oR}seQs~O!`iU2G>OQwn?x0L@U_pOdhzyP!GTU`qfJ^&Lu*Y zegM~&E9_X>w9kK_b3yEoTKAn@9NXyDMY_X639{OX%Y4GTagcQTWbKl_TX(BEfv1{hBI9!@N>A) zU7vZ~uJ-;>Cwh_bXZ6jFDQH@tgYWj7zM*-0*QC9gAKyJ$tQT&Qn9UlFdnDD6k%qgX z=^r>J`38S#@fHu{F74)>%IX>{T-K^-b^3-aaxjFUoH=4m8{Ile_PSq`K&b*lyNSKY z+OIPW$*R9h`PMk`vKF^q6JNMqOF5EoPII*G->YPkwoI%}5wLM|L~PhBJnf)Xw>ZeT z8-E!t9JQy#$GYE%3U=%{$WZyj?OGB|gq$j_>5+fb>@8ACxKx$vRB!bo`FzI>Bc^mN;e%Nh}tm|vZW0m6{#pD{6$6Gk8S7G~!j}ig&_)`by(u|BnuEk}t5o+;g7o^;p zFZTCcFX^YOX7%KhZ6IEMO$L^0rtr9M{x^QXR%_%AHv$(?`1xYvf6jBhIf8dcPbTn!e-Qt(YuIWlS)bm(UM$%^E5r@Ylz95qpEDA6D;8=V+0Y>aKfc!*>~9TSZLoMGnR2WhnG~ER#on^k}7B)d8S zS87h6vG=$V-Y0Ddu0>PFKMRKT7i{^OEw8lAS7d{5gR7+fu}`-UsEc)bWzbnm0?3oI4#usxX)bE1fVKY$soRax z%qd?|f44lf|Ec%^UOKR$$rOL}P1G1R7ufHTQ}TBYk-*Iiq3n6{7-Hv?({O>4kSeP_ zAEEN{@=D9cbJ^}BBa(v6>d(_F&VeC^GW(=ZZYTfH39)tn&RScYu zbvlC4x)@N58F}9KilgM%iOC;Cp|paP#obIc3RR z<^kN}dFH`d6Z15!+1Htwb+iuIF?Qbs8<^uX?SE)`mI^YRVY0$ih2XJ2ihLo2CY{jluA-AA`1% z?F&5cZDh;K;0jsSs2JbT1FLae-OrO%5XJ5Qrkji{rp{O5L{Aoc0-kTDcrx4`;T#4r zP8F>Za3u*v9E2E1*91i5ZUZi$*J=163NOVkxeVgIGOswvFS@xQ=d;~5a`DZAZLj$% z)-t(nz{z#Ae>;D`m$`G)$12Q&4?#Nij$VEDO@rA6d}plQf0*mtkitw|pM~s2bzER= z;S&4cCUjJ9tgU$BvHcLtulPIUs$UE{V_34r)e9%pvp8ExZ9MiVv=-2)rt^>aEjd%s z2~3UqTBz4aK$Qk#Z5O_`AgY9An_Z zCdKYv`?7y5yP&6CJ}G&9BS}rWEb?}HH@lv*eDE|d%~^5TfpTMGJ=fZS+_g;LjD506 zm%F`s6w}3`(ehveed-s`n z_WBa-S}rA!w8=?+1aW=Y8s+lpJ5;UtPOjpH7dwA}oQL&nItKz!+7b$?!+-;@69(!k z-2uij+rl2RyFgG+VJ8&5e1hMR8`(QS!DZM92MMdia5RVlZEXuUI10Pr;r`uE1E~JN zPLO)4vktVZdfPfm_7QdulZ(vNLE<_?{@~sb=svmAd={(jeiy42{tdmq_L}O3#-i2ChS)l~6^=!Kd!Ntdpr*0nrxK(FB*qy_mYBz1uJGt^jI1 zF)0)8TPSR}+najxQ*VyR%MoxdX#f_KWxo%bnZtiAT_RG z>>9?MN0Fd>4WGVj4#d?x72c4YD(-;vMhBq{$4l5sh>kO_n?SVHuSg*3)cZB4!$HBw z_FG#RS&we|aF@j%q0@gS5FY}yFYettt&4gbyO%^7)Tva3lXdRd3!{p)X~Bmxbb){A zyzg&eGrv3y{JKm+CjU?75qBKKU0H-K=uhSlTw}?4Lb)O?`!i7j2cE!wX=l_6G&q!+ ztof4lW$?q*hzm8!Su)pyo0&Dz?KPQ&-M4$_0SWjh{@6<9rF@g|ZKrMcdbOqR*&0g> zM?JdDTI{u6cGYtmS~@5r0I%2GTpv<&k`D!Bl^Jt?vj#t&pds^%+$k6*kz(w!=m z9P#iXT%u00Zs1+M-s#L?3ug}8{1S|Y^N#J=UYb{i5lkk);AA2NEG`o>TOtWixu2Ow zs4XOgu5q%}0Sd*L+g2wPBTYg&O|Q%lLc+P@!MZhc1bUKpTLz(S9Wu>=Ty72pLrl2- z+=EK8*TbEi9ipbm1O;noGT760X1cFM6dYY;dAK_GNVjmi0p1h}5MuK!2nPTFE)SPM z9s(wpe$W95e^=dZ+sGBZ=Tn?A7a|dfl7DV0#3_P}(+!ec2WtaG;TjZ0j;yJu8STu_ zjvyG=hv+l(6?)%C*(d3DW=KkwvjM6bk@ItYzVn?QzWUR+v1UA%YAnY;RO)b_!IZ@q3(almALDG`aWTm>V1;uz155hn@7U=3zOr>703sq$lObANs zkfb7QDq)7?mV*3w8@XlSXkDxXUHitzZWsd=q6JG9&CCz97q`f%|rZ=ZtSGm{1 z_**_3Ed^|t3qY{Za8MYUH;xnoO0~4`I4|yjfBv||!j6N9(hV%FrR5-u;YckQRNdS! zqw7x}KYz~F)%4`_l-+MQdaMmfI-4@O7J@c~E0^A)yxTy;=c3k+@b>bnbW@rT6UKv1 z|NRf0AOb#|OacTUW4c8Pqt}$@i^erZQ}e_8vCt8Pl-@g zd}PW(F$=+P-Ig+BHI+3gLcK*6e=al%jMGi&uW(^e!aZuU%Ju@^fq=5EN1M7R#ZuQO zt~6^cl}fr9If2SlpfZ)Z@dcD?_hEE`G_lE1lxi@6xt?%Ten(~m+d1UP2x9ky5~r1@-2c!cQh`jsqk#YV%0l|$Pi%!@v7%#BkaBT&e37CgQDAnG1AHcTo zwB5;HvFJ8O{>+|vvgd`*e=jftK0OpojRwsNkl+p-Iy~BI@7{xcz2VNo;BZSdg4=(X z6OdUvV3=yOK?y)zc{I`q+fohE*uz7(q|NuZvYF}hsA9+eP@z0dkJ8J#U)YsTU=4i* z*U(^rkjBT1`&u*b^r&_z>#(8@~N^Hss3Nr9Qx-f6*ua_xo@E#&e%A z;gz-GX~rlYD{?$v7%>6|vlbHmpLhMQB0shHWlTFAn-IMqk$*1GWcOk6Ec;M^89lLD^J@_Y?O6qmR46mXXk#y86lG?9 zoZe!Tn%t#-9P%FBM)g8{;4vg!we=FnFb1D}Gx_E6`i3}V+fMbDZvow#-8$4(*yMb2 zGNkj#83)f}UX-QSIL>$^`j0m^U(opcl~9)2>(8$C!@*0?&L`*Jg@3J&;>K%?LoBJ7 zTjBZTzZ~|bz}QxcsGsRMog9S6iLcJLhwp@;Kg`4N1-ju^mn5~y>;?o(;wg+dNihtd zff%{LbjMj%D|Vd4hjZ+ZP157qOf3v7?x6ix%)09$ZzqK*NCt-wgXQtLG0h*svpAA* zvBSetkBAjVShx7RL4Oz~dvF&5llnKp+m9>fJXSpv21G-XZ@(q7gBgw|N_ls9sEgDr zBoe+vYPXzG5)uu@ID{_kN);^{6ed6VcUrsQaGwXeLI1Jf2iGuC^ROFw-(bfpPe=>f zz}2+x4H#@eApIZ)9W-4Q^kTdO4CfIR@>~^b{?LHzIM3IGR26w1w_|)EaqxO;G5PQ( zOyVc~H2NR6(bxg6Ck=9AZYeq&0034|001VJ0ki@hm8${}f3}wY{S;VwXG}IG?YQl2 zN4e^boA@@_+t`^T-A>2#(hvz*tSM3@NIR;knSF?T!hMoG9{>qTl;dvN^<*NE1P=}l z&i6Uc{=XbGx;mO<#gQuR#9~>`%Hs1q&+}9=E8}vZqs3DE&wu^5m}NJ!Fjsdf7iZ63 zUWjs`Dp_Y`f1yP#m#R{!C<{@~lt}VS6}5OGvLaO<;4{yPTOIA~2~jCs=66aE#JOAu zDdsRX!#7Kj)mkL7D2ptSxhTq7iG{3Z!Y?avKZ7Z7P*N!g53)KScrY>-3{NXrC9^}s zp%CiBLKcXU60;KK$;;w~5joOjQza@!_*zT>c2X9Fe@bdt0eq{~hZ-Q`P$3LtGv_2r zGnf(41^lk}_r+V4!-MpiA&ZHs?-fj5-FFn^ZA>_KGM#}8m^1yl$E z3mq{R?cY97|5rH@XI~tjxFdMgLTZtvAL4nLe?BypNdsf~%NE?deQ_$L&2PUgar@FN zMHbOXSpm(3tdHcR1WvDK8Sq73CII5v@WQnqLJ(Ks;QN`Z1q{1W)e@lQ*{#Z#AR1}2 z$bl&UtY4{L8{j<=hQP3iD+sb!-pl2o06q@GxvFkdm=*9C{xu823FHBwst=i_*-+l< ze`OGfZ&hC2lf>S@u)}T@uxgk}H`P=oAQV6XDeh%e;M--9FQdH|KfH;*JAHp9#$uY6 zvi3_If&R#Vd7~Ru`yQ%@*pBiJg?AkN&4cX%M9vH%AtTZXh+HgGk>Xs4Lnowy;LO`;<*Jv-)4E<8>$wWE>p&8@)Y3ISoYk4d|+RL|C(sjjfDDz#yp~&?B8UH-|zw4=p%)>CjpO9-U2v zVvzW^0Kuw#R5MZ9%oo~Ud7vkg8Law!)d2r`2pz6?lFvR!zl#PJ?W`FRQAe6L&v*Nx)vjOeMF% z+l9CXTdu*$P=J=|jDr+~f4V0Q0E?~~P5N9^;EmPn9l;{5jm;-hbsc%UxeE4jv|}~6 z7?xAOykZXz^lS#AE*b272LE+ag{*ner)oVgUdonS!c316yjJM`3{8c~iX5a)Q{kmA zV_XesDcCXhLh2Zi9*Id==0FlcvaiWxoXli#qf&g;e1(zhA>sdVf6pZ}li7J%C1z)8 z_ri5EfLe>DZXcl+K6am=T?IqEP`-yVg1TRMXnpKl-UKdq!jH+s2EVndBWUePy@_+b z{k{%%D`LQKXv25uSAKu$mA4}zCD3%mt67V`1Nm1IbK#20)bnKudUYgL2d4)_)pkda z(ZxY{uwET}OOGboe~$w1uV6YGfOJg9H~e24@Z}C+dGo9uAw$8Ye*Gw(1Hb#_a#Cff z$U&E2NZ^0_`sB+1JpY?YE@pO0lvv=ST%qEHm4f~vuHna#_$Rm|f`TTg2{6jCuH_^L zqti^lv1HL-l!%>gH3$m$5nhC59{R5h2KipE1^DeeZcMge|AB`TR=mp*9{hC?_Bi57R9k)=6RKVhZLlXMEGD zTr6-^SsjTBGlxn6f=tkPM!^QbMK}(9!21FINFll{e-2nhvU!ylST1t86m>RNL?gx| z6>m=8KYuB{I6e+>O3h|W!d5nE8^a)d@Rcr-M$n8u#vkC&->D))*Im>VczuV64!y$e z8*ZD*r>zu#7!50TLaKd2(HJZDh`7Oblq%X>h^D+>N??4M9}yXjR`||3nx~yE1R1Uy za9bv2f4a2LFgyeX@V)QO-h+jpdv~(Q>$s_MpNvsA70{Vsi18RG^~I=XHfjJ}PaX%j zMYaak#7u~0v_ssz(B4Gso#|;3cmh+@lL$heGF85+sd5jRDg_8!$1CqSrl^2z-!B&wSYp2FXfCVU zw7f68^#+~cp;>z8Vf5TWXB0BjgZt5OO}i(aH_TQz08I(XO$7v^)SO>={=dzG|Lkp@ zfBx)Wfm(<{FnT0?FW!BGz!PtqL%hF?(7HZhX*~JAi{pt)G^0rw=FT1IQ;Q8|x5+z* z*L%k!Wx0!#y`e-_8dj9mT;|zt3Z1lM79)~ASwb*pKzA?bSQ~syvQwC&FlKEta_i`Q zVH3(o?C!nyvVsV%-31F~$8MV8Fmmd#f36sfS|&hnkJ?rB0d0~vqYt!p5(B$rGZ!&! zljNh$mI!^D0rIKvSMUn>oreO`mt)Lu>KfKseX@IEoD`clv+hYeX|g<}ytY4`$2F|b zggPvj@twIO+Y@Vhg#k5x-=ZmQZj2G>{hjTg*M?3KP7Ye|Kg8Yo$_ltT^yS$7BM8CP@R}?3Cg2tgv80w|bDH zUk?Z`HTXuEGHD`2E4~|`DL%kl!sXQl{hj~;ryd9W7=9V2>%_pT4_^JkTNUhiuTsL^i1e`r4|C|OoYK?iA-)gfA5wO8qvTG zdM%IcW+cgsB$1KKa6^c|rG!U<9%KCm^vDQYzedrL-Kr9DN|hif4Ci??1l$)AHhN%) z!WlT!n~c$P-lh#&;RC%S?i{X$i)G$Tv#&^)24!(RqMiYq6y~X;z3s17#Lz0qw1;DmlX|J zoCxTH1ku8`fT!@u4Ugkwq+5JS8%nR!b^!7T^iyXKK-|_=yCW9b`5n(5hz>PHO^7snb zis#|Ph|lsH9IP1!S}?kT1c>>*#eji49bI*N$m5yIP_jT*3Ye`}do)vFoy1=5#gdp_ zw*1GE8rNlPv);awf2%_kIB2{En6nGR_idcZW!8^|xm}cN`FAVIHE>O3%}L3(k#1gU z8|V6`#74`|&St@y;9SWBv=}F8fX57M%F|#yAxyF=X@GetHBD~r8T3k+7bH5Ixo zdtqXwtkNjhO3`%jsPXwZl?fgb5s^JVv;u}FXHVNP#Ln^Tf2mKdI*y@wZADz@dq<$t zo(7lU)2qu-_&Kwq&18&)Q>UYRJnrYAEmpM(ZzEWk+Gyct{bU9A603r?PV28s^1?^L!|G^N=C=H`x9uaOhp~gqUj7NqT z9}6#xp813Ef5o2|TlC_J*;9xrS{`|PRDtVD=4m{g`zCj9+$R9xcp_%&v06^L(aL_S z1dYSobKNP(hyls|h@R(g8~jHfd;quGHrSLnT&uaqls01vwMX*ri!(atn!G#U&n zP-n%Y{LoK&V@eHks2g|t$PH!70hwwUSIyRKYK(XYe;hSO#ON&!EM=pF%{tSx1LfJo zy_=O=detf7^wllUw_ffU>nHOVB0%=|OgKm$7!eOPn%s2P&D$5RzrgANgm_2@%%0;e z-9wyUIyYu22i6u?OYuZ9W6vMVn3)Hhlq6RWeEj>ni}Pqhf}Q1o3gJ4ILD0|2=v|GS zEUzy7e;{y*SZvXqHw#c7=F$(kwWL5(UnXsfz@c!=Iaw527CAl?SP3%w#-)7N;)iw; z45+Ne+uLSz+*ZAfm?>e$2tbF(Ju60zJr%gg3*(zxsShfWn~JirU8Y)X1lbm;=@YJL zg)vpEJg#!-bq^|Ix3P4mc36>ILH9KUVC1OHw|u6IuXr*9vd034avj$&WRsAha$_ui+>hi@Nu@9X~i z6yC$jb?;>|`@PT~)9&4y#wGosxGgYdqL3UmFU8I#(MH{%++hU*{a)}#yE;|t@8X69 zk##}%1(*;0Ju+&p@LKIYklkdZ;?etPe|720k(opv(IpleNPmn`lXUDw{`YoZWa4ob zfd|6w7SI!fL~-Hj;kXQ9E(balqqej>Eikc6gARgCTkKvSQFN2LA59)m2{<9>UZb(G z3&-glvOPUS9h>R0Rl9y{VnU}#yvJ4%jI&a%Wfg7y$|Sx_?mBR|Ko&BEL9|WNywNjLG}M?7pv+Mh z5yC{%rbsy1dm}Nh>rzV8FiObCx&`f&AHm+5URy3jc72=NScz*U0mopdQfPjd6{fH+ zGxE_?WB~i$E^B4_tc^EqCu5ZgfA6T@uuF$Ca^am0O*Cq|2*n)SUWh6HcM}5k>yx|N z^Gx2G`0xz=J3}qEmHY-7@tj@pNRBc6T30>{+~jO5L4Q=h*=%?wP#&4>cV^)%DmhVt z=tsn`{D6LrVTGaoDi3v?p`uA36{zq#=%%b4c@J4UN7RnH740ndI>C`Df9Nx|uKcQB zhv|D3o4!P=VXz;^e#cfe$PB#{1-=V+@6#)MjWsa_Tg+7l;w6Xhg|`!7ZG1uW&Z# z={?j!#a#j;vk#A~3Buo|fA_b!M-11rqOSiEZ)>w^YdtN~_~0Aa2}Z3bkkhpm2gb@B zkPO{pONCWmM*j`UOq&CHNMIh6m!7Op(`0NKfNf*z&OYFt<#GGkN1VQ+cpij$hRNj| zJ+>t}9-#cEnM#cdXwOs_1H{d^a$2`F7N^BNyrK5}x?FI+*QN{|f6anZ72=0C*b~6V zRSv0jpOSevBO%?oIUPT@m2kW6ZX^E~RXUJ8DHsOr@<>;w?41!fSalGAJqQ_aBC*lc z+HA)P4sprU^D&!$Z4kZYtoLBi(|yfw#)+{yjC$O0*O-DozoiHg@DZslJN}ZRv#S98 zFO%yEtTX!tEHE9vf7!6pBkbd@D?BH(d8%U$A+%;@pgp#SHOx^B%T2}RAq!J~05;2} zbQYkdw(v;2dw=m}tJAmdI&=wP;#jVRP*_K)U(?DYE9}_?w8~QC(iRdIvcmYsoT0Mv zLCERAdK$Rq*gf;HfT>Mp`DyfJ^c1+qdK48k?NMEW`+%{xfA1OfRgp(1E0x;xb$JUe zoH_AAEr8_HHAVJoqc~=ewLOG_t`luvn3VH*iDtb@=@8gKO$aiwgz5}?YP3cw@Y;0FgQF4J~Ot$H<7H#+sECRTsWuA`h z;hmtRcT2I4eBv2do{2idALNCt z4IlKM9^it9!wh2_zxo@h)0C!4NYmA$4U;AOWEzRDz7(i!55-qsV`6ri-56Up6l~#{ zsx6)QK(d9G?RDOXwXH)*7HvD=Z04J*LP$ejgBs)p4N{2qzzoX&I*4x7Qu_h+w7}R7 zAf`M~e?RwhF>zH)Dl&F8>`PuBjMf%7eELW2z>q562+j9}->)qgN57YP z21Zp7X(#AuD0YILiCb*MF*OD%Fbv8Ss^8Ief6z|OkE{n$C8U4wh*M7nKO$X^5+>Yl z77Dw(e#ctpS0FiK_VUKY+j9uUHvlP2kg}kg z?69Xs4;^Un{Pg_d{MGZ*?>VFA90X7y-JyfW+{kXu^_A5Ics#t$>%lj)z;g!8LeM!8 ze?Hnwxi;KfJY@omfKC(<=9vIqs0w$24d5I@5hd6!%<2Pe&bV1shlkK$XXmED4Sa1n zOKCz3&5jG?0t^uzhs<5#Du=~ zr=gtsSgfpQ$5{OCHKO&Q*>uh!X6o1Ce|NF!7Q0nl8qVd~TpiC%&Bm(WXEuKwh;KHO z`0H2a@%icNv-tb7^KJXx<8|9{=fv;!Z&!=Yp>gwebv)QVE+t`Q*;dUTaC=O6u-(?# zvEms;a6?_0=FEs`mEgo9;uyjTsuGkNXIrNjxC{XH|M}|tMf~pl>H8nv1#XWJe`PK} z0h%+vAw@@e0YR3;b52;XEq#GQ>-8LObGU){yvi!~*iT1&Y9@Je2ly!CRCpUDK0@Dpd#w6Du(rP}z(9%Y` z9dy?^(C>p(a=|LYId09G0Zbtxe_+q5(;?k_843R#h4UML-H_LKznJ#HmhEAqL_g&4 z%ygXMFC$O@uH@|oV9<)dy@NlS5cO%-*`U9~xnXo(pJ1LA2}4bb;)~-D>`4{YV2Kmz z#v~=#ZCjb=uv?b<+myuyShIg^(#BAq=ji>cF^ zDOQZ@XLxrVAo<%C@as_g9iM)4jd}ZL;0@8Ms;Dwd;%!031~cb?Ku_e~|Mcd}5qPq3 zUGI>in%T?zSc<_ZQnWP&f0u-&wWwY1_PHvmf-aic7znPWtNDU{3C1Va!#05hyR3xi zSoikY=lkO2=Od>i88X*$GSD;{;Bi0xqV6(qN~XEI!GpwX1tuu|STGB}(GpY*NS1cM zqw9pf2GUfOQ~t&toN_V&FAP08QDUp|fo5+p5^pKdoYieIUQ#p{e}Vv2sdDl;`&=#z zF;+!`0t&IvyxgvTnGFF}6?ttw7zk>L=zc+HfsPumU%38XhX5fpv;qmHa`Mn`G8heV($)mFV3>_~GR8(SKased$)v|`6y~&t z*zLJ>5)3Qs{DUqSte~>>C*67MXFY*NhY;550B6x5wM-#TRvX^2YD2n8g|ro@k_>s? zVW)bt6KnfufBu%5wMXk63=F+?_k}yj#vP~RsF#bPPINqY3ZrV91!WGVGRwPDA|I(( zZ2;h(*?PnoJBRNu6fo_k8)@o|1O1!rLB001wYa=oIebRyfJw8y!ec@<>9&;;cbXxs zz9TqeW<4iE#9rah@3*+Wnz4yj*H6c1FrIY^5`SESe?QHc48CX2W%TQaU20@29*#-9 zfmvn!%CKPh4$@d?dukxJ3Ttq4e>{DA{_6a@QTOvXHab|fbFE=>4-UlvAB%FPS#yDw zek}mc`sf{nN$L*7@BoHkXAWhQwtp?h4bYG|KOyVg*1P`~P)h>@6aWAK2mk;8Azbq< zwqtbzw>=&L?HLOBI1W`~9RL6&QI|m;0w$MF_W}ujtz7GN+eVW9&!_02=bQjG2}zDK z7iO5OY|F7XzUWxWWM*WAf{3Pw2n1kUBr%H4*@xJ7+5h_}`y~5SRX5N8DJL@%p2Q-6 z?&|L9x_nh#;Dg`o)Mc@A9;Z7xy;AwQT4w3zgW+(f=gVwX=y{kVLB3Z1_h0|5UhYlQ za(!NZ#F0w$O^ip%d-~?_7ruI=E{ZT;D$`!6ldHX8mpfg@g^o@KgZDa5!nrP0wbbfT zudlNr8fR&;Hl37L>adtEcglE`CmJLd@r{mDmg?~`%Y6{4mMT<5m|p5=q>>E128FJQ zSYPP^WWx$w(x^nEG^@12?NnVa@ed98vDU?ZTE(Txmql2Da1<{Vy3lEbDWJ2oqK<>> zuvBH06*Npm{q;>AGT|gkFSwm7*Gwtb1x-;!SjbwH8Mk;i&(c)SD;+%?;A^FCDjL14 ziv`#asW`>L6MYq?bFIJ&T?B){^Mj*T-@H}cc~++p8lA71R$gR3g4Ac2d|l{qSw%^I zd_KZt$C1uU1@dK8gmGH=lYvqX)oZgmNw@}Q)I`xPsf^gfoH@^;SeJo%n<>3I*HILw z7p2<4IxAhI=(vCQiZ_q@Vb&qYZuwwTFl2;<=Nj`(=j-vJ(4Y?nCvmD}Q^4CO!Ny-< z$Ij~{#5Szraurtd<#w`*uu$i^s$^GxQ}FB@BVmq8L?qxve#2W}mPm_S6xoW|Rbelz zfG*>z9I5LtxujLEv1OMrrgm9Ez{;#H=6XgfDOH_spG=jhH~W8^y?l3gG*aa}<9X}&1H5l}R#v>Rk5rV+K|ZXo zlZA%FK0kQ>>aPdSaaF|-$7*DxcCC{5N^hTTEE;kZ)(J`C3M`;4hQ{d|%&Sn|*(|&O zFLwCWj;QsRFM=P-EIso_+bOSqNObW(Yr8s|hj|4t2mk6k3M<(lUqOo0qE6UgoY!$u zk#?Mw*IMT=P-ojolMw40tPtIn*|j>u{Njp)pJzm}-Hslp_e?no&tZV3Ux9-~Mc;+f z?{x{$v5W?%;{|g>I4}>>&oCtjKhMo$KB+n_NLGl5KeCo?fqzOU$^7zvUXdfZr7j>l zVD(PdGGeRZ3OWZH6hUxfD8CNDx`m3?*l(DTXIYWgrJAzwqe&}>5Iba$mSi`&SW zD|~~rg(caCd0fV@Qjjx$y@LG4^PPdt;}Ujgj*hFWSmO~i6MUgT4_|+GB&r;*VDB_+ zd}-zlgANhK6u_G@tUwh;uz=J?jhV%E*C7cC33EBYm?e!4mjPvHq}1Fkcg{q%euS=( z?c-qv-kG`HiNZKp{|qs^#Ge(0SmN);8mglUd<)YQgMmn(j-APWLgQeV!ve-G9FT(` zHmm3FzJELZ{@~R&FOO*(Ss2%5VNh1c2J8AFE=oAFtPnW}SGuuhY&NmQ@Auz+`|9mC zkh8tu3x7hM4^PQHL2^a^A1dD3YceEcpkSKICG19t?_><>az?t3QNVJRaJT(Lkq0+3 z{NMjkyTKO?Bl+@wNy9M@GkHFfW%*2~9g6g1877Ny5-+szkfh|I+R+4BZkD--6P?mh z=>e2wk!0k8RakA&k{9m}j$Y1QyngrYJuxy%BEv#0UlY(|g49Q~C!i(@)L2y>YB0v(j%wX_5BPFOpEFAbI7NvGfi&xH}@ij_B*6mmVo3E(zMEwh+sNdoTCn06Y^ zfPcUxvK(NNG$Zqbp0>#$4LJbefF1;}g1V@@U3Q}$J@l|Th}3N62av`rxze5=h`08A zI~zi|TWzp~8Fc92Z?nVw z%Uggj*Wu-@j5il&-qR0rG@>Nn(xn7Vz6a69x5<%d^%y^f3JLePvgfI$!P%RKJ0ClOPO#?Hm{p4ZTu>^dC_gJ~NWUQtW& zV8d&|#t~wvYYn30A|nFKgrCXTfa*my6c;vsQfpl?dBr=H>qD7qi!|UXUO{?!wB;$^hYV47?s(=*k-&JbU?W)@wWj!9VW~ zeV+t33Sj|Vr^O=9YTEkB6*Mf)0Wk~XxuOD^15B9!xmL?qu#q?>n8u6&XI-oZgV~EW zv+oX{?;iuxPgRHc-q$Pt2kUJ=ln+mTJ`6uQ{o4Bw-9EV+*3kxoNH5e(G-*cI!y{8ZAq(!S@mKU${mf8w!cs+m zsJIz1Od)-9g?rL~1fcTbCSaVz^!!N&KNK1sFJ&47=bA<_GlF%Tk#%fmGa|QtJtJ({ zP7cVm63TF=hsLOqogu3!HfBTtbrDhyKoMjHlrc`5-04KtAqHZEK(?&LW}lcWeN!G> zUs_=?6b!eZ36vAEdrLt}*7#Tm1tlUxduBH5EFh0hGK1&KcrVW2iCXaW2cXvJv>EYcdu#Cvfr%aX0YORZmzsi6CVo2pp1&xqhudBBzd^$G~?AvT1%L-0K_e@tE7)4~kq`vIF-BB^gELREhalUg4X zMONH96HJ)K{|gNAy=}~YrE9+*@wzc5=cbPc!=pIqA^sB49o6#*0G@-{_wU|sSm_g( z(cST%PanZ9K2UGsG+u$V2kAu;mrF`ULW~g-lKsEbl=G9!T(pp^*SRLE6M-oyi}d+g zU0~&klB|RRj*j0QlJhFEpEQ7S<;m`o%?M#C*jm82W($H+Feb5oU>Zt=Tdj)uOYL~KX-nY^yvkQ!sl8LPE*^E}IF;T4@ps~X>M@T3H&Rq3ZMQcB1RnYu zibBmq@7TX^FPf+~{E$+Qk@~!2z1USy+ehljLiz5IGjlA|?of;1}ZAxSGv8BFrMtb*^+fh!ztYlC$((pB||h> zWy#Q_y$KRS`;*&BuSf9qdZcQI(Vc;TU8?Df83T9Iy&qm(%&-KBb)Ee|><_`LEB(nG zYN(RRJM8f98^kN?mD@15B3D_GE!lBNvT1RLVD;=wkp!ArjMOIqb9dIDD*5KLyL$~s z3q3Y}2`+@}baF@;YpYJ}vYMqC#cRInZ`tOAbi}CR@<5tPU~ZAcm^~|0kPEvzh|jSw)hF@r2Ipo!<;!EjD{bixX;i zsvb?%UU%w_VEOG{8ZP#>^Ar1Le2%+^Tf(h>4Hi%za;9E)ftPwsBG@I-7ZuxDmey@6 z>qxdW6h>{d?(%8u^2wP1E%R3m{)q{Ex~-kUVB?7^iksqxL2@H;d}#A)c9}HUCWB$k*CXD6QZG=9Jg73S=D)>#pm#XPaQck{n@C=H*ktF%>iF~&i>N`%ljSoR-L5S zps|*`tiCoHnYP>3s4g*!sQ7)|CDk#1tv1z#@Ijo`-6LlTYo>gr0Dybp?NPAhx}b62 zxKS>U{Fb-ves@B&SkV@-!DeRLtuOC&VwY+hrIpc<+T9eSNT=C~jWI_5c8>zWJ1t*vIY`f zF2fXYIu)%XYoAFq3SMP1I%;WuVON{+b7p)yl;f4*M7c*Yuo)t(!-Z5q67TA9rrF;Hyg<#+nD z;M;VvqA(&n-D%1O&|2zSvSt&eY$okHP}16C%G$K+Z1mLc3v7JzTgBB<3Z!1`ypu$} zHH`Ns@C62pq!Mc?Zi|n9!2tw^-y9r+6MT;e&*~x(FyPyagn<#$sd}>eWJ493bS=|> zl+G_bcSLy8p{c;ZRAB)@`)=6DOHi{Q0%VG`rzve%PSJ(KkM_WVeDo}e3um!17s^8q zhb~_AhJ+q=pe$^MJ-IK>1D!@DsnRsy=a0AtQ#6bc);4V5yBleLaBD(ONBDm;$@(Mc zkiNsEhEeGe4c|?b^0WUudiVA@FMoT|)FF;2j88zPg5!S_k){&}QiGFIpN@n(kJtp* zhmzW$)^se%Mp^MWhNWwegYL5F)EopcDeieP8SkBL?Y_0ESqP6jvV`o;QkkHa7kUxj z2tcF#G@IM)L7UQl$^rKiZVoXf;p5qWJQy#;#u2@n+l#w`-;LKttciJ)dXy)c--BTEU zGhz55gt!|Ae&AL?d7RFw3r@aql`YB9W*L}gkE=(v3`xp=)zICLO(bRZW7a*H@0`jL zB*??EYAe)ssFnu)+Bp-sI2Clvl#R1Cx5aN}i}SV&lm&ydT!h18 zdFW+4|9MNiQKj;0&2lrc@!a=X=b>BX!V54~uw%#iRD%}D^vzqxzIodW+(><^*XG;t zI+vT9(&{dM!Q|1yD{a_IwxVg!%aj=Q^3YMe%1goq7FYru>$IfutFWBM@sxGKhyJnq z8P?Te{3pYKhEW@Pb@<({+N%+r6Ngh~^wvB?^Pxjc$kOz7_>9Ua(`rnN0jwN`IgH1g z5%vxjA>G}-q5a(?el4sk3Z868{%X120@(9TtuBIZbRRkm|0bS>xlW@@|xbRZBW)Mu^on%CnL{gQhi z04NiG9P>P4^P0l-GD}R!whAx8IBm~q2~PJ_laNWX64y*QaRA{;${%x}*hFfa1Z?|S z_(>F97iCovf_@K419c2xb~^0;?jz;I^ZbTID&#tWi+%TEb2Y+=fHK%E5ue?M9Mi}- zOy>;dQG4{#9J|Q5jHs$>89LySkvGY|Q`YW()|lLvF2|NVg|3hFVnJnvaXK$F9nXge zDA8#!Ga*zZc0G|-Sd_Pl8NPDSd#C0dj9{wWqI*+w4{3kPl}B7xSIP;lb2_Og^G6nT z{1vT}n0gH#oy+?yR&2xQDlRkVWRlGmIac1w&wUQasg}Gh_!SB>htiaP z($ACZ{G49_(T0l*-Nl}05Oq#VJ08Ly#~w7=UIs*wDZPW#sl+C?pNCY+Dh2*#WC0NE zFiw?cvx@n;dCu$ZKQ%_07}HNSu|{^>_SVmXhLd4rZ50<8XT8@6w#xN3dQy^1ve%J$ zN6xv}#O<_2$c>vl;6Idvfr-VvNn7oI`t@ zL=fQDc^hqFL_oBN-Wp)}^KLQPoeyV7$a_kK!DR(cR`|ae#*&x5BF~|hUAbo6qFK6x2 z3SlSRbS)MwfrB@Tcgv){h!<pNW1$8T%V zD?)++L?M{mSW=%H?UKzfI_L3!!;!j*L-;dDXHrMjG0U3pZ3V{xc+prg?la3M|3i%O z9M%~qs5ym1iFzhtT}(;k+3s<*oFec60vE-b<8%`pJtg~5Eywbz3E&Z!&#$Bq>)74J z{O*zSz945z&k+$Be9)TiWNoN=eRSjC6he0AWTl7)2r;-Mg5IG6!ZQ1R26s}Yl9L4_ z3=Pp{BTea(-5;)3qZp32G?SM=1;m+RB;GtYXDNqFvvf=>Gh{^|O4FTRb}=uC85xkA zvb=(z=xrmiYEc|XBxreL6CloJkUOR_cu8JgXwM3}zu}}Tm98r|$HZG>PPVyhfxqFc zXS=o;K6=$-)=+GVl_*bt(x%!iV&c5R*5Mg1Nfl4o9?A93bu6mdL{#+Idaz@{q3uOT z-2ueHlt$87-2)i^S)=?W#Dh(38@SfM>z(S;$GodKZM4ie%-IWi$2nHKrmA_x({o=^ zpdvro=dk-<1n_f?5I)#U9Mb#DMu z<|bp*-9qVminh1XEqin&kFmA>`d8R6n-A-Au=Ia;@Zdv#`RItBgOR`Yf`?!G-q%yU z|MQ1`5PWSj^KEL4_ENrXFtImtg5gD8@zn3>aWZ*gOZ2;eI0GQ~iX>$QNme~cNrCxb zgpji)J^DZm{nL}lA5QO|^zcJE3@B;Kc&rmZqpeMQvxvLu z!xnua^%C8GJQ4%2`|2$mJT6X890{x<*KRqU?_Q7RR5-=yA|s*etGKHw=7os`*7@MU z1A7Y9>^Fmvt+@*B_=tMqLa?Q}m1PHy7l?c;4O;F!z3@og;vd^u!mIW@2JF=;FI&72 zFL+IIfWzAC$eT2_CV9cnC+e0uoP0(XpPky{wGJJBtF)gW)x5}HsF~|~2pVnu1U>U2 zj3p2juy+`taNNAvzaZ=U72yQm~xePF^>oxIZxtPxa_|Z%JdAS$$gbst*&%)Q2k_%3;dn>fzDee zO-Gv`Z+qDVO{ue#o_#d?Hsybts-0=4$jxMbWthtc_=gWTY-(mB%VHBOPSJavhF4*n zaAMu0Sia;Ru%LL2e!_y@7?EG9Fu%?+>>n^}=40qC1-5RuD*S9JliUg*vv@%#^jdE( zw#jYlnbX>>4>IXJdlsQ7T@)s9SdPPMO7?N!CqJ#=->+>B`x7R#hqQl_?(WGCCRuS?(C-k@OgRPO^1;L;yc=z#>Z*XT!}lIDZXk%^;kzR` z%vua@xgni1-*tccWan$PA=6v;R|JB8k2MhSEKlmv`{-Mn?&w>4J_%H_8HVs{rChDi zKR~2->!ht#+aAFzC>>L@U*XSIf#`o&K&)R&w^OALi^(EA zHSZJH+x;x<_Vlck4bF{!N&H*Gc*nZ?OC0nvi7Q+e&JQyE>z+z1ef#4_({6TuQGU-U zHd8Ou zzpFN8wobmq|0feGW;81pw+|n7F{l90@+3dK?>CstXUM(<;b1|UFJ^^jBsFUE9+y*6 zMqrsUB>gpV@>4ROpJ3N-$pM*v%D>Hk-uh_~bWk~bF z8Ba=egG07h==_n2H)WqF+?H>@a2UTiKUwE+VitVYkvKSUr_=zm*;-++Q4(n0<*_JW zI#m`3+K1bl94vl!TdU&@~n@C@MezCt1gH2Jlftv}~rmcWB`J02h7V)w_ z>m= zIFhJ90J%GeVinHxNAwr=mvndU4g@ILZd!eiAYgZIU!UE*7rzIjzQF>e_RWbC7`9&Sy>Dn5k5j9DpM;N156%X z#wX0Q$&^f|z21j16>noINPLY((FVh+&q%y55P26kLS7e1YEDANx%oegD zm5&3clzhnK#?lRXC>lgn<(bsh&?J$SktzBC-#lc3-xj4>qiG+V7e7KJ%!+-eOZ4b0`DR1$KbmU5(Jp)->`Nb6Z0 zfEEjhYAZA?ihI5D%O5ZAE^lw(_AOk!{{Hs;xCiiCxUhaCFX8#ux91mELybX#kON7l zU0q$nO6X^9jVOFRw3R|9CKN^#3-ISVJV_{pc~LItV^wOAQ>abC#`-YK?%w=xe{uaf zW@R`VbfU7#kT!H+Dvea@tjIJcW|b9k3=bmDQeh+vzl9_hm>-KRgoWIU>WbcvvSMC7 z!UKz7ecCaMh|PtO9w~g3w`ANRQ|lORO1Qa#8i|!Q7-3R4N8f5#NhJpqw9NE1OGaNC zPQY@p$Wc3V^?_~VN(q%zom_2|f5k==hKoW;ETXwo5yj?-#RsT&E`u>WeqYYv4%2uQ z4B+tR1v39!Q$bp`do-1~Ai$G#SsdZ%u%Wp*RvHBCJ7hIj!B*D1Ot((fJCzklR$(q< zWxh_<@)Y7Y_Of4KeARtgZVDhCG`ylj5qV?Ev&^fXNN(a+Jf6ZQi=W{L) zOKs1e1PNAJVV?7J*iPBGlBvgE6u%TsO#kts|O|Tdc8MSmk?5GqL$_HCks&MM$7GQdTjwy&m_5f7xtN69ms@kgcmy zA;uO6zzk@**K_amR$Fqy={%cz+IRSGuZLe_`VeD_kjjK7hzLf7^f5&FS&q`9SV>*x z4>F99R-rGV$V(?Hk2|d>e->l7_`}KX+O#ur$*C>ALu}y;J9RB$pA^{1a_nc6bc?(!6=fY~B~~kng>Vm**!b!^ z#agD)Hk7sWjpar$1(_swR77V?(4xtdKP^Z)HgROZo=~r)f9kjf@bhFES)=JETTpxE z@K*6sn$Y1fB3BSaF}}~LutiTl_S_h=6t!JlbzD?S8)gqJ2rDVD2#82YcXvuCNVl{! zNGmJSCC#B*l!QmSieJ31Pd3=N@L=hoO!Fr}7hL=JACcE3Jo0Wa>dJ3K|j5r`KZVBiFb(93BL?c~J;2 z+3RlWy!d{!Ua#*Mx7Q1Ut%P|qw@;Ii9!!DDgPdC=*#<)z;nBm7c0*~4?$ zr;q#54p6VZq&H`uBZ0Cz{a z9>v{7cXwy*JVa{7KzCzERGhJJaWOxa#xJ(B;VNXs=Vjyo->l9D{hK1ccSSU4E$^aY z`4Yt!VLm=elV*?AP7K46NDspibGtK8XE@v8KRSpkkV*XPG)>ZG=P5*Q>bxqj_?dCN z7~Y@lnX3D~mR^SSAMH9+)$xb9oNO;a;=ixN=4NIdMfxHo`iB&CIEYyATmXA-%3I5e_VrJ_ixM5DZV$t7+w zhIeDqYk8WfT-x@1(Z-JjY<##q_80vCmQ!aV*wT2K|;DD)vOGa?DXxwo@;JSSW!T*`gimNy!8 zNtn*hB(DzA70xXqopx%mSB{dqtyeyFwrLK%^eNN9M_B`2rY+~S>~WZ% z_jNB@aZ%bs03`V7u*-U`~xZd;QFBfqo`CR9B<^et?(sssPOcYgPg_oO09P ze8&;~vFG+Rwp}k-bZL~})?{H{?ly4HRhq|$WocK3)Fsi+=lzmWGa(jF#6M5{4nG~2 zzV>vbyw~j?QC##v<(J)&xCR>7GFjTTzusD-%$zv-K@{PUx?qHDyx}E$>~@Mu89oM zu(<^8miSH>^So;`z_Gw`w)CO-631(eJs66oKUi_f^h$bFd#TQ1kYEFj^zWXfIWamb zKB8wicmGe%61+YI?!aR;Z)~Tf@vhU9*-W+L>jNvcPNq`v(AT(kKL$L)`EiWnw}h|0 z-1SnyLCcX|gBGJEpoX@dw${!C{Q8JY3bi$(rmp;GDJjA3c)5Hjr|83)mcx?}u2`~$ z7i}7;dzb85d0I?c{HjvAw(E#9X;?NsK00NHl9E5lk#*vuQImzsOQz!Mk3E_aius%u z71YPWrkB=k&`!&f>iRre%b`0d(Y;VAquiM`CQ&;lo6ypM)-ofJf#E2p-I1OlgT}N? zn?~H&N>MCduE@5*RRkVCa9;R?vq3#VKkk{S_)~fYAy7PeGF@Jn!7&eC?Xb{ezbGmM z)^#}f2EVK!^&Kj_GDk(d&>tVKLSa=)!`D&Hk zKRZ)sqYRP?`YHyYv?xqv^9%B72iKPhEQ;F~Rw?Yq7gw8beuEWQ|>wKnLx6Kq|O!f;Z}X^&rc(8>^oU%D$>sM`0$}(L9u#6Cz^XJ0(`_rboVMN zUM}oR>V8a8g2&vv8EncxZo1E?>Wu5k#GtkwJ1`db$WA4paUuVLu|V_NeqyQp1P#?p z_&Q?et)%tk>Gk6}`ZkA|S7+Hiqa5NWNCz>yU}zZOJdJ+ESrf{1$}j=;2v{vpFw|-mz(p!D$LG82F`;%#4svKt8Ly?N5>_OQ!3E! ziRq@MD0Xx=R5S4LI6-_(wM`g0QNTv`G#y-GJnKu-cL z$kGpIlZLZeyew2H#f;DbLr^=ctXG*NnJU{q7TW%A?cT`xgQF*y)5nxRU%FXslw3X|94Z-@V3YQRh$UZekcIeko6E zke2K*H`+Ejstnqh@3E|72e$?~VTy^Z+-wZMU=gBFRenP*jcrODYN%L`aZoX#+X}`}+E{9a6tcH-*Crr^jd|c)`X`H|lS9sldg{aZYop>N z6!C-4VLf{+UiFd~Qj5XfM)>bnGgqKR(k0R0<9W{Nrud_m3tkm9@A)K#rr1lXj<4bH zfm7r0IxL;9Sfrd;b6YdPNcWW>U^yCVjs!^wEZvXrwWc%WV(-^a(+`+?Dhpq|HL&vj z)-7ROV|7}&O}d~tEV7)Vj+Q+4Q|ykyj#mNb-|*6ghc_!j-zIG{W-rk{NU*^)rv=4| zWx{sa_pspFXVBJe0;~En!#)YYPMOL_c%GF0=@%9SWBx3^ujDRKdKJ!yf&a%)x@XjG zCo6rBCfN%g42uo%ajNht>7HGT2E`Tey7rauSAE|)lqEBQLv>bJ&Lr*TBv}lkbxktY z3a+r|aQ*cPJ?i+n!zMYRH#(FGFDB>>?Ko!-S=mm$N9N-AM9EVKIG2_nIgwtvz^B5H zpbl6^#l_VH{XaP`HqBg}mT(!azSXoPt{4eh%7<4q?DzU z-NKBrAHV$J=K!OaF{kkkGvg=wYdDUBd0>jed@LDZT|Jg#)^Y2Pt1YPcJ~~ z_BvA+|77xyakLM1nsX^Wm_Xl>;8J%N@i@$qTMLHS_^=VS;Z-r+RBz?`v3=pOT#k4| zTw_{_*^{&1WSd$1qw0Q%iB?TY9W#R%`Nbu=j4SW%7S2@Rw)b0>Jv}1Id!}$V!~Jmw z<)w^B_v?bGbL(6^);aQR-_JR+arX;Ei*c8K@jd8qO-b7*K89^CpjM$}WfZ5Z$*591 zC0FN6624r0`=p()I}GzlY1HbVLNe2@4neywp4$}AMEpHUOnkLRim0{;);MD?EH_Y` z&ZctQ`t>6oPN!&bSMOm-bQ!*bCmfuyZu=_V&!D)N=falf(CdD4Z$JK;UsFnof?HQPIGw#ogZldb?1 z95yB`$!8uopi}!{65H1$jb2qTdB~{oaq&dVpsmRB!XE!UGc5yZmi6(&3pkE$CT<31 zv$M|Qn&V3tvcc>*a$)4yT2@7lA+r^EXWRRh;6XgLp;+1Da+iv-IoG2y^8V&hdTbO6 z^`rEdhrDEsLabz`qgxk45?xeJ=LNh5K(mHc!8cZLR{8--_w@?Z(w&HJOh52~JbOft zD{;E<;ciyJp32g8wX&dYHk^WY(ImEtt!IIlmpE(@yDKSimtvP^;2m*!W+ZwDO!h;` z7Nt)F`Ys;+W|2YdyMRT}yVWft)TC9j{E@C&kr#`;T~-F>{NQzO zwyN{tg4o#|g&JWeU}n6^-0^}_ZK$}ec=p1Foo&z+{j3&j@2%8ZgqyElr2D=olvIB$ z(94rEdxUmOn0qi$TM(Z=-xm*77_r(#7!^Rq=OVM3k*SEZmHXG9N@U|pf3KdOxF5E- z%AP?(!V@=jLLTvu(b`q;RfujqN5Td2=9pJ19)F*2)k(Z6>JyAAk1TEv{l^?=+B zh0~xpfS_z)%lzx6$H|h-AC6?5?Iu0P^*?+IeLZp=6&|@_ zB)Y4CE}BgPjmz_q>M7IqE`UF|n%~2k{nb#1=DYGvVt=`#3b#Wf@zb&hwaB%ve#hpF zk^DrFSi}UGE55c9CRcTvA#pyxMdkYUwj0R5yOZngg3P-ZNJyE4w<}HHuN^Io%uNwP zlzy5!zc)|))&D7eg@`pF@O3gNQ~C?cIYJ2ltFAWHA3$)j@w5}$P-Wp_=vkz7- zyFM%;+D1%I3y<{+@zc2nDhHj9In`MM&T;Mo{uR^#`b3fM83G4dfi;D zVZ|K^YGCW^Tz7mKX~yuodO=4MA`l012N!e*STKEB|+#=gYMvvXR|q@E8Rko2~4gJ zSSUZrD1^`oJ*15?Wp8{&rTVebG$CjP1;em~N-dYSo~y9?T-`gOL#BX^t4>e!D6mHf zhqLssYtt0lWr;gNKe!+DX}9<|tZ(5JxsRASOXe4PP1WT<6m2+1>`y^hDT5uIS=Gx0 zMYN8j&p!Npx_Ycq7&*TT_KDwK5Rg-lbt`r!2BG`*sCzSKeQ_A~He28BR@o zBXr(lNG-@|X_3iBQ*(cU;V3eeQA|N0_!9$|J3@uw-spm2WIr~g zx_xytc|Hkdg?GY1*F9xn@=EoWrcx?yeYS7dL*T|eN*%FEWW`yV-|c;lKiW^dAMumM zrKi)NcVQI{q~CKRAb%p4b63hBij}Xpi}V8%lsGwpuHVV{YhIZ}n$@6iu3worLy>uH zy$wzB(&!N@*{~$GORzRNi!{M8L=lZwZIzcJR4j}g4i$q3R&uSA`4_%cu}f0*lAO72 z@7P}6Z+qH*X@7CDorXCy;>m4vjnj8N*HNApwS=2i$JHWwB*fzG`r7N@Xf1kqDPS1C zAYUpu?&uDy&(zI$tOwDefqg6!uEsqtgD>;YtR)^!LXo-)aXa{-ME?;xTV+sFj{mzF zfycp0x{Ao%d9O$Ml&C>v$YR#W4+?dx0+Wb}ZVxxilJL z^A4G>zHH|pih$0LK9iV13HPv>^mqvKx-+KbGyY*VrZBnG7c^(wYy6bTt!8parvn}b zF~RcX#e#ONa(duT+vm1vQpm3udi3;@G%}rfDWbXI=n=N10K7uZPi#Ui#dy3)g6-YM z^7PpUMH*eXomNLwSwU-M7Lw@Y#f@?@tV_PAL|zpwct>YAS~+)cnps0TAy)V1OJr$( z5%@JA%eMzcno30?n%ddH)B`qgDRqeBXJua2;S`~%!~&?4Nz+^xSW$PEFm%bm^sX9~ zq?Qq!s>X1qOS4H$+?S$!x~vCsxhHGX+{)w_Hu{uGg7c1$TtZ~IqVLF$YH2$1Gm#YC zGj5?a-1<)Ep%M3id!ubonCZuoPG9myUKlRZuwPKq#%%Vzq&`Uxies_RiIHmODU0T~ zS$9T}E1z{?E=$kkAObE<=rTXofz|UBK|(KhKqZ$vZQfH=Oa|rAbsTE`dO5@cAA;QMr_fk}#OCK3%J9b~nV`_Z-jfp^asoC|ya6h>mL2 z!hhcbU$R^0|4A~$LG|Ws;0x-oS)H>aj##HzaLkzBSptI_L&mF*Z*8<~&L z1*g7LSEg^Va!?H~3vK&XRag4DXFUEyHE>~GoU5?Stu3;$iY;%M?Z?5o%GH)Pl{+>J ztoiS`svL@S!=_eubvhw!Yg=(j4dW4C%ZTsNTA2(^bjub?n9F?X*e>}zLeQa&25k*Siu%Cx7fc^31&0!{fecP^am z=V(I#l;kDqW^};gkw^3?MsFv~8FCt@IvASpke zSFw-xePqfQ*1#txnP-$EyObm&61Y zl;+iYudVC2bpmYW6{=C$Q3=U)OFSs}ZQVWkXWA_Fa(Q9U*w_XVC*y)j%}u<(GAn`f zf&?7}D6eJ(5T7of8y;*&8qB&4ab;9+!}j>buXlKyLY z=6-9h@(j$Z{XF<;7(HrY*%FgXre(Wc>D>)_wU0YcY>K*y6nTFJ;S6? z0#kqri+})d3ctml7Xc2SC-@d$Edsm%Z}=@%`+?B&SO2B?0dN7RjR;H&qAda30A?Qo z(?=`zK|$9gfB{e)_-%tdO8^hBJamhhml4=!U z+@?`(VuFx$M4H<6t(~w<4BGs*!}k8RvBAD|fCU&hyv<9uf$-`5F*pc7=)kZIfDfnw zzGxVHhYL5M-BO+X#cK)IzsN)`wfn6L@(1Ls8l!~EPt_#j66KaAoQAOh?& z|Cb>F>$ebo^ojkK!2&VG5x1wI z5TgGt0t5W8gNS*F!T+&4JBU_LWAukr-9^ZJjsGDYf)mCNQgD9vw__&%utK209)iFz zyCpzTGYH>rC!yfh9zY5hnE#`D2#W3_yag@(A?|@i`-pa#Z~2FyJOEUH$yfg(@W7%2 zKmveT|6$1v5sr%6{3gi3cZY~r4!-`&z;uQ{!IeV*N=8C0h&{)Pgv8Q}g!J&AwvM=F zL%2Zb5ugmH)`CVyfHsiW^!rK^*8)KU*N*@w?9TxWNJ#Ym%1PGuTl(<#kqwxj$T6bU zuSWl3_KyKHkZ=-$3s#&Xs(vvCfnxuYH5=kL{a+O~09*RYkpGEzZck?>c8-Xe zV+6Qxj`$v6$p8*$hWJ4PEpvhY4ftNlZ-F+l$LGJZQB(kTe=8Y~sT%>Xpc8+`u;LP6LaQ1E5E1zgG5a5r diff --git a/echo-memory.plugin.src/.claude-plugin/plugin.json b/echo-memory.plugin.src/.claude-plugin/plugin.json index 5f57621..840f542 100644 --- a/echo-memory.plugin.src/.claude-plugin/plugin.json +++ b/echo-memory.plugin.src/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "echo-memory", - "version": "2.1.0", + "version": "2.1.1", "homepage": "https://git.alwisp.com/jason/echo", "repository": "https://git.alwisp.com/jason/echo", "description": "Persistent memory via the ECHO Obsidian vault over the Obsidian Local REST API. Cross-platform Python client: one-call capture/resolve/recall/link/triage over an entity index, hybrid BM25 + graph recall spanning entities + sessions/journal (recency/status-aware), a pre-write duplicate gate, complete-frontmatter capture, session hooks that self-fire load/reflect, offline write-ahead queue, lock-guarded concurrency, linter-enforced routing, and /echo-* commands.", diff --git a/echo-memory.plugin.src/skills/echo-memory/scripts/echo.py b/echo-memory.plugin.src/skills/echo-memory/scripts/echo.py index 30eed46..1898dd0 100644 --- a/echo-memory.plugin.src/skills/echo-memory/scripts/echo.py +++ b/echo-memory.plugin.src/skills/echo-memory/scripts/echo.py @@ -549,59 +549,76 @@ def extract_heading(markdown: str, heading: str) -> str: return "\n".join(out).strip() -def cmd_scope(subcommand: str, text: str | None = None, as_json: bool = False) -> int: +def scope_show_op() -> dict: + """Core scope-show (Phase 0): active scope + freshness + sessions-since, as data.""" path = "_agent/context/current-context.md" status, body = request("GET", vault_url(path)) - check(status, body, f"scope {subcommand}") + check(status, body, "scope show") current = body.decode(errors="replace") + scope_text = extract_heading(current, "Scope") + scope_updated = "" + for line in current.splitlines(): + if line.startswith("scope_updated:"): + scope_updated = line.split(":", 1)[1].strip().strip('"').strip("'") + break + sessions_since = None + # Count session logs dated after scope_updated (the drift signal). + if scope_updated: + status, body = request("GET", vault_url("_agent/sessions/")) + if status == 200: + try: + files = json.loads(body).get("files", []) + sessions_since = sum(1 for f in files if f.endswith(".md") and f[:10] > scope_updated) + except json.JSONDecodeError: + pass + return {"ok": True, "action": "scope-show", "scope": scope_text, + "scope_updated": scope_updated or None, "sessions_since": sessions_since} - if subcommand == "show": - scope_text = extract_heading(current, "Scope") - scope_updated = "" - for line in current.splitlines(): - if line.startswith("scope_updated:"): - scope_updated = line.split(":", 1)[1].strip().strip('"').strip("'") - break - sessions_since = None - # Count session logs dated after scope_updated (the drift signal). - if scope_updated: - status, body = request("GET", vault_url("_agent/sessions/")) - if status == 200: - try: - files = json.loads(body).get("files", []) - sessions_since = sum(1 for f in files if f.endswith(".md") and f[:10] > scope_updated) - except json.JSONDecodeError: - pass - if as_json: - print(json.dumps({"ok": True, "action": "scope-show", "scope": scope_text, - "scope_updated": scope_updated or None, - "sessions_since": sessions_since}, ensure_ascii=False)) - return 0 - print("-- Active scope --") - print(scope_text) - print(f"scope_updated: {scope_updated or ''}") - if sessions_since is not None: - print(f"sessions logged since: {sessions_since}") - return 0 - if subcommand != "set": - raise EchoError("scope: use 'show' or 'set \"\"'", 2) +def scope_set_op(text: str) -> dict: + """Core scope-set (Phase 0): atomic switch (history + replace + stamp). Chatter + from the underlying PATCHes routes to stderr; returns the switch envelope.""" + import contextlib if not text: raise EchoError("scope set needs the new scope text", 2) + path = "_agent/context/current-context.md" + status, body = request("GET", vault_url(path)) + check(status, body, "scope set") + current = body.decode(errors="replace") prior = extract_heading(current, "Scope").replace("\n", " ").strip()[:140] or "(prior scope)" - cmd_patch(path, "prepend", "heading", "Current Context::Scope History", - temp_file(f"- {today()}: {prior}\n".encode())) - cmd_patch(path, "replace", "heading", "Current Context::Scope", - temp_file(f"{text}\n".encode())) - try: - cmd_patch(path, "replace", "frontmatter", "scope_updated", - temp_file(json.dumps(today()).encode())) - except EchoError as exc: - raise EchoError( - "scope set: body switched, but scope_updated frontmatter is missing " - f"(run bootstrap.py to add it) [{exc}]" - ) - print(f"ok: scope switched (prior archived to Scope History; scope_updated={today()})") + with contextlib.redirect_stdout(sys.stderr): + cmd_patch(path, "prepend", "heading", "Current Context::Scope History", + temp_file(f"- {today()}: {prior}\n".encode())) + cmd_patch(path, "replace", "heading", "Current Context::Scope", + temp_file(f"{text}\n".encode())) + try: + cmd_patch(path, "replace", "frontmatter", "scope_updated", + temp_file(json.dumps(today()).encode())) + except EchoError as exc: + raise EchoError( + "scope set: body switched, but scope_updated frontmatter is missing " + f"(run bootstrap.py to add it) [{exc}]" + ) + return {"ok": True, "action": "scope-set", "scope": text, "prior": prior, + "scope_updated": today()} + + +def cmd_scope(subcommand: str, text: str | None = None, as_json: bool = False) -> int: + if subcommand == "show": + env = scope_show_op() + if as_json: + print(json.dumps(env, ensure_ascii=False)) + return 0 + print("-- Active scope --") + print(env["scope"]) + print(f"scope_updated: {env['scope_updated'] or ''}") + if env["sessions_since"] is not None: + print(f"sessions logged since: {env['sessions_since']}") + return 0 + if subcommand != "set": + raise EchoError("scope: use 'show' or 'set \"\"'", 2) + env = scope_set_op(text or "") + print(f"ok: scope switched (prior archived to Scope History; scope_updated={env['scope_updated']})") return 0 @@ -730,51 +747,22 @@ def _render_brief(texts: dict[str, str | None], listing_files: list[str], return out -def cmd_load(brief: bool = False) -> int: - """Cold-start orientation: the canonical 6 reads in one call (fetched in parallel). - 404s on today's daily note and the inbox are normal (printed as absent, not errors). - `brief` renders the token-budgeted digest the SessionStart hook injects; the default - full mode (and /echo-load) prints the raw sections unchanged.""" - if not echo_config.is_configured(_CFG): - print("echo: NOT CONFIGURED — this machine has no usable ECHO key file yet.") - print(f"Expected at: {echo_config.config_path()}") - print("ACTION: ask the operator for their echo-memory config file (it holds the") - print(" vault owner, endpoint, and API key), then install it with ONE of:") - print(" python3 echo.py config import ") - print(" python3 echo.py config set --owner \"…\" --endpoint \"https://…\" --key \"…\"") - print("Then re-run load. (Memory is unavailable until configured.)") - return 78 # distinct: configuration required - targets = [ - ("marker", "_agent/echo-vault.md"), - ("preferences", "_agent/memory/semantic/operator-preferences.md"), - ("context", "_agent/context/current-context.md"), - ("heartbeat", "_agent/heartbeat/last-session.md"), - ("today", f"journal/daily/{today()}.md"), - ("inbox", "inbox/captures/inbox.md"), - ] - import echo_queue - # H2: sync any writes queued during a prior outage, best-effort and quiet on empty. - try: - synced = echo_queue.flush() - if synced: - print(f"(synced {synced} write(s) queued during a prior offline session)\n") - flagged = echo_queue.needs_attention() - if flagged: - print(f"NOTE: {len(flagged)} queued write(s) need attention (e.g. a capture " - "stopped at the duplicate gate on replay) — resolve with capture " - "--merge-into/--force; `echo.py flush` re-lists them.\n") - except Exception as exc: # noqa: BLE001 — never let queue upkeep block a load - print(f"(queue flush skipped: {exc})\n", file=sys.stderr) +LOAD_TARGETS = [ + ("marker", "_agent/echo-vault.md"), + ("preferences", "_agent/memory/semantic/operator-preferences.md"), + ("context", "_agent/context/current-context.md"), + ("heartbeat", "_agent/heartbeat/last-session.md"), +] - # All orientation reads (+ the sessions listing) fetched in parallel over the - # warm connection pool — the listing feeds both the brief digest's scope-freshness - # count and the heartbeat-absent fallback, so it's no longer a second round-trip. + +def _load_gather(targets): + """Fetch the orientation reads (+ the sessions listing) in parallel and resolve + each to text via status/cache. Shared by cmd_load and load_op. Returns + (results, texts, listing_files, offline, marker_missing, heartbeat_absent).""" + import echo_queue listing_path = "_agent/sessions/" results = _fetch_statuses([p for _, p in targets] + [listing_path]) - - marker_missing = False - heartbeat_absent = False - offline = False + marker_missing = heartbeat_absent = offline = False texts: dict[str, str | None] = {} for label, path in targets: status, body = results[path] @@ -792,7 +780,6 @@ def cmd_load(brief: bool = False) -> int: marker_missing = True if label == "heartbeat": heartbeat_absent = True - lst_status, lst_body = results[listing_path] listing_files: list[str] = [] if lst_status == 200: @@ -800,15 +787,84 @@ def cmd_load(brief: bool = False) -> int: listing_files = list(json.loads(lst_body).get("files", [])) except json.JSONDecodeError: pass + return results, texts, listing_files, offline, marker_missing, heartbeat_absent + + +def _fetch_pointed_session(texts: dict) -> None: + """Follow the heartbeat pointer and stash the pointed session log for the digest.""" + hb = texts.get("heartbeat") + if hb and hb.strip(): + sess_path = hb.strip().splitlines()[0].split(" @ ", 1)[0].strip() + if sess_path: + st2, b2 = request("GET", vault_url(sess_path)) + if st2 == 200: + texts["_pointed_session"] = b2.decode(errors="replace") + + +def load_op(brief: bool = True) -> dict: + """Core load (Phase 0): the orientation reads as data — sections keyed by label, + plus offline/bootstrap flags, queue counts, recent sessions, and (with brief) the + rendered digest. Raises EchoError(78) when the machine is not configured.""" + if not echo_config.is_configured(_CFG): + raise EchoError("NOT CONFIGURED — no usable ECHO key file on this machine " + f"(expected at {echo_config.config_path()})", 78) + import echo_queue + synced = flagged = 0 + try: + synced = echo_queue.flush() + flagged = len(echo_queue.needs_attention()) + except Exception: # noqa: BLE001 — queue upkeep must never block a load + pass + targets = LOAD_TARGETS + [("today", f"journal/daily/{today()}.md"), + ("inbox", "inbox/captures/inbox.md")] + _, texts, listing_files, offline, marker_missing, _ = _load_gather(targets) + data = {"ok": True, "action": "load", "offline": offline, + "marker_missing": marker_missing, "synced": synced, + "needs_attention": flagged, + "sections": {k: v for k, v in texts.items() if not k.startswith("_")}, + "recent_sessions": sorted((f for f in listing_files if f.endswith(".md")), + reverse=True)[:5]} + if brief: + _fetch_pointed_session(texts) + data["brief"] = _render_brief(texts, listing_files, offline) + return data + + +def cmd_load(brief: bool = False) -> int: + """Cold-start orientation: the canonical 6 reads in one call (fetched in parallel). + 404s on today's daily note and the inbox are normal (printed as absent, not errors). + `brief` renders the token-budgeted digest the SessionStart hook injects; the default + full mode (and /echo-load) prints the raw sections unchanged.""" + if not echo_config.is_configured(_CFG): + print("echo: NOT CONFIGURED — this machine has no usable ECHO key file yet.") + print(f"Expected at: {echo_config.config_path()}") + print("ACTION: ask the operator for their echo-memory config file (it holds the") + print(" vault owner, endpoint, and API key), then install it with ONE of:") + print(" python3 echo.py config import ") + print(" python3 echo.py config set --owner \"…\" --endpoint \"https://…\" --key \"…\"") + print("Then re-run load. (Memory is unavailable until configured.)") + return 78 # distinct: configuration required + targets = LOAD_TARGETS + [("today", f"journal/daily/{today()}.md"), + ("inbox", "inbox/captures/inbox.md")] + import echo_queue + # H2: sync any writes queued during a prior outage, best-effort and quiet on empty. + try: + synced = echo_queue.flush() + if synced: + print(f"(synced {synced} write(s) queued during a prior offline session)\n") + flagged = echo_queue.needs_attention() + if flagged: + print(f"NOTE: {len(flagged)} queued write(s) need attention (e.g. a capture " + "stopped at the duplicate gate on replay) — resolve with capture " + "--merge-into/--force; `echo.py flush` re-lists them.\n") + except Exception as exc: # noqa: BLE001 — never let queue upkeep block a load + print(f"(queue flush skipped: {exc})\n", file=sys.stderr) + + results, texts, listing_files, offline, marker_missing, heartbeat_absent = \ + _load_gather(targets) if brief: - hb = texts.get("heartbeat") - if hb and hb.strip(): # follow-up: the pointed session log's key sections - sess_path = hb.strip().splitlines()[0].split(" @ ", 1)[0].strip() - if sess_path: - st2, b2 = request("GET", vault_url(sess_path)) - if st2 == 200: - texts["_pointed_session"] = b2.decode(errors="replace") + _fetch_pointed_session(texts) print(_render_brief(texts, listing_files, offline)) return 0 diff --git a/echo-memory.plugin.src/skills/echo-memory/scripts/echo_doctor.py b/echo-memory.plugin.src/skills/echo-memory/scripts/echo_doctor.py index 521b1a8..e308768 100644 --- a/echo-memory.plugin.src/skills/echo-memory/scripts/echo_doctor.py +++ b/echo-memory.plugin.src/skills/echo-memory/scripts/echo_doctor.py @@ -22,17 +22,18 @@ import echo # noqa: E402 MIN_PY = (3, 9) -def run() -> int: - reds = 0 +def run_op() -> dict: + """Core doctor (Phase 0): the readiness checks as data — {checks: [{ok, label, + detail}], endpoint, fatal, ok}. `fatal` names an early-exit condition (not + configured / unreachable) after which later checks were skipped.""" + checks: list[dict] = [] def line(ok: bool, label: str, detail: str = "") -> None: - nonlocal reds - reds += 0 if ok else 1 - print(f" [{'OK ' if ok else 'RED'}] {label}" + (f" — {detail}" if detail else "")) + checks.append({"ok": ok, "label": label, "detail": detail}) import echo_config cfg = echo_config.load() - print(f"echo doctor — endpoint {cfg['endpoint'] or '(not configured)'}") + fatal = None # 1. interpreter line(sys.version_info >= MIN_PY, f"python >= {MIN_PY[0]}.{MIN_PY[1]}", @@ -53,33 +54,50 @@ def run() -> int: line(bool(cfg["owner"]), "vault owner set", cfg["owner"] if cfg["owner"] else "optional, but recommended for third-person writes") if not echo_config.is_configured(cfg): + fatal = "not-configured" + else: + # 3. reachability + auth + marker, in one GET of the bootstrap marker + status, body = echo.request("GET", echo.vault_url("_agent/echo-vault.md")) + if status == 0: + line(False, "vault reachable", body.decode(errors="replace")[:120]) + fatal = "unreachable" + else: + line(status not in (401, 403), "auth accepted", + f"HTTP {status}" if status in (401, 403) else "") + if status == 404: + line(False, "vault bootstrapped", "marker absent — run bootstrap.py") + elif status == 200: + text = body.decode(errors="replace") + ver = next((ln.split(":", 1)[1].strip() for ln in text.splitlines() + if ln.startswith("schema_version:")), "?") + line(True, "vault bootstrapped", f"schema_version={ver}") + else: + line(status < 400, "marker fetch", f"HTTP {status}") + + reds = sum(1 for c in checks if not c["ok"]) + return {"ok": reds == 0 and fatal is None, "action": "doctor", + "endpoint": cfg["endpoint"], "fatal": fatal, "reds": reds, "checks": checks} + + +def run() -> int: + env = run_op() + print(f"echo doctor — endpoint {env['endpoint'] or '(not configured)'}") + for c in env["checks"]: + print(f" [{'OK ' if c['ok'] else 'RED'}] {c['label']}" + + (f" — {c['detail']}" if c["detail"] else "")) + if env["fatal"] == "not-configured": print("\ndoctor: not configured — ask the operator for their key file and install it " "(`echo.py config import `, or `config set --owner … --endpoint … --key …`), " "then re-run.") return 1 - - # 3. reachability + auth + marker, in one GET of the bootstrap marker - status, body = echo.request("GET", echo.vault_url("_agent/echo-vault.md")) - if status == 0: - line(False, "vault reachable", body.decode(errors="replace")[:120]) + if env["fatal"] == "unreachable": print("\ndoctor: endpoint unreachable — is Obsidian + the Local REST API running?") return 1 - line(status not in (401, 403), "auth accepted", f"HTTP {status}" if status in (401, 403) else "") - if status == 404: - line(False, "vault bootstrapped", "marker absent — run bootstrap.py") - elif status == 200: - text = body.decode(errors="replace") - ver = next((ln.split(":", 1)[1].strip() for ln in text.splitlines() - if ln.startswith("schema_version:")), "?") - line(True, "vault bootstrapped", f"schema_version={ver}") - else: - line(status < 400, "marker fetch", f"HTTP {status}") - - # 4. invariants pointer. + # invariants pointer. print(" [i] invariants — run `/echo-health` (vault_lint.py) for the full check") - - print(f"\ndoctor: {'all green' if reds == 0 else f'{reds} issue(s) — see RED above'}") - return 1 if reds else 0 + summary = "all green" if env["reds"] == 0 else f"{env['reds']} issue(s) — see RED above" + print(f"\ndoctor: {summary}") + return 1 if env["reds"] else 0 if __name__ == "__main__": diff --git a/echo-memory.plugin.src/skills/echo-memory/scripts/echo_ops.py b/echo-memory.plugin.src/skills/echo-memory/scripts/echo_ops.py index b6abf6d..d63e7bc 100644 --- a/echo-memory.plugin.src/skills/echo-memory/scripts/echo_ops.py +++ b/echo-memory.plugin.src/skills/echo-memory/scripts/echo_ops.py @@ -37,12 +37,13 @@ LOG_HEADING = { # ---------------------------------------------------------------- resolve ----- -def resolve(mention: str) -> int: +def resolve_op(mention: str) -> dict: + """Core resolve: mention -> match/candidates dict. No printing (Phase 0 — the same + return value backs the CLI wrapper and the MCP `echo_resolve` tool).""" index = idx_mod.load() slug, e = idx_mod.resolve(index, mention) if e: - print(json.dumps({"match": True, "slug": slug, **e}, ensure_ascii=False, indent=2)) - return 0 + return {"match": True, "slug": slug, **e} # No exact match — surface fuzzy candidates so a shortened/expanded name (e.g. "echo # memory" for the project "echo") reveals the existing note instead of looking absent. cands = idx_mod.fuzzy_candidates(index, mention) @@ -54,21 +55,30 @@ def resolve(mention: str) -> int: "these before creating a new note; reuse the right path or `echo.py link`.") else: out["note"] = "no index entry — derive a path with the right --kind and create it" - print(json.dumps(out, ensure_ascii=False, indent=2)) + return out + + +def resolve(mention: str) -> int: + print(json.dumps(resolve_op(mention), ensure_ascii=False, indent=2)) return 0 # ------------------------------------------------------------------- link ----- -def link(a_path: str, b_path: str, as_json: bool = False) -> int: +def link_op(a_path: str, b_path: str) -> dict: + """Core link: reciprocal `## Related` links, returns the change envelope.""" + import echo_output a_changed, b_changed = links.link_bidirectional(a_path, b_path) + return echo_output.envelope("link", {"a": a_path, "b": b_path, + "a_changed": a_changed, "b_changed": b_changed}) + + +def link(a_path: str, b_path: str, as_json: bool = False) -> int: + env = link_op(a_path, b_path) if as_json: - import echo_output - env = echo_output.envelope("link", {"a": a_path, "b": b_path, - "a_changed": a_changed, "b_changed": b_changed}) print(json.dumps(env, ensure_ascii=False)) return 0 - print(f"ok: linked {a_path} <-> {b_path} " - f"(added: {'A' if a_changed else '-'}{'B' if b_changed else '-'})") + print(f"ok: linked {env['a']} <-> {env['b']} " + f"(added: {'A' if env['a_changed'] else '-'}{'B' if env['b_changed'] else '-'})") return 0 @@ -176,52 +186,52 @@ def _append_to_existing(path: str, kind: str, today_s: str, body_text: str) -> N echo.cmd_fm(path, "updated", json.dumps(today_s)) -def capture(kind: str | None, title: str, file_arg: str | None, status_v: str = "", - aliases=None, sources=None, tags=None, date: str | None = None, - domain: str = "business", inbox: bool = False, no_log: bool = False, - as_json: bool = False, dry_run: bool = False, force: bool = False, - merge_into: str | None = None) -> int: +def capture_op(kind: str | None, title: str, file_arg: str | None = None, status_v: str = "", + aliases=None, sources=None, tags=None, date: str | None = None, + domain: str = "business", inbox: bool = False, no_log: bool = False, + dry_run: bool = False, force: bool = False, + merge_into: str | None = None, body_text: str | None = None) -> dict: + """Core capture (Phase 0): route + frontmatter + index + auto-link + agent-log, + returning an envelope dict — never printing to stdout (helper chatter from the + low-level verbs is redirected to stderr). Special outcomes are DATA, not exit + codes: `duplicate-gate` (ok=false, candidates), `queued:capture` (queued=true), + `dry-run:*`. Raises echo.EchoError on hard failure. `body_text` may be passed + directly (MCP path); otherwise it is read from file_arg/stdin.""" import contextlib - import io import echo_output import echo_quality - real_stdout = sys.stdout + def chatter(): + # Low-level verbs (cmd_put/cmd_append/cmd_fm) print progress lines; keep + # stdout clean for the envelope by routing them to stderr wholesale. + return contextlib.redirect_stdout(sys.stderr) - def quiet(): - # M4: in --json mode, swallow the helper "ok:" chatter so stdout is clean JSON. - return contextlib.redirect_stdout(io.StringIO()) if as_json else contextlib.nullcontext() - - def done(action: str, path: str, links: int = 0, ok: bool = True, dry: bool = False, - near=None) -> int: - if as_json: - act = f"dry-run:{action}" if dry else action - data = {"kind": kind, "path": path, "title": title, "links_added": links} - if near: - data["near_duplicates"] = near - env = echo_output.envelope(act, data, ok=ok) - print(json.dumps(env, ensure_ascii=False), file=real_stdout) - elif dry: - print(f"would {action} {kind or '-'} -> {path}") - # non-dry human output is the helper "ok:" lines + the summary printed by the caller. - return 0 if ok else 1 - - body_text = echo.read_body(file_arg).decode("utf-8", errors="replace") + if body_text is None: + body_text = echo.read_body(file_arg).decode("utf-8", errors="replace") today_s = echo.today() aliases = [a.strip() for a in (aliases or []) if a.strip()] sources = [s.strip() for s in (sources or []) if s.strip()] tags = [t.strip() for t in (tags or []) if t.strip()] + def env_for(action: str, path: str, links: int = 0, ok: bool = True, dry: bool = False, + near=None, **extra) -> dict: + act = f"dry-run:{action}" if dry else action + data = {"kind": kind, "path": path, "title": title, "links_added": links} + if near: + data["near_duplicates"] = near + data.update(extra) + return echo_output.envelope(act, data, ok=ok) + # Unknown home -> defer to the inbox (a single idempotent capture line). if inbox or not kind: if dry_run: - return done("inbox", "inbox/captures/inbox.md", dry=True) + return env_for("inbox", "inbox/captures/inbox.md", dry=True) line = f"- {today_s}: {title}" if body_text.strip(): line += f" — {body_text.strip().splitlines()[0]}" - with quiet(): + with chatter(): rc = echo.cmd_append("inbox/captures/inbox.md", line) - return done("inbox", "inbox/captures/inbox.md", ok=rc == 0) + return env_for("inbox", "inbox/captures/inbox.md", ok=rc == 0) slug = idx_mod.slugify(title) # Offline short-circuit (2.1.0). The routed path needs the entity index and several @@ -235,9 +245,8 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str = if "unreachable" not in str(exc): raise if dry_run: - print("offline: vault unreachable — a real run would queue this capture " - "for replay on the next reachable session.", file=real_stdout) - return 0 + return echo_output.envelope("dry-run:queued:capture", + {"kind": kind, "title": title, "queued": True}) import echo_queue echo_queue.enqueue_capture({ "kind": kind, "title": title, "body_text": body_text, "status_v": status_v, @@ -245,23 +254,16 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str = "domain": domain, "inbox": False, "no_log": no_log, "force": force, "merge_into": merge_into, "today": today_s, }) - if as_json: - env = echo_output.envelope("queued:capture", - {"kind": kind, "title": title, "queued": True}) - print(json.dumps(env, ensure_ascii=False), file=real_stdout) - else: - print(f"queued (offline): capture {kind} '{title}' — will replay through " - "capture on the next reachable session", file=real_stdout) - return 0 + return echo_output.envelope("queued:capture", + {"kind": kind, "title": title, "queued": True}) match_slug, existing = idx_mod.resolve(index, title) # --merge-into: the operator has already identified the canonical entity (e.g. after # a duplicate-gate stop) — route this capture as an UPDATE to it, whatever the title. if merge_into and not existing: match_slug, existing = idx_mod.resolve(index, merge_into) if not existing: - print(f"echo_ops: --merge-into '{merge_into}' matches no entity in the index " - f"(try `resolve` first)", file=sys.stderr) - return 2 + raise echo.EchoError(f"--merge-into '{merge_into}' matches no entity in the " + "index (try `resolve` first)", 2) existing_reachable = bool(existing and echo.request("GET", echo.vault_url(existing["path"]))[0] == 200) # Pre-write duplicate gate: a strong fuzzy candidate means this title is very likely @@ -276,40 +278,26 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str = for s, c, sc in idx_mod.gate_candidates(index, title, kind=kind, threshold=DUP_GATE)] if gate_hits: - if as_json: - env = echo_output.envelope("duplicate-gate", { - "kind": kind, "title": title, "candidates": gate_hits, - "note": "likely duplicate — re-run with --merge-into to update the " - "existing entity, or --force to create anyway"}, ok=False) - print(json.dumps(env, ensure_ascii=False), file=real_stdout) - else: - print(f"STOP: '{title}' likely already exists — not creating a duplicate.", - file=real_stdout) - for c in gate_hits: - print(f" candidate: {c['slug']} -> {c['path']} (score {c['score']})", - file=real_stdout) - print(" re-run with --merge-into to update the existing entity, " - "or --force to create anyway.", file=real_stdout) - return 76 # distinct exit: duplicate gate (cf. 75 lock, 78 config) + return echo_output.envelope("duplicate-gate", { + "kind": kind, "title": title, "candidates": gate_hits, + "note": "likely duplicate — re-run with --merge-into to update the " + "existing entity, or --force to create anyway"}, ok=False) if dry_run: if existing_reachable: - return done("update", existing["path"], dry=True) + return env_for("update", existing["path"], dry=True) s2 = echo_quality.safe_slug(set(index.get("entities", {}).keys()), slug) near = [c.get("path") for _, c, _ in idx_mod.fuzzy_candidates(index, title)][:3] - plan = done("create", idx_mod.derive_path(kind, s2, date=date, domain=domain), - dry=True, near=near) - if (not as_json and not force - and idx_mod.gate_candidates(index, title, kind=kind, threshold=DUP_GATE)): - # (in --json mode the near_duplicates field carries this; keep stdout clean) - print("note: a real run would STOP at the duplicate gate — use --merge-into " - "or --force.", file=real_stdout) - return plan + would_gate = (not force + and bool(idx_mod.gate_candidates(index, title, kind=kind, + threshold=DUP_GATE))) + return env_for("create", idx_mod.derive_path(kind, s2, date=date, domain=domain), + dry=True, near=near, would_gate=would_gate) near_dupes: list[str] = [] index_title = title # title to record in the index entry index_aliases = list(aliases) - with quiet(): + with chatter(): if existing_reachable: # Reuse the matched entity's CANONICAL slug — never re-slug from the mention, or # two index slugs end up pointing at one note. Keep its title; learn the mention @@ -374,13 +362,61 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str = if not no_log: ensure_daily_log(f"- {today_s}: {action} {kind} [[{links.link_token(path)}]]") + return env_for(action, path, links=linked, near=near_dupes) + + +def capture(kind: str | None, title: str, file_arg: str | None, status_v: str = "", + aliases=None, sources=None, tags=None, date: str | None = None, + domain: str = "business", inbox: bool = False, no_log: bool = False, + as_json: bool = False, dry_run: bool = False, force: bool = False, + merge_into: str | None = None) -> int: + """CLI wrapper around capture_op: prints exactly what pre-Phase-0 capture printed + (human prose or the --json envelope) and maps envelope outcomes to exit codes + (76 duplicate-gate, 2 usage-class errors, 0 otherwise).""" + try: + env = capture_op(kind, title, file_arg, status_v=status_v, aliases=aliases, + sources=sources, tags=tags, date=date, domain=domain, + inbox=inbox, no_log=no_log, dry_run=dry_run, force=force, + merge_into=merge_into) + except echo.EchoError as exc: + print(f"echo_ops: {exc}", file=sys.stderr) + return getattr(exc, "code", 1) + action = env.get("action", "") + if as_json: - done(action, path, links=linked, near=near_dupes) - else: - print(f"ok: {action} {kind} -> {path}" + (f"; auto-linked {linked}" if linked else "")) - if near_dupes: - kind_word = "entity" if len(near_dupes) == 1 else "entities" - print(f"WARNING: similar existing {kind_word} ({', '.join(near_dupes)}) — if this is " - f"the same thing, merge or `echo.py link` instead of keeping a duplicate.", - file=real_stdout) - return 0 + print(json.dumps(env, ensure_ascii=False)) + if action == "duplicate-gate": + return 76 + return 0 if env.get("ok") else 1 + + if action == "duplicate-gate": + print(f"STOP: '{title}' likely already exists — not creating a duplicate.") + for c in env.get("candidates", []): + print(f" candidate: {c['slug']} -> {c['path']} (score {c['score']})") + print(" re-run with --merge-into to update the existing entity, " + "or --force to create anyway.") + return 76 # distinct exit: duplicate gate (cf. 75 lock, 78 config) + if action == "queued:capture": + print(f"queued (offline): capture {kind} '{title}' — will replay through " + "capture on the next reachable session") + return 0 + if action == "dry-run:queued:capture": + print("offline: vault unreachable — a real run would queue this capture " + "for replay on the next reachable session.") + return 0 + if action.startswith("dry-run:"): + print(f"would {action.split(':', 1)[1]} {kind or '-'} -> {env.get('path')}") + if env.get("would_gate"): + print("note: a real run would STOP at the duplicate gate — use --merge-into " + "or --force.") + return 0 + if action == "inbox": + return 0 if env.get("ok") else 1 + print(f"ok: {action} {kind} -> {env.get('path')}" + + (f"; auto-linked {env['links_added']}" if env.get("links_added") else "")) + near = env.get("near_duplicates") + if near: + kind_word = "entity" if len(near) == 1 else "entities" + print(f"WARNING: similar existing {kind_word} ({', '.join(near)}) — if this is " + f"the same thing, merge or `echo.py link` instead of keeping a duplicate.") + return 0 if env.get("ok") else 1 diff --git a/echo-memory.plugin.src/skills/echo-memory/scripts/echo_recall.py b/echo-memory.plugin.src/skills/echo-memory/scripts/echo_recall.py index 13ecfba..bb172c3 100644 --- a/echo-memory.plugin.src/skills/echo-memory/scripts/echo_recall.py +++ b/echo-memory.plugin.src/skills/echo-memory/scripts/echo_recall.py @@ -396,32 +396,10 @@ def _doc_summary(path: str, text: str) -> dict: return out -def _brief(path: str, score: float | None = None, via: str | None = None) -> None: - text = links.get_text(path) - if text is None: - return - info = _doc_summary(path, text) - head = f"\n### {path}" - if score is not None: - head += f" (score {score:.2f})" - if via: - head += f" (via {via})" - print(head) - stamps = [] - if info.get("type"): - stamps.append(f"type: {info['type']}") - if info.get("updated"): - stamps.append(f"updated: {info['updated']}") - if info.get("status"): - stamps.append(f"status: {info['status']}") - if stamps: - print("_" + " · ".join(stamps) + "_") # staleness is visible, not guessed - if info.get("excerpt"): - print(info["excerpt"]) - - # ------------------------------------------------------------------- entrypoint -def recall(query, limit: int = 8, as_json: bool = False) -> int: +def recall_op(query, limit: int = 8) -> dict: + """Core recall (Phase 0): hybrid BM25 + graph, returning the structured envelope + ({query, primary, linked}) that backs both the CLI renderings and the MCP tool.""" q = " ".join(query) if isinstance(query, list) else query today_s = echo.today() index = idx_mod.load() @@ -464,31 +442,49 @@ def recall(query, limit: int = 8, as_json: bool = False) -> int: # --- graph layer ---------------------------------------------------------- neighbours = expand_graph(hits, nmap, base, max_hops=MAX_HOPS) + import echo_output + texts = echo.read_many(hits + [p for p, _ in neighbours[: 2 * limit]]) + primary = [] + for p in hits: + if texts.get(p) is None: + continue + primary.append({**_doc_summary(p, texts[p]), + "score": round(base.get(p, 0.0), 3)}) + linked = [] + for p, (sc, via) in neighbours[: 2 * limit]: + if texts.get(p) is None: + continue + linked.append({**_doc_summary(p, texts[p]), + "score": round(sc, 3), "via": via}) + return echo_output.envelope("recall", {"query": q, "primary": primary, + "linked": linked}) + + +def _print_hit(info: dict) -> None: + """Prose rendering of one recall hit — same fields the envelope carries.""" + head = f"\n### {info['path']}" + if info.get("score") is not None: + head += f" (score {info['score']:.2f})" + if info.get("via"): + head += f" (via {info['via']})" + print(head) + stamps = [f"{k}: {info[k]}" for k in ("type", "updated", "status") if info.get(k)] + if stamps: + print("_" + " · ".join(stamps) + "_") # staleness is visible, not guessed + if info.get("excerpt"): + print(info["excerpt"]) + + +def recall(query, limit: int = 8, as_json: bool = False) -> int: + env = recall_op(query, limit=limit) if as_json: - import echo_output - texts = echo.read_many(hits + [p for p, _ in neighbours[: 2 * limit]]) - primary = [] - for p in hits: - if texts.get(p) is None: - continue - primary.append({**_doc_summary(p, texts[p]), - "score": round(base.get(p, 0.0), 3)}) - linked = [] - for p, (sc, via) in neighbours[: 2 * limit]: - if texts.get(p) is None: - continue - linked.append({**_doc_summary(p, texts[p]), - "score": round(sc, 3), "via": via}) - env = echo_output.envelope("recall", {"query": q, "primary": primary, - "linked": linked}) print(json.dumps(env, ensure_ascii=False)) return 0 - - print(f"== recall: {q} ==") - print(f"\n# Primary hits ({len(hits)})") - for p in hits: - _brief(p, score=base.get(p)) - print(f"\n# Linked context ({len(neighbours)})") - for p, (sc, via) in neighbours[: 2 * limit]: - _brief(p, score=sc, via=via) + print(f"== recall: {env['query']} ==") + print(f"\n# Primary hits ({len(env['primary'])})") + for info in env["primary"]: + _print_hit(info) + print(f"\n# Linked context ({len(env['linked'])})") + for info in env["linked"]: + _print_hit(info) return 0 diff --git a/echo-memory.plugin.src/skills/echo-memory/scripts/echo_reflect.py b/echo-memory.plugin.src/skills/echo-memory/scripts/echo_reflect.py index f7ffbc0..29e7f1a 100644 --- a/echo-memory.plugin.src/skills/echo-memory/scripts/echo_reflect.py +++ b/echo-memory.plugin.src/skills/echo-memory/scripts/echo_reflect.py @@ -99,43 +99,69 @@ def preview(proposals: list[dict]) -> str: return "\n".join(rows) -def apply(proposals: list[dict], confirm: bool = False) -> int: - """Validate -> classify -> preview; with confirm=True, apply each via echo_ops.capture - (which routes/indexes/links/logs, each self-lock-guarded). Without confirm it is a - dry-run that writes nothing — the preview IS the confirmation step.""" +def apply_op(proposals: list[dict], confirm: bool = False) -> dict: + """Core reflect (Phase 0): validate -> classify -> (apply via capture_op). Returns + an envelope — rows carry the classified preview; with confirm, `results` carries + each proposal's capture outcome. Never prints to stdout.""" + import echo_output valid, errors = validate(proposals) - for e in errors: - print(f"skip: {e}", file=sys.stderr) - if not valid: - print("reflect: no valid proposals to apply.") - return 0 - - classify(valid) - counts = {a: sum(1 for p in valid if p.get("_action") == a) for a in ("create", "update", "inbox", "error")} - print(f"reflect: {len(valid)} proposal(s) — " - f"{counts['create']} new, {counts['update']} update, {counts['inbox']} inbox" - + (f", {counts['error']} error" if counts["error"] else "")) - print(preview(valid)) - - if not confirm: - print("\nreflect: dry-run — re-run with --apply to write these to memory.") - return 0 + rows: list[dict] = [] + counts: dict[str, int] = {} + if valid: + classify(valid) + counts = {a: sum(1 for p in valid if p.get("_action") == a) + for a in ("create", "update", "inbox", "error")} + rows = [{"action": p.get("_action"), "kind": p.get("kind"), + "title": p["title"], "path": p.get("_path")} for p in valid] + data = {"proposals": len(proposals or []), "valid": len(valid), "errors": errors, + "counts": counts, "rows": rows, "dry_run": not confirm, + "applied": 0, "gated": 0, "results": []} + if not valid or not confirm: + return echo_output.envelope("reflect", data) import echo_ops # lazy: the apply path pulls in the capture/index/link stack applied = gated = 0 + results = [] for p in valid: if p.get("_action") == "error": continue - bodyfile = echo.temp_file((p.get("body") or "").encode()) if p.get("body") else None - rc = echo_ops.capture( - p.get("kind"), p["title"], bodyfile, + env = echo_ops.capture_op( + p.get("kind"), p["title"], body_text=p.get("body") or "", aliases=p.get("aliases") or [], sources=p.get("sources") or [], tags=p.get("tags") or [], date=p.get("date"), domain=p.get("domain", "business"), inbox=bool(p.get("inbox")) or not p.get("kind")) - applied += 1 if rc == 0 else 0 - gated += 1 if rc == 76 else 0 - print(f"reflect: applied {applied}/{len(valid)} proposal(s)." + act = env.get("action", "") + if act == "duplicate-gate": + gated += 1 + elif env.get("ok"): + applied += 1 + results.append({"title": p["title"], "action": act, + "path": env.get("path"), "ok": bool(env.get("ok"))}) + data.update(applied=applied, gated=gated, results=results) + return echo_output.envelope("reflect", data) + + +def apply(proposals: list[dict], confirm: bool = False) -> int: + """CLI wrapper: same preview/summary text as pre-Phase-0. Without confirm it is a + dry-run that writes nothing — the preview IS the confirmation step.""" + env = apply_op(proposals, confirm=confirm) + for e in env["errors"]: + print(f"skip: {e}", file=sys.stderr) + if not env["valid"]: + print("reflect: no valid proposals to apply.") + return 0 + counts = env["counts"] + print(f"reflect: {env['valid']} proposal(s) — " + f"{counts['create']} new, {counts['update']} update, {counts['inbox']} inbox" + + (f", {counts['error']} error" if counts["error"] else "")) + print("\n".join(f" {r['action'] or '?':7} | {('-' if r['action'] == 'inbox' else r['kind'] or '?'):9} " + f"| {r['title']} -> {r['path'] or '?'}" for r in env["rows"])) + if env["dry_run"]: + print("\nreflect: dry-run — re-run with --apply to write these to memory.") + return 0 + gated = env["gated"] + print(f"reflect: applied {env['applied']}/{env['valid']} proposal(s)." + (f" {gated} stopped at the duplicate gate — re-propose with the existing " f"entity's title (or capture --merge-into )." if gated else "")) return 0 diff --git a/echo-memory.plugin.src/skills/echo-memory/scripts/echo_session.py b/echo-memory.plugin.src/skills/echo-memory/scripts/echo_session.py index 3bc614b..c650b90 100644 --- a/echo-memory.plugin.src/skills/echo-memory/scripts/echo_session.py +++ b/echo-memory.plugin.src/skills/echo-memory/scripts/echo_session.py @@ -72,36 +72,34 @@ def validate(bundle: dict) -> tuple[str, str]: return path, line -def session_end(bundle: dict, apply: bool = False) -> int: +def session_end_op(bundle: dict, apply: bool = False) -> dict: + """Core session-end (Phase 0): validate -> plan -> (apply). Returns an envelope + (plan rows + per-step results); never prints to stdout — helper chatter from the + underlying verbs routes to stderr. Raises echo.EchoError(2) on a bad bundle, + BEFORE any write.""" + import contextlib + import echo_output import echo_reflect path, line = validate(bundle) scope = str(bundle.get("scope") or "").strip() proposals = bundle.get("reflect") or [] valid, errors = echo_reflect.validate(proposals) - for e in errors: - print(f"skip: {e}", file=sys.stderr) - - print(f"session-end plan ({'APPLY' if apply else 'dry-run'}):") - print(f" 1. session log -> {path}") - print(f" 2. agent-log line: {line}") + rows: list[dict] = [] if valid: echo_reflect.classify(valid) - print(f" 3. reflect: {len(valid)} proposal(s)") - print(echo_reflect.preview(valid)) - else: - print(" 3. reflect: (none)") - print(f" 4. scope set: {scope!r}" if scope else " 4. scope: (unchanged)") - print(f" 5. heartbeat -> {path} @ (written LAST — the commit marker)") - + rows = [{"action": p.get("_action"), "kind": p.get("kind"), + "title": p["title"], "path": p.get("_path")} for p in valid] + data = {"path": path, "agent_log_line": line, "scope": scope or None, + "reflect_rows": rows, "reflect_errors": errors, + "dry_run": not apply, "steps": {}} if not apply: - print("\nsession-end: dry-run — re-run with --apply to write.") - return 0 + return echo_output.envelope("session-end", data) import echo_concurrency import echo_ops steps: dict[str, str] = {} - with echo_concurrency.vault_lock(): + with echo_concurrency.vault_lock(), contextlib.redirect_stdout(sys.stderr): # 1. The session log itself. A hard failure here aborts the whole bundle — # nothing after it (including the heartbeat) runs, so orientation state # can never point at a log that was never written. @@ -116,14 +114,13 @@ def session_end(bundle: dict, apply: bool = False) -> int: for p in valid: if p.get("_action") == "error": continue - bodyfile = echo.temp_file((p.get("body") or "").encode()) if p.get("body") else None - rc = echo_ops.capture( - p.get("kind"), p["title"], bodyfile, + env = echo_ops.capture_op( + p.get("kind"), p["title"], body_text=p.get("body") or "", aliases=p.get("aliases") or [], sources=p.get("sources") or [], tags=p.get("tags") or [], date=p.get("date"), domain=p.get("domain", "business"), inbox=bool(p.get("inbox")) or not p.get("kind")) - gated += 1 if rc == 76 else 0 + gated += 1 if env.get("action") == "duplicate-gate" else 0 steps["reflect"] = f"{len(valid) - gated}/{len(valid)} applied" \ + (f", {gated} gated (re-propose with --merge-into)" if gated else "") else: @@ -139,6 +136,30 @@ def session_end(bundle: dict, apply: bool = False) -> int: echo.temp_file(f"{path} @ {echo.now_iso()}\n".encode("utf-8"))) steps["heartbeat"] = "ok" + data["steps"] = steps + return echo_output.envelope("session-end", data) + + +def session_end(bundle: dict, apply: bool = False) -> int: + """CLI wrapper: same plan/summary text as before; envelope logic in session_end_op.""" + env = session_end_op(bundle, apply=apply) + for e in env["reflect_errors"]: + print(f"skip: {e}", file=sys.stderr) + print(f"session-end plan ({'APPLY' if apply else 'dry-run'}):") + print(f" 1. session log -> {env['path']}") + print(f" 2. agent-log line: {env['agent_log_line']}") + rows = env["reflect_rows"] + if rows: + print(f" 3. reflect: {len(rows)} proposal(s)") + print("\n".join(f" {r['action'] or '?':7} | {('-' if r['action'] == 'inbox' else r['kind'] or '?'):9} " + f"| {r['title']} -> {r['path'] or '?'}" for r in rows)) + else: + print(" 3. reflect: (none)") + print(f" 4. scope set: {env['scope']!r}" if env["scope"] else " 4. scope: (unchanged)") + print(f" 5. heartbeat -> {env['path']} @ (written LAST — the commit marker)") + if env["dry_run"]: + print("\nsession-end: dry-run — re-run with --apply to write.") + return 0 print("\nsession-end: done — " - + "; ".join(f"{k}: {v}" for k, v in steps.items())) + + "; ".join(f"{k}: {v}" for k, v in env["steps"].items())) return 0 diff --git a/echo-memory.plugin.src/skills/echo-memory/scripts/echo_triage.py b/echo-memory.plugin.src/skills/echo-memory/scripts/echo_triage.py index 3f5d652..bb59fe1 100644 --- a/echo-memory.plugin.src/skills/echo-memory/scripts/echo_triage.py +++ b/echo-memory.plugin.src/skills/echo-memory/scripts/echo_triage.py @@ -55,19 +55,25 @@ def parse_inbox(text: str) -> list[dict]: return items -def list_inbox(as_json: bool = False) -> int: +def list_op() -> dict: + """Core inbox listing (Phase 0): structured captures envelope, no printing.""" + import echo_output status, body = echo.request("GET", echo.vault_url(INBOX_PATH)) if status == 404: items = [] else: echo.check(status, body, f"triage list {INBOX_PATH}") items = parse_inbox(body.decode(errors="replace")) + return echo_output.envelope("triage-list", {"path": INBOX_PATH, "items": items, + "count": len(items)}) + + +def list_inbox(as_json: bool = False) -> int: + env = list_op() if as_json: - import echo_output - env = echo_output.envelope("triage-list", {"path": INBOX_PATH, "items": items, - "count": len(items)}) print(json.dumps(env, ensure_ascii=False)) return 0 + items = env["items"] if not items: print("triage: inbox is empty — nothing to route.") return 0 @@ -80,53 +86,76 @@ def list_inbox(as_json: bool = False) -> int: return 0 -def apply(proposals: list[dict], confirm: bool = False) -> int: - """Validate -> classify -> preview; with confirm, route each via capture AND write - the processing-log audit line. Mirrors echo_reflect.apply's contract exactly.""" +def route_op(proposals: list[dict], confirm: bool = False) -> dict: + """Core triage routing (Phase 0): reflect pipeline + processing-log audit lines. + Returns an envelope (rows = classified preview; results = per-item outcomes); + never prints to stdout (audit-append chatter routes to stderr).""" + import contextlib + import echo_output import echo_reflect valid, errors = echo_reflect.validate(proposals) - for e in errors: - print(f"skip: {e}", file=sys.stderr) - if not valid: - print("triage: no valid proposals to route.") - return 0 - - echo_reflect.classify(valid) - counts = {a: sum(1 for p in valid if p.get("_action") == a) - for a in ("create", "update", "inbox", "error")} - print(f"triage: {len(valid)} proposal(s) — " - f"{counts['create']} new, {counts['update']} update, {counts['inbox']} stay-in-inbox" - + (f", {counts['error']} error" if counts["error"] else "")) - print(echo_reflect.preview(valid)) - - if not confirm: - print("\ntriage: dry-run — re-run with --apply to route these and log the moves.") - return 0 + rows: list[dict] = [] + counts: dict[str, int] = {} + if valid: + echo_reflect.classify(valid) + counts = {a: sum(1 for p in valid if p.get("_action") == a) + for a in ("create", "update", "inbox", "error")} + rows = [{"action": p.get("_action"), "kind": p.get("kind"), + "title": p["title"], "path": p.get("_path")} for p in valid] + data = {"proposals": len(proposals or []), "valid": len(valid), "errors": errors, + "counts": counts, "rows": rows, "dry_run": not confirm, + "routed": 0, "gated": 0, "log": f"{LOG_DIR}/{echo.today()}.md", "results": []} + if not valid or not confirm: + return echo_output.envelope("triage", data) import echo_ops today_s = echo.today() - applied = gated = 0 + routed = gated = 0 + results = [] for p in valid: - if p.get("_action") == "error": - continue - if p.get("_action") == "inbox": + if p.get("_action") in ("error", "inbox"): continue # routing an inbox line back to the inbox is a no-op, not a move - bodyfile = echo.temp_file((p.get("body") or "").encode()) if p.get("body") else None - rc = echo_ops.capture( - p.get("kind"), p["title"], bodyfile, + env = echo_ops.capture_op( + p.get("kind"), p["title"], body_text=p.get("body") or "", aliases=p.get("aliases") or [], sources=p.get("sources") or [], tags=p.get("tags") or [], date=p.get("date"), domain=p.get("domain", "business")) - if rc == 76: + act = env.get("action", "") + results.append({"title": p["title"], "action": act, + "path": env.get("path"), "ok": bool(env.get("ok"))}) + if act == "duplicate-gate": gated += 1 continue - if rc != 0: + if not env.get("ok"): continue - applied += 1 + routed += 1 original = (p.get("line") or p["title"]).strip() - echo.cmd_append(f"{LOG_DIR}/{today_s}.md", - f"- {original} → {p.get('_path', '?')}") - print(f"triage: routed {applied}/{len(valid)} item(s); audit in {LOG_DIR}/{today_s}.md. " + with contextlib.redirect_stdout(sys.stderr): + echo.cmd_append(f"{LOG_DIR}/{today_s}.md", + f"- {original} → {p.get('_path', '?')}") + data.update(routed=routed, gated=gated, results=results) + return echo_output.envelope("triage", data) + + +def apply(proposals: list[dict], confirm: bool = False) -> int: + """CLI wrapper: same preview/summary text as pre-Phase-0. Mirrors reflect's contract.""" + env = route_op(proposals, confirm=confirm) + for e in env["errors"]: + print(f"skip: {e}", file=sys.stderr) + if not env["valid"]: + print("triage: no valid proposals to route.") + return 0 + counts = env["counts"] + print(f"triage: {env['valid']} proposal(s) — " + f"{counts['create']} new, {counts['update']} update, {counts['inbox']} stay-in-inbox" + + (f", {counts['error']} error" if counts["error"] else "")) + print("\n".join(f" {r['action'] or '?':7} | {('-' if r['action'] == 'inbox' else r['kind'] or '?'):9} " + f"| {r['title']} -> {r['path'] or '?'}" for r in env["rows"])) + if env["dry_run"]: + print("\ntriage: dry-run — re-run with --apply to route these and log the moves.") + return 0 + gated = env["gated"] + print(f"triage: routed {env['routed']}/{env['valid']} item(s); audit in {env['log']}. " "Originals kept in the inbox (deletion is explicit-only)." + (f" {gated} stopped at the duplicate gate — re-propose with the existing " f"entity's title or use capture --merge-into." if gated else "")) diff --git a/eval/test_ops_api.py b/eval/test_ops_api.py new file mode 100644 index 0000000..1a4114f --- /dev/null +++ b/eval/test_ops_api.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""test_ops_api.py — Phase 0 (return-not-print) contract: every high-level op has a +core `*_op` function that RETURNS an envelope dict and prints nothing to stdout. + +This is the seam the MCP server wraps (docs/MCP-SERVER-SPEC.md §4): the CLI wrappers +are tested by the other suites; here we call the cores in-process against the mock and +assert (a) the envelope shapes and (b) stdout purity — a stray print() would corrupt +an MCP stdio/HTTP response stream. + +Run: python test_ops_api.py [--port 8850] +""" +from __future__ import annotations + +import argparse +import contextlib +import io +import json +import os +import subprocess +import sys +import tempfile +import time +import urllib.request +from pathlib import Path + +HERE = Path(__file__).resolve().parent +SCRIPTS = HERE.parent / "echo-memory.plugin.src" / "skills" / "echo-memory" / "scripts" +KEY = "test-key-not-a-real-secret" + +ap = argparse.ArgumentParser() +ap.add_argument("--port", type=int, default=8850) +a = ap.parse_args() +BASE = f"http://127.0.0.1:{a.port}" + +# env must be set BEFORE importing echo (it resolves config at import time) +os.environ.update(ECHO_BASE=BASE, ECHO_KEY=KEY, ECHO_TODAY="2026-06-21", + ECHO_NOW="2300", ECHO_STATE_DIR=tempfile.mkdtemp(), ECHO_VERIFY="0") +sys.path.insert(0, str(SCRIPTS)) + +failures = [] + + +def check(name, cond, detail=""): + print(f"{'ok ' if cond else 'FAIL'} {name}" + (f" -- {detail}" if not cond else "")) + if not cond: + failures.append(name) + + +def http(method, url, body=None): + data = body.encode() if isinstance(body, str) else body + req = urllib.request.Request(url, data=data, method=method, + headers={"Authorization": f"Bearer {KEY}"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return r.status, r.read().decode("utf-8", "replace") + except Exception as e: # noqa: BLE001 + return getattr(e, "code", 0), "" + + +def pure(fn, *args, **kw): + """Call fn capturing stdout; return (result, captured_stdout).""" + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + out = fn(*args, **kw) + return out, buf.getvalue() + + +def main(): + srv = subprocess.Popen([sys.executable, str(HERE / "mock_olrapi.py"), "--port", str(a.port)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + try: + for _ in range(50): + try: + urllib.request.urlopen(f"{BASE}/__debug__reset", data=b"", timeout=1) + break + except Exception: + time.sleep(0.1) + http("PUT", f"{BASE}/vault/_agent/echo-vault.md", "---\nschema_version: 4\n---\n# marker\n") + http("PUT", f"{BASE}/vault/_agent/context/current-context.md", + "---\ntype: context-bundle\nscope_updated: \"2026-06-20\"\ncreated: 2026-06-01\n---\n" + "# Current Context\n\n## Scope\ntesting phase 0\n\n## Scope History\n") + + import echo + import echo_doctor + import echo_ops + import echo_recall + import echo_reflect + import echo_session + import echo_triage + + # capture_op: create / duplicate-gate / dry-run — envelopes, stdout pure + env, out = pure(echo_ops.capture_op, "person", "Zara Quix", body_text="Met at the expo.") + check("capture_op create envelope", env.get("ok") is True and env.get("action") == "created" + and env.get("path") == "resources/people/zara-quix.md", json.dumps(env)) + check("capture_op prints nothing to stdout", out == "", out[:200]) + env, out = pure(echo_ops.capture_op, "person", "Zara Quix Junior", body_text="") + check("capture_op duplicate-gate is data, not an exit code", + env.get("ok") is False and env.get("action") == "duplicate-gate" + and env.get("candidates"), json.dumps(env)) + check("capture_op gate prints nothing", out == "", out[:200]) + env, out = pure(echo_ops.capture_op, "company", "Plan Co", body_text="", dry_run=True) + check("capture_op dry-run envelope", env.get("action") == "dry-run:create" + and env.get("path") == "resources/companies/plan-co.md", json.dumps(env)) + + # resolve_op / link_op + env, out = pure(echo_ops.resolve_op, "zara quix") + check("resolve_op returns the match dict", env.get("match") is True + and env.get("path") == "resources/people/zara-quix.md", json.dumps(env)) + pure(echo_ops.capture_op, "concept", "Gizmo", body_text="") + env, out = pure(echo_ops.link_op, "resources/people/zara-quix.md", "resources/concepts/gizmo.md") + check("link_op envelope", env.get("ok") is True and env.get("a_changed") in (True, False), + json.dumps(env)) + check("link_op prints nothing", out == "", out[:200]) + + # recall_op + env, out = pure(echo_recall.recall_op, "expo") + check("recall_op envelope shape", env.get("action") == "recall" + and isinstance(env.get("primary"), list) and isinstance(env.get("linked"), list), + json.dumps(env)[:200]) + check("recall_op prints nothing", out == "", out[:200]) + + # triage list_op / route_op (dry-run) + http("PUT", f"{BASE}/vault/inbox/captures/inbox.md", "- 2026-06-10: try the quorlab tool\n") + env, out = pure(echo_triage.list_op) + check("triage list_op envelope", env.get("count") == 1 + and env["items"][0]["age_days"] == 11, json.dumps(env)) + props = [{"title": "Quorlab Tool", "kind": "reference", "confidence": 0.9, + "line": "- 2026-06-10: try the quorlab tool"}] + env, out = pure(echo_triage.route_op, props, False) + check("triage route_op dry-run rows", env.get("dry_run") is True + and env["rows"][0]["action"] == "create", json.dumps(env)) + check("triage route_op prints nothing", out == "", out[:200]) + + # reflect apply_op (applied path, using capture_op internally) + env, out = pure(echo_reflect.apply_op, + [{"title": "Blorp Pattern", "kind": "semantic", + "body": "The operator prefers blorp.", "confidence": 0.9}], True) + check("reflect apply_op applies via capture_op", env.get("applied") == 1 + and env["results"][0]["action"] == "created", json.dumps(env)) + check("reflect apply_op prints nothing", out == "", out[:200]) + + # scope ops + env, out = pure(echo.scope_show_op) + check("scope_show_op returns scope + freshness", env.get("scope") == "testing phase 0" + and env.get("scope_updated") == "2026-06-20", json.dumps(env)) + env, out = pure(echo.scope_set_op, "phase zero refactor") + check("scope_set_op envelope", env.get("action") == "scope-set" + and env.get("scope_updated") == "2026-06-21", json.dumps(env)) + check("scope_set_op prints nothing", out == "", out[:200]) + + # load_op (brief) + env, out = pure(echo.load_op, True) + check("load_op returns sections + brief", "marker" in env.get("sections", {}) + and "ECHO load (brief)" in env.get("brief", ""), json.dumps(env)[:200]) + check("load_op prints nothing", out == "", out[:200]) + + # doctor run_op + env, out = pure(echo_doctor.run_op) + check("doctor run_op checks list", isinstance(env.get("checks"), list) + and env.get("fatal") is None and env.get("ok") is True, json.dumps(env)) + check("doctor run_op prints nothing", out == "", out[:200]) + + # session_end_op: dry-run envelope; bad bundle raises EchoError(2) pre-write + bundle = {"slug": "phase-zero", "log_body": "---\ntype: session-log\n---\n# S\n\n## Goal\nx\n"} + env, out = pure(echo_session.session_end_op, bundle, False) + check("session_end_op dry-run envelope", env.get("dry_run") is True + and env.get("path") == "_agent/sessions/2026-06-21-2300-phase-zero.md", json.dumps(env)) + check("session_end_op prints nothing", out == "", out[:200]) + try: + echo_session.session_end_op({"slug": "Bad Slug!", "log_body": "x"}, True) + check("session_end_op rejects a bad slug", False, "no exception raised") + except RuntimeError as exc: + check("session_end_op rejects a bad slug", getattr(exc, "code", 0) == 2, str(exc)) + + print(f"\n{len(failures)} failure(s)" if failures else "\nall ops-api tests passed") + return 1 if failures else 0 + finally: + srv.terminate() + + +if __name__ == "__main__": + raise SystemExit(main())