From 1d37ef6b52f26e6bbc8150ef7495a7d7675344ee Mon Sep 17 00:00:00 2001 From: "zhangqi.ruster" Date: Tue, 28 Jul 2026 19:30:28 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(card):=20=E5=9C=A8=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E9=A1=B5=E8=84=9A=E5=B1=95=E7=A4=BA=20Contex?= =?UTF-8?q?t=20=E5=92=8C=20Token=20=E7=94=A8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 15 ++- docs-site/docs/en/bots-json.md | 1 + docs-site/docs/zh/bots-json.md | 1 + docs/assets/card-usage-footer.png | Bin 0 -> 186292 bytes src/bot-registry.ts | 45 +++++++ src/cli.ts | 137 +++++++++++++++++--- src/core/cost-calculator.ts | 88 ++++++++++++- src/core/dashboard-ipc-server.ts | 23 +++- src/core/worker-pool.ts | 71 +++++++++- src/i18n/en.ts | 3 + src/i18n/zh.ts | 3 + src/im/lark/card-builder.ts | 3 +- src/im/lark/card-handler.ts | 10 +- src/im/lark/md-card.ts | 158 +++++++++++++++++++---- src/im/lark/message-parser.ts | 50 +++++-- src/services/bot-config-store.ts | 29 ++++- src/utils/child-env.ts | 4 + src/worker.ts | 11 +- test/bot-config-store.test.ts | 26 ++++ test/bot-registry-grant.test.ts | 13 ++ test/bot-registry.test.ts | 37 +++++- test/bridge-final-output-retry.test.ts | 60 +++++++++ test/card-builder.test.ts | 11 +- test/card-handler-config.test.ts | 75 +++++++++++ test/cost-calculator.test.ts | 127 ++++++++++++++++++ test/dashboard-ipc.test.ts | 68 ++++++++++ test/md-card.test.ts | 124 ++++++++++++++++++ test/message-parser.test.ts | 141 ++++++++++++++++++++ test/tmux-backend-env.test.ts | 10 ++ test/worker-riff-env.integration.test.ts | 157 ++++++++++++++++++++++ 30 files changed, 1431 insertions(+), 70 deletions(-) create mode 100644 docs/assets/card-usage-footer.png create mode 100644 test/card-handler-config.test.ts create mode 100644 test/worker-riff-env.integration.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 8b15cfb1e..c6c9c669c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -26,6 +26,17 @@ cache read/create tokens when the CLI reports them; Token Out is the native output-side total. Botmux does not estimate token counts from message text. _Avoid_: token estimate, cost estimate +**Context Usage**: +The latest valid context-window measurement reported by an **Agent CLI** or its +persisted transcript. It may decrease after compaction and is never derived +from cumulative **Token Usage**. The window size and percentage are shown only +when the Agent CLI provides enough native data; missing measurements are +omitted from card footers rather than inferred from the model name. +Each Bot may set `showUsageInCardFooter: false` to hide both Context Usage and +Token Usage from ordinary reply-card footers. This is a display preference +only; Usage Ledger accounting and other usage consumers remain active. +_Avoid_: cumulative context, estimated context window + **Usage Ledger**: Append-only daily JSONL files under `~/.botmux/usage/` recording per-turn **Token Usage** deltas per **Session**. Each record is a self-describing JSON @@ -48,5 +59,5 @@ route that reply back to the same Agent CLI conversation." Dev: "Cursor did not expose Token Usage for this Session." -Domain expert: "Then botmux should say the Token Usage is unavailable, not guess -from the visible text." +Domain expert: "Then botmux should omit Token Usage from the card footer, not +guess from the visible text." diff --git a/docs-site/docs/en/bots-json.md b/docs-site/docs/en/bots-json.md index a14320b63..7844d1318 100644 --- a/docs-site/docs/en/bots-json.md +++ b/docs-site/docs/en/bots-json.md @@ -139,6 +139,7 @@ You can also add it to the corresponding bot entry directly (manual `bots.json` | Field | Description | |------|------| | `brandLabel` | Branding text at the bottom of the card. `undefined` = default `botmux` link; `""` = hidden; any other string = rendered as-is (supports markdown). Purely cosmetic, does not affect routing / permissions | +| `showUsageInCardFooter` | Whether reply-card footers show native Context / Token usage from the Agent CLI. Missing / `true` = show; `false` = hide both metrics. A missing individual metric is still omitted independently. This controls card display only and does not disable the Usage Ledger or other accounting | | `disableStreamingCard` | When `true`, no real-time streaming session card is sent at all (the Web Terminal still runs and the final reply still arrives via `botmux send`, there's just no auto-refreshing status card). For users who find the real-time card noisy | | `silentTurnReactions` | When `true`, card-off sessions no longer add GoGoGo / DONE reactions to the triggering message. Only affects the lightweight status reactions used when `disableStreamingCard` or `noCardChats` suppresses live cards; defaults to `false` | | `receivedReactionEmoji` | Feishu emoji_type for the "received" reaction in card-off sessions; `undefined` = default `GoGoGo` (冲!). Free-form string; a bad value just silently fails to attach (best-effort) | diff --git a/docs-site/docs/zh/bots-json.md b/docs-site/docs/zh/bots-json.md index c8e44abc2..786628aca 100644 --- a/docs-site/docs/zh/bots-json.md +++ b/docs-site/docs/zh/bots-json.md @@ -139,6 +139,7 @@ | 字段 | 说明 | |------|------| | `brandLabel` | 卡片底部品牌文案。`undefined`=默认 `botmux` 链接;`""`=隐藏;其它字符串=原样渲染(支持 markdown)。纯样式,不影响路由 / 权限 | +| `showUsageInCardFooter` | 回复卡片页脚是否展示 Agent CLI 原生提供的 Context / Token 用量。缺省 / `true`=展示,`false`=同时隐藏两项;单项数据缺失时仍只省略缺失项。仅控制卡片展示,不停止 Usage Ledger 或其它统计 | | `disableStreamingCard` | `true` 时彻底不发实时流式 session 卡片(web 终端仍跑、最终答复仍经 `botmux send` 到达,只是没有自动刷新的状态卡)。给嫌实时卡吵的用户 | | `silentTurnReactions` | `true` 时,无卡片会话不再给触发消息添加 GoGoGo / DONE reaction。只影响 `disableStreamingCard` 或 `noCardChats` 关闭实时卡片后的轻量状态提示;默认 `false` | | `receivedReactionEmoji` | 无卡片会话「已收到」reaction 的飞书 emoji_type;`undefined`=默认 `GoGoGo`(冲!)。自由字符串,填错只是静默不加表情(best-effort) | diff --git a/docs/assets/card-usage-footer.png b/docs/assets/card-usage-footer.png new file mode 100644 index 0000000000000000000000000000000000000000..0a74f153e47d99043c2bc78aee2547313246894b GIT binary patch literal 186292 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 z%0DZ2Fv40^g-(w58^SbnM{R9GevH{dZX_h1dyRim{RNt(;DV{(Z*1F|-;ddcbl_|R z?yc1Zt8Hs$rYmL{uPa(v1^9avs9+1Q;be#-ah#4?s_&8Qtf<3U)B{oE#l;T>S#*me z8FSab%S;t9tW^K){uk*d@iAQbHANVi=>6$weV9z97eY$RJu0uXo8w2-W{EoNKdrmC_jy^7r`I2uw zV>gg#Bo|e$x?3|3)a3%LoJXtH1SPX{z^e)IeQWxby=$84p~ItsPa%Mvd(wY`t`Mbf z>To{pSg;yB>)V~wD>VrHF2-qUxyc&$-P4O5Y$={OV0W|bHM$CctPpW|OJzv=mt z)Mm^4c4esDK%f|Q@NoeiEe!o-4Lr8`ein6I#&voR@G_u>xPhG8$?Lc3rNf~YJwJ@6 zZzuii^7a}9Nyc@&xOlko#n?M5-*H-?UxgLx|63(KoI{dC>W?T6*Nbc++x$zlcUJ^s z^*}*UbqVRojUq}DN@Yj~-0bkY(aL1wD-G|lzVdtnUQye_Q`g^jr%dho5Azpoc8nX& z>QJIb6odW=|vQJ@m;HTgEFo|oFE4p^$QDBC+TO!T=45qi~KO|nv+4_!yTW)rrJxl zjxIOdG*ukf+ks`Gty{n2*!Lsr5YSJgFUm)Urgd!f{o_05_T-MeM|Csa&1Lm|ifWx+ zlod50^eHE) z_t$z^YvFRdIRpX=m!z3Dfgep+MU!ng#+x*qr(=uzkcs%&lh)8=GkgS(A9L}`?c5?Q zmG?J8^Bz_CxZNQ4eLV%iPsU7*f)@HwcO+vqn5PbTVTcl9;w4DH`$aE5JM8#t-n;iq z^UcE&0u`Oj(FBi-aN#Hnxg^`<)ZkTJfcWt2DEsB?!0ZQ|0EA2(I?3k;FH{bjc+;V zJ)3O6g$$&2bl}?gg)%*8==EHVK_LO64TbR)H<0tifGTgIWp+BuDEoIs(tx{THpre0|29VK2~>k zd29D$I#rBXv6WU}mSKP|jmHG6xMmiH%VFe`CZCF(dfG|{0$EKGO0;}GgX}qc(EJ`# zHCZKXg&Cbp{z7}fvVYbgtZ-?0Shx&-#mgnI8{b?n z;SdVn6j^x8*a&9pEHz%tl~}zweaiR}sTM_B-`^fW_aI;EXBw)%rTMVsSxTX#-pd^} zezy$017kP!gsdu9$S7WwsF@!{!7tlD54%q~t1t zCO339!m;ir!98XgB-jz+Z>(RslbNpNr3qnY&Ho*KX;0^KqY>G+U8f{R6~vSQb&-h zjg_mNc~H%vA-lkzHJkKa9KBMK7sxyT5|^ht`oEh_NS>5Gv+1c;@n7NV-Bjz*3&y}jI*5^WnySLc@WZC0D#;GY zJ)9G~fwfUr>>nSK^f~r`>*EeRe4;UPF}C2a20#V>haFgu>M%v|Sv*G} zeEj7(uxmRS%!>SN(Gz>WTOTRF33^SF>&L0+w26%p?aNCGzpKRw;qL z%`)wKX2rB;80CKUOdBaz$+~fdOni99_K?5eWYup>x3uQd#QuyahCx}IgQTf$t(J=v z9qMP~c!`cWk4uZ3A00E}p@~-7NI2UoJCA;%p0f%avsTCVr7;=llMD}G;?@-4kNlLE z#8Pq@mQLik0VtwF?j7wVlbKzwxgKrgoIT9HQR#Fb=-~ahDO&L8SYG zYaK`Pe2-70*6^-{qZ`jH^>7Dni8sbw89UaJm}xgoJU!Q0|ASX>%>{{I0}`dua`q6Q zk)(rNG0g~?B6k#yAg$V(R$!2g64`C-jYr0x5C)pb$a`x%60_923+PqH8wevHYtJ#c zJA-EjX=3l6iJhAVQTlvN7G5v+zZ$2#zr&^X0U^Ab(_cDO5=3LTw#qyl#(NvdK+3$H zsCXipFx4-0tq*yB6W7wh5uf7;6$0$D2zN(b%xl@MeRYWXHfc5Sb@}D@D0YOh^~?W6kg7}MpPmYVN32yRWfxy9Cpf|G zvBOE#q1=THIaFox+-z?is2UQ|Cx&+6KXk$4Gd~OnBi%*B>}H)Rk}`MAJnwxYkfcba zGx161ybo$@BCBryoqLw<_iV1SO{OoWQh9%_SCCW;v!YV29^YK=jkTKff$fcrZR|Wm z$cb3sUsvFvlJ+9DH0gis_CUAt+kp3^>=sGauXtKRO8g&AIrUa3b&kMicJGrv#j-p| zLUs76{zfuCu>mZJ!CA1(&uG(8>;dt%53Voe@XSwG(D=Y-@k9!joY(61UvTG{jk*Dn z%4{U>=_}02cI&i?Kt1v>wr`47z3M*H;=Wxx^lwD28Sq`{ZsdA><38cpXxDbMFh(6g*u0FAQT^n1N5-?RM?uKgAyo&diH-1@#YlG{|$w$lldWm6{MygLn zLl@Unl-=UPkzK1{dD`#2wU*97os@3tqdk*5p=ccDvgURj_*KwKr))wm=2C9VN5^tj zs-|lts|RUkbeyiQ#It+Sq-UBw_kcC+w)v)|=;!;B=V>~$udGa*>fm(=L=tChXT>qn z!kmz6@emB!?z~sR)3KRGMYNsS;>T|S(zEn*<<{`2BDmjj%wAY3Re-Ip00 zYDdh8a=eCnM6>f-mIuuW`~>P5On0@mMzLibalzx!#J^;J{z9#coqcFBD)I~WW-~z3 zNSRSn&L?69(sX=I3ER~$Zn22^CT-1VOmp|BZJ%sj{|X*C6QnNt(59YHB^ZT(X!0tFxv z)}%90rh?bQ_!L+GG+pqjfic}UP1Tb|>eT4ltJyAm{t#76`X6JZ57t1NmZQ;(^!Zr$ zeGM^;Z#Dk6m;Vm*cxCV5__PHurhiix=rSDtz-l>9VNd^>7}X5@xZpiQyXvU;15gh} zPej}G6`UD-c-~|&Vpm?s2z`?g+MfLKGTUO|!;a6(c->3geyTS!$$W&-Wo2HgO}d6$ zLeLvA#+0sCwGtl@Fx|U@`@1chLSG!&p!ct2Byr(u93$Mfm*iVY2y=r?#nD!E)O1+Q zNB}N98c|siIZjymTAGqoaNgXGa+PfCdYPM$^&ia}mgmNI3bQVf_~e(n+&7 zVqfL%@HRPUTSTx=QqjxBHzM3PrE{qPC%EHLNkYHW$ShDSEgKKk5f{-FLg_maG1 zw3BF2=jQahm(F8MVE*v=m*ik(5s$-tK7VSXhV$Ybl?>v0w;B0P!6kEp?g8`CmiwEn z>}r31bOhH>w}fmojoY9LJzE>!uu|`X6*RS@?>?@C_DBDNc&`GXz0qR4?6@}M%wK=F z8G3@*lQvw9Cy19s4NI;zMeE{)Q?Ti(;M~pv#*~pi z!(7PsbFSyQOR}v*K;6nck5j&E4ByXQ_3IaN$S(-%{%1V?DKgGu?MJss$p`sBHf&l_ zghuKwwdQ%RM!+x_`HxQs20slh;?^*AfU6d4ya*>aP`5_RM`D%8EpDOsLvPv31`ok~E zdO3UULGWw-A3z2=oWLqvT8`o4fQc+M7TJ>BFXlnu>Y)R8N*Mq-#wS=$aD5w`)GIF) zirtC_^z;CD>+bu#vj>@Z`YO68PZ^}$ewriD1vx8;N7I=j>iGcwNRk(sqf7*Y^#TS` z7NUh%%y$3HZx~tbj=;n}--#J8ansEg3mBoY9$Ijm{O2mU3Ph3+LE!h{|8KD>qayBL zr@fDKjJiBmeF-uM+y@PY-yi4_CzgZ~$ei|m43!SOFuKvt{^pfMB z?%p$P5p;dZtwOlh2h#{1iUpPW0<<)HoA;X6|1(luwi z$x9ALVecO3MhKxh1Ga~QO2(3YK5HQDP8SZ=D4j&~(@juL)Ya8mrTF{_$2DLaQ(2iZ z1BsBp!ItDkIs1|Da~?u7u}|ed01(;du9+zmQUQMfU5szr9I)^%ilmo%UGV7)6t&ZR zCZ;fSt!a1861pK-Ua4;qQ-)yAG2t{}jzvxKw4V0=8bQgKm;EEZ4kW?;X5=-61VHhO zl_X4=oUT8d125}Dbi#CSu!rN7+2c<%QdvuJe;@Sd0+0>LH4u7Va>LRpJaN?-uX@n) z_iieS(SR-C3a@~EksQUwpTTP-NUxTs6TSFKHBxjF`b;)PPfF1dGtFme=X=`6jMJHA ze*eZVhiS9VK3Md{c6%*k3CYO`8vCE0?WnvEpy{akHma!~XCzqb2%hFa$^QX`R2(qo zr{pwl?hYW~NX~8bInVLG%*sjY&Uk7Hlm}&+ncVbh>K09SVGA1N_cp|i3n))bh94KNX(MHEyaZO(W$C^!(}%*W>AjU#z-3aFw|aTDq+ z+N?my-pLopPN9B(M-0-e?VHg;!_FTHK_6V0E&)9{mEYH6Q zC9a9>^GvSL9=%Zp!RRw!P7cKCSdG=g?(zLGKj&`VYh>ap=^)CpapyB6y!J)yGagST zX^fb+dn0l8>idY8eYbekak49)~10KViv2&RzeTm_w= z!oB~#rQFbM(zN+TNb7x8$!sociQ{=wDeL@Ju2;V^K&cA-)p$iZKUczeby+g#>U5cb zpeylaMd{{a+@$LL+$7^9%WpTM$7C%1hH7GV{!&gV9iRIV`lOS3b~R^uvwHn?KgN}< zr54HrEp(ec;x{p=NBJw6+eL+2s*6Q@5SU#}BJ$m@?4|2@V5T;-cmX4kM+llD95TYK z|B!+ZjFD=jaRmmV)d+R~PslD6s&^xl-3=ZDf=<8YvBnN@cT&}R{uRuyfgDD+5cEX{ zc)|q^1TH=x*?*zH*my{37FOk?fM?8cereyQs)_Fo{Ru-r)(&7kaq@kKr>h_ZpzT&m zBPHN!=7Psm5{4}hXvq(5eq`~L({u{$`{V=n8AE|bTA_;4i%>7FIeOjK?Y!S0nRx{4 zQ6^?K&q>A=dq&CuBJHNuxCzBrMNwN)wf>U8|1P+GU8Xm*vKkBzy~yW29i2mhvyJ|3 zxuRX_+CcF*0@fZ3;475j$wPEX>!7JFoK~T1Za$hg7Fc$y0k2OrFIi3Q# zWU#BG01;BA^cOy=`UTZ9Gco$S!+gTa$}up?pkw^9xkNa|Z(T7l%Wou0DN6aLhTSJT zQK>(|(8q3unnh}HDg4V9&kb@7*fNyrer!!`f-g(UVY@Lz;?DFn@d*EY**gCLgGqbL zQ1MMriuO)7&R_CE_TRe1)ax$b+s&fYr1xubk%9o;hN8V-#uTuB)}Wg`niaS~3G}rW zb2mR`vVIdGPN6f;;e3+Dmges`Nz#8-fkP^C#PMYE6|+`$uC-`Ayi7rZmnYX6u$XnF}8T4WqC+S}(*+=MOw8btbrDcf%;o%Xc2E zhetpIfU-e+V0)a?y_J4yXDybcRDqZMhZiVoZgf1feJk{3 zyP_&3NkiAoNlfBL2}?LtzD55N$ub%S`Q=1GuLy^41k}p@L#e1Si+isI42$01Rfpcx zC-}9qe3?Gzot?`;EyZ?NDz7|4{u7j8(+Bxw63lB*4*UF5?ll&JfTJm@?nV|Z!RNVZ zWik|O931bgtWXlH`$E|@pVEaxxT}FX>N^m#0ou@!UZLc#!Ws+rELNef;C@27DNBL8 zK+>bfBFMW{wK(Q)%lUggTZR<~-r|gD54#C4-G&}Y|6yDMJ>su>3WO6PiQ>D?kkzjJ+5Fv|qrK9zdwoBk)0KJxS8j zPAu-Q=VTqdImCwnaeWP|UhXzVBA1>#dZSlWRx4+1I1coKOaf_ZX1MhP=7QcUq3 zmAJm)#z8+m8F^kn-Yxe1dt@_f{{B-}E~W?51MNeDJoGWi=SCziplJr?-S}5D)#TKr ztDrez#SwYy%%InfkT%AH%t}FzLs`7+);NMy;ttO(LxwWbI&ZLeHC!l+>>&`;=Dozj8kda>ZkUe@N5np4g`~<)ZDJ)l+nX9wB@VS?<&_ z%wKyB;)-AFvT>~ul2JRfTzT&~eLsWDvfv_#*G4o<$<^0z@ z2@g2EX;IqV6^Ayakm5U2ISh1x6NmBRHg$_W)&nKKx(&OM>NU?BUA%oZico^4n^y5< zkd+m^6e8yMM*CefD#!6%H20oS=hYfd6To`qxiHZA2@3;B6;UD3YA9T6_XLlQ}y|-6c^XV_uPT~M|-}Ph?%wy)*FbMsq7>!T>nqCv^GOEKq zkx71UO#zKUCbqsg$*;|HG6?PM=*F%R3oB=bZ;=^?HQMh0sJ@Y<{*1SO0$NR$3FTIZcXn*$Z=%t7 zWP5CTF}^J)rnFiWh_mV2^Aos{$A(O*lKA#W?uGYtfb=)QfkE5+rlrw_qOEZiF;;5y zBhRap$4}QmRjHU#0E25ZK|8?X*?z#&EfA-1i;!VZ6?G#DAzECcp?vqc1~U)$ck?06 z8zR><>@ASG;n^HMALdDg2Wx3)W&0lvj8b$QX;Z_}yj0}x@lhm-BP3_UpK)E%o>1^nItU3zL286hZW1*E?xr55q_$p`bG zQp+pvqNzeNfEpIQbGxyVt!?rHI>{W71xo}Xi1-6Zbo|B{jACXdS}(jA8o$v$8*7Gm zzFU&sZ&0;8!Yx8rXcKcF>?XUkq?Xdr*P-q^|t0^CD@#6B|c+9+PT_J~BfXs_t%+RbB zQxN!_Spetnz@^zfZGZUKY4(aNw_WOP>j$pi%^xhd@NEcSAZ){MY3dMYYHV#)_RMiW zq8zWBw?|EdUlVSLImHOMyQoLY5-?p}9KxHkM63z}0@+WT!uzPf-eF9Q<06u93~x(ewAg z^%IfhSm1uk4?vNpHUy7X&8pwv%P*9Wf{T-Ln8c+_zHq%%_N&8tHlLh_i2=lG>f?!F7=Yw8I+;QMGBCKbOp5kdCY>7s8T2S`lAQdR}hDlperLz zChk(0yoUYTPUTUURj4#e{$IMM0q5^`bkp42frAH{)(W&^5zSTcys6T<$KpV!b_3e* z@%YhI=KY(aMGg47-1fM$)d<&n_f9RRCDeB%uLPlQYW6Z50nS&G$-=&?B6|}22l!p( zgFaU=D<0iykywTf~S( zVs%szIuHC6d}=z@L=DKK^Rym_wF2t9&!l{ ztWsT0QJ3%O;uHO7-~4U+GYClsznTWc>L`4E;wM7lk1lIm2-UC=fsX z-(6b8G4B(FZ|j#<@}u)7K(w=v#8>QVuFkDlps^WXO{dEJ&w(k_z&8ETduM%AT}l*y zgTsayvCpNxCgT3z=l!Efe9uC)kk7)5o9N_fjh#)pQN~pAb^X9HNwZATAsK`NIqJ*= zAt-ca4a0g%8)7epXABlLLB=N`eEz7tOm?HDINm3;B4_C#w0xh}G!Yh9GY(pRM6{v zKc|c`g`n?g4_jpk#{OxN_*$bxhv8Wszmf2P$ehyrAJp2D5VT>cW8HfmT_dyZX9j8l zE1&TyE!QY6H$La5y(r^#H9cc)ScNZ|9KvfSdwku6Z&d7m}#ZIe-}@f@7Ed?w5um?gzBqnDxy+mWmNDAa?0Hbt(r@kc-iDr41G z5?KYotF1*50U7*KktkAkpq6a@F=79nVIe-Fkjr_#%4yCsv0_(m{5ZA0W4?o9=TN!`*$iafOdbUQ z*VDiY;0kO9jH<>-bk2b@o?@nX?bLE0{6`ybXCoW0r&J&p)Txdll(@w2vG)UA{XgWkhy9!qJ@@x(?0Ce+;!nIc zsQq!JMY2yt&E@~J0Jq-s(Krw(v%^s%SLQ_kk^gS^zz}>lT%skX-VuI1WFT>Rj7s<4 ziRuiAX|wqB=K4&Us3?12ox@;M!kh`8Tbu`vR9KCCw<1Y@F%09 z@b-Xeq8z^RvM}SMk=!`x6lNjOv}Jr<@=VmN!x)beOehjjETTaw#e@!j4Qz+V_T zob%s*>GM+gkonCY;ej;5%kZ)|JL=vjfxDbgSb}-03>lTYHKM{>P2Ay z6mK?(*MRFdg-sYzL38`dB4DzRsT!aW(3k9e1hy=jtQKUK(G*aArE}p=E z&bvuhr~MbazX=pf1+rtM@g`0a#qeG50{oYL0F-e$<7j=-OtHWF;_yK))l0&<1ZNbL; z#oT?KN_xx5jLQ$SE8=+(5H*9_?`BPB>$%lx#faXG19~ij&dpAsgt70%V^@QKVpPjL z1A|OtRzq);e{%}qf>&ZIhV6xJE>tarBE-4tkJ|g!Ro3Qg(Ue%0rAYMN@4HM5c&ON= z_I%66Z&9b#>)%s#C-=8yTp1oy_pPYyHRiJFc0h@XkgWtBOrNZJ1+Mn1RU|3(XvM6a zECDS}d-1d7+fevz)NnO3o{IP}OW^8vFRqgIBPzSn+VLTuWrKJ9HztBhq3FJ!^*L&) z1#q+69PY7=R-|ODlz@ubpUm5Jf6m5B!y+Y(Lh-xirJENQd$U_cFkoLjit(qy)Z!c$r}ff=tkm$)ZGxWb*h%?~L6;4`3 zi>GI=O8;;woE*T}4YP$<4GdBf$!d_~66inb9@kK(-vE%PkWur&)1{^R$wYCVo8}my z&20+_fiE-PJ2Y-qhKFwYh+t~ao$xe4V5LiS`&uH$R`&WLZ#eCyP7I~9K}xL1-lQD1z6m!$4RuDxk2&Mr|CV+KwA$Ir$!U_n z@}uJ690sWGa$4<_28SyzYi*j4%OdjrvOdXmuBLZbcg5YoKytXKurDUc3}jZ=Ys3;0EqF1%OWR)p+V z%93&D<7J<9Spd-pgg3PVypwY+0%H{#Mr~iAx5ru|g6%SWaNi?F9pUN#A9vI(2q&Er z*CU?Jr{uMzp40qX^$`?ahD%#WCE1z4cnjHMeOfP^0D{7?J~Cw(L#AV&;8nE|H# z_#J1wMx`wf&=$t9(SIj$Sk*-~iX9Z><%EUh{Tk2x-l_rwGvTDh>4 zKuS|^YD5uX(R2hqCI>8*avCRUr*F%AoKn}EA{Ur(p92soF26qr&hIWn{H7Q4eloQc z@5S}3vfXWJ@hIRLc~Y-V0T|#aH(NizK_R8}?FDwf8|z4kB9C!)gpXM^#_}5y3f%UO zh&u`Wrbj~n%l0)L-GcL?)>EZRQ?B+h5c9UCVv)T@61 z?-twRy*#%m9TkOXGav))*IDaNCt!bR^|Kz~oqABe;C=m9U~$z0etY$vJWUU3#xp!( z1~?R(o}I_z?w%`G@ZKi|PR*U+x!T_c#ryc$7$nR;OSKnxz0w##dqKd(G;$#houjJs_2sjvd)19%q_= z=cVkY`Mvkd6RHaF>09+_1*)Cmud1e4{zfwav<|LanRO|b4(B`1NX|BJX%vh0MAzZg zd?-k&gWX3d^V^P?em3C88~?}^WG&#jW-Iv=vE2tK>;Iq3(QE1zS^1&wx-SW+h0Pe- zs1h~RO`@Z@i)O=Ryb(XU&ag25`9QHPVm)VAI3;uw)2<~JUWg7o7(nh#O!CdEddk%fQ&>#mi^j0_O2`?)Tg3*$H87y3Nn0fZz|W_@E!e4oTc zeWqN2anAdql#(yvUQF!#`2LNd%W`hao1-G*N0!0QwAnkXOJYpyfQaqW_A>tP`ww#% z9syiSi5s+Ax(ZujTv`jc@gZKF_PqiRhKG|+@BPrUgA#r>DcAI0gw%TNSfn4!xYY*{1x>Sia@_JTveSoNPJx2c9W)Q+~ zzmu^0w|jQO_r9BJ^g@>-xzYFAz~MGxR=YoA;OJepV880W^|l8JOLMpSEu(z`O+nXkm6gq;pGApf*UqB)!@68sUujUKygi9D{{yC^y zvWeiR?-5D-`~Lg`Moaz!J4y5p>0TNsXTV@6S_%Pu+3QSZP6l{uzutWL^u<4fe|Yfk zum9Si1K>FRYY^rdsOgp+;G6>0Qk08O^noY66l)NT6dky$1k^|h?n--&nW#zMJ*Ck~ z10;2fQfOL#gPoH-j+9BO(r)G0~0yFU?ZOqmyt?*nK7rL^Rzko4xYDfUb_%I zY`XJKWjCx@zVku0To3t+-6c_Sl1XQU&^6RwhWI;*caxH@-73s2^#Me{mxI3My}`dHk}>{qWd-T$ILg=Kz6It zLP(qO<8Ui#2{PF$u6LbUbdL}I~v?*m-o`b5+T{!lTgrF+W1aaU#95JDN> zh~BlM`?5{^5~ee?;^1@W^Cw#tcGCZb|9Mv>c6C&D1lcWGN1}&BV8L^Iaj7mwPYr9L zG+^V|gGeg=T@;+4;!a@E{;}-~|Ayt#OMoBrYZGKn1-#tJ3>?RSR(#8K_OUoLV1`&O zC4FVA--Nz1+a>xZ=^1$};5ZHNif(`e=0m>sqAn}_?7#QczOsPmPxY>zSf#Z-B{U`% zf@ZJ&%L87mF{tPokqC_M7{-Xj5g4|KP3xrtUVt;-oo$@Y^Z#GYDQ^&vFAShH0u!A8F#Iv}C0+rbncaPhSyRe_MdIT?m3%HU@i0qOU>vyyJIKXBXgg zI%fI5IDks>Rkl{aiU~MIMWkQ9aL6xbw^j7>L=nT1D-GN(jBCT3F(|934;r>d=Y2U! zz86En^DL;Xb~>YGMJBb?Em5p~$!waKLFQ4mt~~I?G=nnJ`R#fZJ#O|SV#Lq(`Nj)X ze4dbhWgV+9>x7?7A_*x)Gy#hbW;Z~UQZDni5r?4PdP%}hGeK|qeDo*msLLWoDHS~< zFu(8F)-D_P7e_Nw7eEoX`#ocRvtMN4bTdCG^qk!hIc0#XxK(a`O5SkTM7@i4#(7$s zoZ}c2?Y$J@F=sh^f7@Pv$TfZc=JGobK-PCb3~&+9iqv?%1yuI#g~s9Wug+t6=XFai ziSasgI?5LKt2EJct?FJbfi%yu8K5N74AAw3j^}WNAD#!t4}!wS4(HqU<;`LbD;6Z@Wdy=p2Pd ztDv!@5$6j{T4?uzs}R($R7J59JfS8TAw5$I!DE_aEM#GtjqL{8FZu}Btwpu-T7+0O zYXAh?U!&>UP-$Jio#zv!=Ei_>2fSRKR~qT;l~hjtltU#<&EpKi)+oyrkpAUSv#X6TQ{(rIx77*nZ zYmLg!omQ99QsOE=^5IL0d9j+FC)A?7|NF@QX%G}DoE)$L)m|^EN$(}T2TM9Ts<5F8 zFnf;;5dTuwg6xZo)Q)@wL8@*qAX|LnnT8foVI}2_Jy86;^2Q73WKTK&$3M>xSnEom z)!C0)(3kuu(dCL;B9SM^#R0C?v$JuPH~f&@N4eTG@E&aNuS;mC;M~WP2CW`xwwzk? zhb7^wbQZnQ*K>mYXJzG(UByCQ|D$C|UNBlssB_*CrEOo#AQKJT4r{&T$BkU+?&q0Y zvtHA!?g+18AOq8PHupwSJKm4J#Hlmf458xZ*V9d&uJKMbM*IT$VmL(Q^2`snbuZ_1 zm?Eg!C;9)h0CDpft5m1k(VybYbHD6L`OQ|n_Z&2z#0K^Ajm62s0>ptRNc@V3d&r;S@E+;0K>Bc=osezeK^sr_R zXtl)0v}oOB{%*k!d8io;uVWVA>Ubd(gV`^k-I`q?1YO`lRlb6|5`ueG{~fRJ3*X`l zr=k~Z7h_-r6zeaVvM%gH{i4CqCUjxfeXxa)3f`AfUTGCrn;9CJfX$!`A)c9Qg!#KVJ|PckP$X?e$YAea(YO{Z z_Yxx%SX-{db9@y z@vje28KrTr1hywaG9MdY-?5*={4t+|aDUHC%q*V&UZTsijbMm1dDlO^q{$YE?nh(i zU*FokVNQTr44l*zyRh!M&_pov&Gd0a8aog6_nlR$Q$2AZ3i7`y_M0SrZ$zr~PNq`) z@2h$T?CeT)d)AJ_lEoW&fXGUlZ=JdDjOIi|$Z^w-_d{&qDBnVH*O_MzvZ6&Qh*HqkTKbkS*Poi|Rfz7F3)lRS}sY3b@dR0#K zjF9(JRQ0N*7?+%r+k4eqBQv4ON0TqDEq&2Le|y>C{ITzzH2Kr;+cIL5xv6>bbp$9JW7EPqi&a0gv38)Y5hfzXxh7ag$k!EBhP^o$o$mYNcSn;U;dwiUU>!FZOZ-bSQ1BAQibIV!OPl*V*nX^!Sndcu`f7s;swR3qWM za$lkv-Z<%^3@NSffSH-hW4`a6Ol-Q0hgP`{5_#JfOs|cakR_ELy7`UkKMNr(Qqoch zj$hoJ;fM4QnRcTq&Uc(%oLN`w>qtV7;bqTeQ1@?5?y!m|TX|Cmp8o6-2VpK<=dTrE z7ftI}Z0ZCg3s;?kGHoa+{yWWqG$G2_K>h6L>D?RXdxm|_r?(;hZ+m=mY--@+g601l z%x1NgnX&o~KR!jXbc=MpcirZT3SeI*|JCtp%A_CvykKR1cGyZPbCo_Di zx$yN;tho8-52@>eKAGQ4y7VXbzG)=Xnhpw3onm6&FA7xxaAqL&rtJs!bsBy$#kF?u z?>sx7%H^)x!G*Luuh&JFBz^TO&i0R4w^!;5vBWt4kN|`LX6VRvC_DrMPQ)hu;07 zoXvC$^w*}Dw-f5>|4xF*B@ z?OR0!R7xb25)n~4q+F&Nu@`u#oM z-}BtB=YP4JvgilmRI6p|0Ri)T{rKa{>rCG7GXZlK^S!57T9e zZbbr@V(%3*G>b;PDW=c86KXSegQwLigN^5Ip$G67{$J_MU!?|(0XF;}kP5_Iy-BhI zPKRSL2ILdrp}kR-rY6X_Lqe#rlGV9C-)h%OfigLN6p(M4P532B4LNF+v&SJ2Ra23 z-&-jGgW{P`U9*o!YI^@h8!o0%A8({TNfhDnE(-WdERsEV%W{!66Kfw_x z=FSl|kQoYlj_iW8Qy!7V1?Pla(+QR}-jXBJ207Yw<*5-G-(C>@8(aWrI)X>0b>|z0 z!`cu0(k8(C;9r771itLS$;SZu#>gv;4=tK~gf0Oz!R5C-gu`y`t*5yb0WumBfyxxI ztV+%j)Cc^jU#NalJb$}??v>J7r4#@d_|pQxC&9F?7knk81EuZ_(y5<|*QB6R;C$II zxN=2z_cAvhebI^|LMGd!K|FY1q$!-g~ZRwU<%m@HZeFos}A-A1E52 zIDc3qqCV-UN4*JCe;E-D9In*Szcm|-!ILUDVi0F10kZWq)yT`1i7 z*`6EI)qz^@gCQV9{p1$Kl>8Su8wQ}W{Ay!sRLi0U|C`6`Gq*^&#ztzY2pK3fX$N_o zaXKPT{`0e4%qLgL!hl$Pv(L29`S$b8iBAdppFutcslcedGS>(0fvvYh09ow&C>l^h%O*v| z?z~34;_6Sc0cy`nsOu|WC@h`#{JA2(!L^r%K1(lRUD3CNI)-4ywcF$y9Vgmv#m8ek zwEr&J9u7jL5_?fTmX~PfH_fgbL>!#u9e&S}w*?y_g6xDnS%-&+oM!Xcjo8u;C&_G` zcPFMLx!rdxTM8W3>h66cFWL9ffAl5~mZmzT$CIJQs^keDoC$sC^BN0l>h3VS>WRAM z&ZF;lVruwEr3giQJu<=Dqh+yw-d<93v>%mT6?~~b?cs)UT;8~MsJu0@G~5ii5^^*V zt~vB2&E6LqHH(Dv2#Dxll;+fYk&54@w8q_D*H~tRBUCw`PYc zcSeZQ(J+_*!u>$DA}1R)C#J0Kz!{)T4Ll!4l*&Hy`?2@f>doj)lW+!>y9LrJR&3X- zOVgC^183P16X1?Kka(Jwg5rE!FA(uM0hoI^wYkS#|HU^{j4`!o#o^tJU)iN6U>Wl_ zEKWp2RgTLPm+0NV1fLnOx589qaZ-V|LGCQW<>h!5zEQ?7_j48gu~1dKHu1VTyvU=L*dyWWF-qqsj@b)yStmdF&;f&p1s2f)iw!0|HueSi|+$)R5aZVoHo4j;Gr zdT*8%{CPlgGkundxEh5z7o|DB3V(5*2Pl)OKW08?zwYz(+l>qJK3xxo##uNrOyB^8 z_}Y~swenF@n?y$hpaloAyHXesKRguMzHtOF`NMhIRB*GUT|4(7Ow#*abJi62&0U0#t-kRT_4_HLu$Mx=_Ae*N@EkI zT2kZxE{rSw+z~WYtznT@8&Ov*0?Hn7&9^@KVf4&@X_a_E%$do^x^&>@VTykKB=ENm z6-i}Mv{N>@P%!i>?plfhxa!e<=wnx@Lk0(B9)%xgR%oYOmCnC4 zw)}w(9$45cAS6-%(7T`e!V8C&xxjC@%K*K9MU_=4exp7sfccQOrWgxZj-7Cumsrw#(8bV77}8eJ!OosImi2Ki`6}qZ?n#^_De(ZcedKb_b}48GYr02j=L1A19+x zv>`0RLvGZWEj%E~>PhLs$29i4>6F=`=N-@)Uw}F1t_+a=3fI z_E&iRq^W2D-_%2E_^Z}B-RsFgegBSRfX(#P*5^3?I;oLwu;jofh45x!;mX!cy-j5b zhafmg>@po7hPqLFbfW<8OH<0F5BR0q`YE*9#wEUYU!phz|0o0=*AUa5Ci1u>E@Jl>(`~g%Rhd8!7y_}Ev zaws@g5%Go>Z&_XVe{-A9t@9Kfispm-H_HFa6E)FFx3+VOc8oqT|FM1W$L|sp_22xr z$Jay}1*|@S@V9$YFNMzJ{wKM4TzyW|YMHvhAfoDUG5!1EEf!_x|Ebh#($uVh!O=`j zLAbIsL?JY2mA2<1oGFKP_-$p%G;Q$9Lm^iB;Fn@L10Aocd6OXRG`)Q%S^D9-mx`s* zzV8~y#A1k|!*l2B@D?eLC?CMCSD|>mTBH@p6~$W1A%`hP@>A*01$J`=tBN}C6l$+u zb~mQB5vJ>QHV;?>yj?%0a=*B7^247BAFpnU2Y+|Di&T@)>N&~e)08H-=)XJA7LDlH zjaHjBtz3X+9|sT{1WvsyU0w_ zD?}Rb68>v?|JCSP01JyY^kO^0OdtRh_UBmM-=pxo-E+?#nD+hY0BuijLrgyI`u=NL z>!Eq(^48FqQ3~JxmG+~91w@n2Kir$R zy+GT2+xdIXP{c?F2usU9Gs(ceagT}0i8ojmV=J_Dm0lX>6^m(|LDRG4QoFD5qVb4Eo7%*A+CTHXrLQR_y$~%=j;9c@A{6sBdcFWeOSH zHylvUwbB7NQ&(4C)H<%zB0#CtJRpPPcI6E~b5pw>1v0;@@4az*sB}>}FplX`-Se)j zF~Q)M1Cr@WW&b$$puR9pJ>vW^k+HZDw(J|h%GK05KSD7ujng!8_^vz=1gE(4XiFh* zgx*l#!q&^JMhhw_v{Gt$dUWmV#xTzH3QTY4 zT}lC_{GbRVTm55#|0fy(x55b^uz?2*%tNl^s(2sZA5;T1`i>l(Jg7Nv7u{?(tcub5 zBD^5b%@BcYbZKnA69U(4`A?+|<>XYvn#+sX93a|J2Hz4_9-UBggMDH26O=r;U@gL0 z|K2KHkDYw{i$lvfN}st6PSAu3PhgR;5{zcMf74%y2*GfPh zchjT}H7umsb-xUwy_d_Qbb4`5l&z6sOPSeW{N{$+g_K8mfiu0>yYDEtPHFdUN}(-U zaSTOM!NPx>7&i3<%R3bUMSv}Hw^pj!=p7%0yhj?3rR|rvTDiuw*PGt&8owSq7nd;L zw9rk>T0uu>XFnKn`-T}(Htl{C1yHZ21qDQ^B#-IMEU@hk5MN4L^G{6u0EgHOIuP%JN|!FY7bkXh zgXJPnlYAi$>98y1?&P`HWghT5R8z{XM&I_5h!-tC2bG{^>b=2RCSRH3s92&ZPi*@p z`D8r+<-%2I6Dy)b!3*ivdPCf^9_xOtP6vWYQz~zO`eY&Sb*1baKy&v_rRmdCegfac zUlSR$fDv){((b@&Iko{#5d^A+eLHG1K{jf$v>KYs4)zKhr=^Ip3CFEjfaI9lR8Fu0 z3U1)@`T9ndRf$zo$&y3q``GcXcN|KzhhU{0ABr>bQDWw7-KOCR)P;WY)?S&o@&8F< z;7d@yr5fbNz|Kf{qTO}&J=e$XQl0gJPsw?qCp;?A_n}7rLt{M;65F6hJdJHOLv;MG z0AN1CBXD4zYI`#L?hfPyu8Wd@Pn3iTs=1ukp_FPwZ`Wf0bHjls$!!P(Q268`eLv97sDHPxb-NJY{am`4mp($E{UcxLz-O8= zIRQ;^+opNnNzt?5eVP1M(_c*WZASA4uwK7JG>7K?wn@Y02zOHQN^zVqxjZ6pmAmb$ zD;-e-VSBE*;}o%gKYWXZ>nj>`;_gdVA=S4H%>^k}xly``l15jVzIiCQ_T zqSkaBTv=Ldh}lwYRamqy1BYk9MUK2@{}Z7AlG02-jIleD=j-Nk&lu3nEaxO6qx#OP z$bWyIvLyP8{PqX&uqOZ%*)|pix~dWVR@`0fjhI{8SJ*d7#FN$tKkt=1q-*Y-*8A>8 zA)ee8N+tT$p8eCcT}p9Hp=x}fiWhBM-ckX%?NdDEyUF*=u$Lpi{Ijk&PV@4=vIynvC!(#HpqXfMaXeYum>7Ij7;fpxbvZl=b)ZoXD_{*B@0coSlt z?Qq^@>A?PzEKjs|2LSiYVU@kksK(KrB=~E8Gfmd!3m7--EDDus6r*KXGvD1awLnYU z7&Cg?oARoQQ`@3yej@#BegzP;B3#CpMp>KN!kE&JwwzIkVyf$<(%q41yKvIGKBy34 zasIc${M1HC{b^b~w^6b7g`J7NBL$jiK2x@zmG!G>ICq(a>Ni98g&Mx&CeFoObP*3~ z*e|Ik`pi-_$pspDmo$d2fn){2Gzs`gtey!KnMl{|xS@{UbVues=NoxcG)4LpAO8aR zu&^3+?qeyP|6jVo!)6D&Y7wXZUxbe&;(F2@S4e^L}(VIO-F7yw4uMH@$ zKgZm16}-}yMY%_LoOI>2KcC75-)iIihq`HrmI`r^n|%B|>hzhh>_7f7@tieCoD;Ud zUFf$KRI90gzYiS1EJw9Me)UpzQzIl}pTV08deHip{fTEYK+O_N#e#5|`=n>;Z9A$n zem}-7J-u?@3FeG!*)DPt@W(}}<{Eo+8PGl>XWvWf<8fB?bD`sOh$(5@AC2}G+mC}5 zK^#IpIQ>ASDlAM&s|(6m-64AK$KXkaVJrQFy}+&w>Hvl3VR4-%3cG>X4Qu}6MrMz7 zVVh#*@dRQL6X|S#Fdw;9GucT`eY3iKqG{mUd(6UB2qC~)@bu_Mr5=!&kt8yDz)3a9r|&u7Uet z^QACGA$ED6UN@A&*;2G7n~Y`WZ&si^UKbz{P62fdXf+mGloLSEivnUW>L$|6oc|~f4?IRl2=zAjS&YOGw{iW&r=7u&imrB+>Td1&5BKqZTWFAQ zl>DXXk7N@Q3x$~D~=Kq3l{`E8p1fvj9?1{O(W9de@lj3c{RnAza|lPG9&dvHbESMyp+Gh1D% zzDmiKo*x!nn)7(a-VHIQnd4|pyxB+K(%{&t>Oi36#NA69X~v+wW*S~ZytMThF|U?* z%dt113`_dc5hiC%Jj#P@y8n^ca5!$YpqV`>Wr>j9rdJ_`2^|ag_|ZV#W0w5o$lv=+ zo09wOE^^#hF{$Dw;WZIuI9YTpK5lw#_vfC?Xj<@vxV$@5H~EPe|6&gfPTl{`0(3r* z8oe~;Zu%W{>RgpG*FUq_DN$u2j0>etzdNH#mvM+~Q{OXUPw-#KzDn)45jv3k&^+OI zP|bcQU76hTguuujhpazo+mc|PJUq`?XIL9fIv0NDKqJ{^%gSGq@Q^vis(uS$ZH7WA zoc1Q!zo^;UQwJdpHzYhZCm_aN+mt+hKSx=OH1EMkWgVm=Nn`_IPn|~o02Gn$62law z?tC&X)$`udLk3BZ7C7LzKV&qMfQI(#-ngmfDDV~ejznU6irozsZS(vbyP~)W?}#}6 zZ%D+Lp?$j7JkMd$p9~loV);t(13Q;U>uM8R%}ScAu8F&^tBF?bbLr#1%E2@oyCYh| zq31_;GL_#o3SK=F`@PxZT4LQC_=P9+fO6gJig%y*c*EY5TuQai=9dm^EiMgd?8Y#* zlg%M*1`Kan+#i4sUf%2@wr0Pd;mNn8hqfjlv=^uM8t2f~NnpVcWK52MizY&TSwD?_ zAYHJBzu5pu@JB96ZaF$n@BcZ&<)N>iv)P(aa$EQ&X>u6<=y+orhUJud+nZ48e^~e$ z%dKx1D}ybrMtclnNZ{DvbFS-p3q!(7sU#=vv&5*)vRLRgg!V~HE(T*r?9i3=wG|{O zfCH5x9Od0wP=HLHe`W^Y>ify0AHr18_f&jCLuQ)P!& z9D6QcNa5sZ@0%%s=sL}<-V1&`+7ct*ez+)Z2t<4b(89yRi9|q)g$mN&ok-JtiNQ@I zdR|@s=pD*%68_7tO?*gwUF0&w^6vtK(%u-;u;-_G?WA=@xO}x>9FH35bJ`BK^(iItr{BCkamXX3gS}DT6+LfKq zGXfhu$66PslrQXd(s2bxFnezs2yoaBWHd>ddM>3%ive=M>)jP;Yr=)A4=y}tD+Bz# zrFQ69Q))HIVx68OJ>S?7aKvTZ>m`GVcdS@j)P*q~o#cG2t&n4=h6lfRa^p=~WI*WY zXDRNp_3qlj2)4b2=rLx)&(FhNPB*vprIbIheR*$aV~nKsvy^-=A`cq}vX4&P%aKnb zb2VY_ih&R|cm{@Jv!YxgwEZ3vn6(`4qLK3=nRsG|aa*3PkrN?wEfJ(uHDCxdc!Re} zuX*1U1yt4)ec4(9lH(vo);{p<=B)Wv^Uw*VL*b~v{4cpayb6nv$D#4qX(IQ54XvSa z0b!RXP2a!qFih7DOSGOVB)uDjI1^>-WpPX-$C|nJ=zA}Gwrj1C@AjBp$=VELXlb6M zhXKG5`knFn?E_mC-?g0<4A(hpm);W{{lg28oxfyltJ{mEH&x%XuXsb?2-pR?i&i}xkp>Q_EgXJiNa^|_hNkRE6`jf3N`MD=7 zjt7wo5j4PTg&8Q`6RKOBm))+R-_ZTgLJjjgFERUmm<$Jb)7nWl0b!y)4>`pPX`D=S zTxQTLtRNqtQ(`5bXJ@x_9D4WSHe+ER4vB4?b|@FW8Mr8*DIL2r?PVEMls$XhHA zN7Z>WpE~2&UE#@6b9zu3N}o55zjXf}5Z1Zdya)MZ`!y$qUvsBl3MqlM7#8H7ym$Cp zm&S<|^_25=Lhy!c(=L=#Q4=JYj(B-T{jYWw?ZxzuuC+ZUEFb^Z_UpyJ3+-1L4-OxA z39FD5fgrg}eLBt`(iUrgy}p`dqIBE;aT>lpy+395`Kw*v_R;0U9^)Y4_&*&0H_ql3 zv#oeV&hM}?LVlb6{pLtPgfZ+eJ#^xB@ss-(Ez}BRhn@vNVf~3n-=jp^`PUqq& zhwmbzQA1GaQ#$owYe9UQ6^f~;gig8bh$X@ogXVJ-9rSI%A8c}yJeOvsIntnngW`qu zVb54CYkuj&O@jzXz0E$;&iCav~@YIZ`Vw3;9p=atHfcoZzA96tNF< zzv(e8u0TmceOjilz1lVlJ=TgI#%u-8k)O&3E2XGjgS|rx%I9EBW#cQyUde2`FZPr4 z!RXSE)y_iLGT{s{ZtCYa$+$s9Ittd~kgQuSoMc)Nm|nT61@Ro=9Y^o3TcG%pYNo!= z=O2!-hK|Ef7FUrwX*$8bX>5XNbdLA@`|Fp(_1IJDTqXvJ@{h*D4IByG%UVO8aQKOA z71V5VwaFjhj&2!ddZF*wnXZwny=sm4b+Cx(C!mJI#tf}G>peKgy4DVQR!rTe`Ggwl z90Dpo`<+efZp4ch9k1oWpr?d2G2s?9h&N*sPND8(y4vXcZjQe{zJbM*cU{c3k`wBM zwVQU5O1UDpl`^T>XGq8-Yfpo1g?XM?E7Il5?f1}+Sy*eU)f9g*4`}UN`YmO?6x-34 zC|1vW+ws?2J1}XR{Owh-KgsR9LoKyq!lim`<&^sxhlF{n9alc?LHnhiJ>S*unM2bC zAKRVBLisa37uGfolbzeYyE)4i!_?f}F!$M>v>U;Ko3h7(!+>oFM0GfFP*0HloE^#k zowQCT_Bs3M#Qd5)lHJ5tHR>9hwUJJd(CwN9BTxpI0M#y^k{0PO5yj*#H6Mawbzg*O zl8~h8Q>S|8y0vAk+!@@+ux2V@R7ghB>vXGHXnXw(hc2J>aL%l~Yy5G4IGtFkl;%&Y zwJ#Mn3uf~mEElxV=D4$joyZ;R1PC<Q(-$yEw^*qF#7Fc$g5ua zi+!99R2BX5*pvq}&*$8aTU7k-6XDCl=>`^u;$Bcm>;8S9;@xFJI*j58S5ZPl_}W#d zvDK)~xsKqX;08h{A7(ck{+WtR_b+hOn4HBI;-;OEkz7w$y^rZs*<2S#{cD#;^;Xzi zkLlXnUnEO3=D!xX^}`|^zFsTsmM-);L(c?$2u?{hO&ngxuYiKze~S-FeSjYF?K{7U z+mNJ1UJTqSp$L1vnU+l$pZTT$&$!XZ9S^ip+CxwB zdcxtP(~OAg-l0{x(_}qdP2q=%ON2%-5Lg2hBu}_FZEViC*_D=Pw$DnVOELopo0iw0HHyri0 z@nS#w2$*S#GV_29YUXU#0}cHj^cpAg5R&UzpxOpdMh~TuhV(~Mu4Q9z*}e&JKGXa)nDOBaFEwP%KV9JS z{kE5R_qWoumLbv>A{?}7d#8z5G`u%%sNDO z2oW`k{1?{H{dq|RDZg3q&wg`yVu+LJM5dS@9X#nq+MUH?9PDz7zm1k1k-H&Wn5vQ=E(Ka~M_GXpYld5R>NW19@vhTm&=Ov94aU%)C``_dG z>$h{tNhkBg~H9!#ohrdG%-Jn8pyUGwZugdek8ph@Ae*2rHE zulo7&Td*n_qqL%0bq&5QIgxP4I02(7o89JkQ@`EHanrL6aeu_Zkx|(%Ed=q>eKwqP z>Pcf^X&s?z+DY+DPlGrXLdVseZn#+lZr_Fwz_lHErtWk3aS{b5d+f%cig(daZ=IQQ zKC;s3kS7DN+8!y8)=05@T5jd5ny93(du*X+>^%t{hIAnkM)g?yPe`ZudK%dV=C!W9 zK~##gTVLf^7MuNu6RXd+{4ezPKq+q!`dN>tE3r`gtv_iH3l;V! zc1CjT&Ju>xN$;yoJf}UTw|3sj?{Ah{v==?J8|j=c>xY|d%g~SG(Vj@vQ(EFC6J#1b zJ_|f+eCPV^^8fCGXHuL?dq4k0fQVM6DXAvbMN8Vl2X_`n3Ov#7<4xpjpVFwt9{;qD zsoy*p!=&ui`(ymbC9BDB7^IXAorK)Z2QtBi)vRC+zs2M{Uz3n)76H6Uo!r}WSsqtx zXPsgQ$<(4t^T-WgyI)5VM$2}E{I-$0Q261ctBhY+B&&|ycC)xu9y0h(`Ig@at?l|{ zcAS@jd^4vUA@@3v9yg6;bH=!#l-x%mRVfOYBpTtaG#FSb9gV`?&g4p3^%QTR6LQvL zqnL?;=ICCK>|-3>@o8Vzw7@8f0U!v+Wf7rU!FZ|MH8M&B5auZ7 zu-BxS?fl~^(LVs`Ts1GgFrw4gKd2{W;5tmcOG#{$1!f=|K54L1tXH=G&l8^x1n^k+ z=!pttl_73zxE3tr==*$D0c?iXCq5J%G&!Zte>k;k=&3W=c)qqXfV>&^tF8-IXhXRU zI>JzX2Xta<3hyU`zsmXUE@G2Io>7lOVUrmSJEo_sBX!H5>BuxUd*J-#4rdOB_grnB zbHkeMbfcBEPF>&>h1uK&%E#4KgxFL{AEuukJr}6lzEw7wuiGCVM|A79mr`ongYJj^ znH5rKWGpc@BzU3^h1mDQW5XWJ^oGSF4(t#^S0-b4m`X&PI(aQFGtAYSXXy96DavpE zY)^G|xHA5PQ04ffxN7h3ehG|@Jg{2+A(Hu-$zHMAMiF|iWm%{f)u=(G%8^vH`nldz z{!n>=W}-}IL@-*FZD*-~xn!jMw)!al707^Tv2p1b4SG(P@>{kEyaEBqal zRo)9jSfy^29^+y^uV@<^ni$xzhBCj6Bf@nJ<60)i5njCrzI3mrJ>0YIJ+DrEiCsMa z)pv?EJ=A)&HUFb6=*Efp!%pO5f%YS0-Mj6~`fvsO>S!n=EMYDfLFx_ihZRV%EqOa_ z4?$8yMQ9lo)x<3No;67P3n(=jJIyKeEgt|4htb=9ucK6~!j*jvufO%7E@LRalScEu zk`4)>6l^YkGfz2OR-ZhV+!mJxbsrrSt3uqzz-E)^&x>Dp@AUy- zB!~SO>x(MK43p>mRJ!W7>pHgUkyNKI`kJn~P+)7$s(@ScaKZv2<+fZjS#G`gtHp>% zBjuo)r4(aVSj^$K+GM33udv@MW?x(ucMZ_~N(Pb&Dm6#+wQ&-9#;$9#o|td$r^}Lt z>0~zoD#)m%@}zoPr9#p_BRP$NVCr2`7=i2 znm0{>U+58M#m@Lf$-_SpkfUS!(ngOnD#^|LqediAz@D4=8AfI)hC!D(NY8Z}&vb+I zDFPNT>)9f+Y4Z3ZZfc`$!}5`R}UvxQRy^ zuA46cLTn|Yk$+D*(+c$tH5WF%c1iE1By~hirp3pwQ;)rIBaS3L{9@@h7TKfY1qJ=T z=B!@(ebsWOGj8?RGQR!bKkaI25h5edDcOpHvyXlC4fGAh9%>i%D=t%O#4OSsUD=on zMk1fO zBS6rnr-uYgf8%M}J_^MyZl{vD+&OyRDMB6x+YodX`V%J9E4|CZk&rv>t%!inFN*LL&&UYr@1H+OmRypJ%00C2GQ68Em7r^4yK~EeoU{la zl$$a>)dElOJ>MK^OG3XC*LMG9g1$=bV+d$8H=cW9gofs*h+cb-&t{g?|NX9y?@ zQmNTI=8$tcP~h`lpIu;2MA!_giEo7H>McYek;c~)!~RYaREq7hmtJ!u)VVA%TrSS; zFfl5He7YRyDJSEO$+7YC@mGgNC*^ zU>2P<3f1)DZ; z7ShSMJ;D0Ws-2Vn^{dW;JU{Y!o_@~W+prGwsj1IePC99Z>2k}PM<~-$p%dUVKJ67l zNo~xOj~iJxf~w>i`@_|42+4aeOC`ARbXK1{Q}u$1zfs_)FhvWxN7F6=RJn3eIU(i6 zVzxu6o|qu{qZo3I3D0!T@6aIV4U{)AXjqdp+Nwq$#(X=x+6`u6bN_UOCr?4zpGctN zQl0VZNKJM|;B7I4dnMfa{z#%bUHLSCt>SHXn>n=ymT5t$X*(=2X&HS1vaW<{DZ$`BqENj4Z?dRJ4|0efOkJ~ z0df$)L9DTAN6F)Y!vr53kh9m2YFE-oi_uy%GH*x>x_^Y~lD}O(-?6n>nt_1)%5M?9 z#J912%^q|6v(>H7h$gyk>E)4NV1FR{`5`~A@FOPSB&SMrOBQ9^S%_@R$+sD%iLpnL z|Jmu%0?J|_Csi1+4WT#uR7nb9y;dL8!m3pHJ4N23$i)6(tg`-1o@;T&+IUPg+6(YA zfn|6#&fi#ap*AnYb1|Qxoz->3_4N?@mt*Dp^P)y&C#pw%$uiodaf8j?@IaTBmsK*b z!AyPlL5?&bqGO|Cn0*I?iM2p2pzYvHRDLW)*7*VEhgokvW7_`Ekhz2I*Lrq8yH~F5 z=Fg9@!7F56`97S$*(U-82cq)#1?&97j^72OF<)et#Suf1ujMcCl@ z6|NVVeBq*A{bA}%LXonm2U3vnz=~0E+qOZ)rBq`>>Rnx<0-eNoQ-k)dj|sVb#(Qf} zIlafC|GJj>J*bcSWEV2A)-rL?yWJ25~V@p}Mleg(buO6Qdoj~MMc8$$_nX9w%{TG!BsuS}>$F3_gqS91`oG?=RM_im0eelAjkkUq z8{ia4Ev}mJX`ZqlK73ZYxmn)NOFca&AbZ1tr!G3caQgH8bSW)oA#u;6I5zaV4J7HX z8aew5i#WZ25IE3c7xfw##LA!WKuDOi;oPliXHc@6c%bpB5aJ*jhBbM7VRU~;-jeP) zi(yG|-DGa^EO$ePoiay!)GURdb{Qf0`@$v>XMUQ z(E`;8zG&~2Ss3Xoyq4|VPE$R$&i>UNgy`L~*pP;sgyVTzXdrn*oiT;3jI-K<1ssMAn=&aRKUqX9zMVL89xHB$(Y?Y^uF>qs&@D zzg#bym6$G)wM_-n%_UjPDRB<#Gv3bPc=1i_zcq2t=hTS{}$!d2qLT-M?v1;n~ zfbS7f0X`y&B!egSL4n-^S=i*fzBO3S1Itlq|ToJg2j8{yz7tFm57^ydd38C+a+vWA_(64h+9e7`qiK5&g1*GFmz?)>SE8 zEktMlekA{XhIiqr?;;H1JBUZdPObd8#n|5pY~veKRfNIJmd}l(iHPJ;#mfDiMy&7o zGVh~vR{_PMG`W<;G%Cr3wT2%>PQzY=&<%S1jLV{c!t#uuWiM!Oc^q@tjq6X}FXUVH z+YeaC6#aHn+@}5N+aJ4o_5#6dkh~f5*|`Y0%ALv}Ji^&vxA%_CQSUq$K^n!3)Xymw z(Q$?+s$T2+S?Jm_TZ(Ysk0kBWRVp)G-w|f8_H{?xTtn<7HQ9enujf{T9y0jhN3Fhp*X8Z_ zwr?tyxY7sx+#Qw7^EZ2HM*Ry|tnc7Tb*}!ph z6mbf{2pJ9+V2JHeDq#?O7wRhqgxy{g>>XqEOmB9SshE-K4xw!|@3GZbt=1B1>BWKP zaA>zY&A!#eRX7Fas7hX__8_Y9$X8Z@ixsad-OQoz?y-ol?CEBC=AvLp#a=gyb3@lR z$=Tz6W%L(R=-3e;wGtSf6u-2ba>@QCh&YI1g8F!asRIAyta9tVI-nG(o-yyqR1Th( zCE}Tu!=a9Z42OZ8xt4Ibc?#c)=|{@;b9BjN;H;(oNUIgcpUcNvqN+syq@6`C#6EQdfxG z_Nyn&dPbXhj=H}nGEfx0ef@6s#V=6-&%(vXo?E`F(}d45XttI34N(nu?C#XB6qJ;d zDCod7(iL<_SPoJo!;c6BAh(H7*vrHrp)0i4kJq>%JtS}J;h-v87rd$ggh5Jq^iDL0 zxiz5BFbG(v)+*P$nfv66P80QhZThz5#GOlZx%_;ekmbqy-7kl+deG?l@czGlwWQrq zQg+&9LT#mj$U705d$innT+|SLnk*&N5bfr`ngs1;w!aT=R!8%}Dr_Ald91u>-?@l< zU{aRZX5{Ir=}U`a-d@IoK<9F40O7i6a0|Y6z`L{lyd0KF5fn zS6-+sRg?FJzvUivAaB8s4bCzb&ewk_)gh&z`Mjk+wI)mOA?{jBwq{ z4|+20`~x4fQYYf~K3BoAaba-u0fSSu4rVC!tVj27|9CpOC^w4nzq5c+ho{oK*b>55 zd@OsF_3qXNSZ{U3OQd+tEFNp7wEyZ{nR5Tk>j1HTj8^9xm(Y-^sT2@QNO$Q2!41R- zE@$cOEihtweplldq$d&QnV1TdNqc!b?cp5$%LdpqVY8vDLUOQm+ZDOJ9WqdzC(F41 z>{NgJ`2e)R-ha-MBg@S3_N9VTR@L&RFvmd?Pf#_`Ydkdfr1IJ<3$C7Zf>jx%ekZ#R z`5v*Vgz#FMSBt(UMixat`V-ARn+M?ajSvoa|FdzXlhZYdHAl2Ri34b1LaW*Ro6Ukb z=rF-RKEjUv<=S$*OTR}kDj+%QwVY_}hCgxN*xN8b_}+}f{?ve+ZKn92W;vI=dcH3; zXS$DTFZ}v01@>YkW#37Grc++5qWGx!Drza-b3lkpyYC2>YoK+i|G> ztOh_T^c5MLZXrqOq=P`ew>=U?hLt~EobUIU6wIia5Kip9N4Cq;@x6gxqS?Jxn`pqY zKQN^Bppz@Sb9&#Xs{6jPW`8MSIf=lbTb=5OmWF_jgML5b>O8mRjenJ`8h+Bk=DA&V zy3KXsV{o+-eY%<2!5J^e_x?+@9Av%04K?eT-YDi)-OdX2?cyffsx>)1Uhto?h?xE8 z$c=Q}&*e$?;qB0bob?tNOz((pjFoc8+g0$=IQ;DA3a#J%Emp$AbCdH%P7!M}*^v-y zvk>tV`BScibTZ|SZ5Y;=u|Upu7pB3s@w*G{sqSMpqj_FLDSUpBYi#iCb3?Q~B(G)f z-B)iN?>>4d{kS+yw6udkiK%xWpSs8$&F0%^UF|4)7VX%NaSMK_e-iX!l?_>Gq84Dq z3!$oCq{m_{ij}RR7L_$xlA9BP7Xuxo6l|vQ+d*|t@lK%2) zbY?p`QFF>NiZ=3OcG}3`N8IwBr}kqvP+=6AUb;VJ;itvp&XuD@CLYPCJ1p9e?g#q% zGnto7YW@c4nff@T!>OknBj9ds^b@knWefKd_D2@H8@-?Z4O&T4I4aHqw@xIsI(KR_ zXU6(?$STZ@!=b)dmEYUKjeXMxJAF_2W-7e*M~^dXAsPzx+AfPe=FMh8IErEdI6a3L z`6uN(?L{5UQ6KBWiS^hYVn+rH>H_IOj(fz~|e=)Y?t9 zNfsZE*lOt&IT^c+cgJM!ZJ+%8qJ?l<8Zw^4zs}-1{aqfOc!Tt1c;Z#ti(0~w&aU)fF%90k^W)XC9!~6fGj{F0u!4I z6RS0Q-B1B&)q8B?QSAy^76?hJ)#!QHeI_C?Cu4dv(wm)K(f2+X=eTXvjnd5cW2QBw z416zjqkpVgU*+Uo5vF33wQ`YI<8L_&cL4-w~3q z7lwTk)=D@!7Vwlyed&XIdrnpCbUilarMp)#oQ~^FZh(#H#zSHv->>V zE|(_}_8T-$Y`G#nnYs}1Drz3YNH`pZ!fc=+#NEJZ1j7OPm-d?rs|A@iYVSwO!u);z zel!rPCN{^DEOzI)xk8Pbo^0!bj?IFc-RqoCxldnGnMKFzgSt;~k15)q(~k99c@Vto zA+G&J@*jmq1RU6Vu}eB_Lj8}#t}+TX%uDJuVjG!g=XWoEJY8QEDs6~PxZBB)%PAc% z1rCxcJu7}sHHt+Qye}$$YM^4C7SmTxjVfS0Hy!Wi9V~LUlPlrAn!d)JcR{tGx-s{#nYzq%44T?y^$QVtR$}}%>oJ+9Y%ce!b!$yzEjA{@7#qB{Mv+pxi(r_)GbFBO z{2NOQ=D>|bs}u!Rqb&-=&A>7Pbc1>-n{2~DTD3-+d@=Snru2B!kuQAOpWyFnx%|V`U`;cH zxM5L_IO%C4t*~5`bjY&fXx55I85ma=MA+iRJ&U^+CObA|1#vlk-m>+Zr0FRF=M2M{c&50XOjhjVQWIO)su2f!Vdd46 z;SoWgdr*L=v75>)e05_I^4n@6-#*<@RY;5o0Ye0AIgY4j>^BJ$BK+!=a3HM5L(=7} z)LX7Nd$Qbai2|-!5E#U7ajna@s?#C(-A?{lt0sScJ}utNlf?p z0a2p|$RuT3-ahW>@lQLouugT?V2>k=miiHQBm8~37rVS-2RqpPrD$Z91g}5Esu%mH z1o~eJi*N87RKfK7pP@*E`TxV!SH?xvz0uMPC=JpvfOL0vcb9Z`3`&c{NOucJcXv5- zH%drJ&H#ddv@sbWPcoZkY3o>NdGG2we?tGtbKAFtEzr@FTn{ePzWZ*Fg=ObLFHAitBFAou##)1GbVw}y=QL)tCRlo?=nB8(q< zVw{ny-ZI5s*1^}`xMg!$g?%hB2s%^vDJzjQOseje!;``V0 z)2ZJcmiUjJ6R!S0H(mvS2W#)+Uz~Q%OTlK+M%DedJxF~L0`?mipj|>#;2$R^d4g_C zWxAm~!+7EXn1R=CZjQ^e@leV*6!qE8#o6SG)#~uBYA$o}_;4#;9%iDpGmwD;vqc3@ zEgCm_k5gbMn8d!%CiltA;=kiyBq;|O22w>c`UKYRdMy2votunr)MvZISPOoea%;Y3 zVbDPbp3u|yM4K2uZkSwmg}F^cxyUYd9+3=`%HIH{tN7)St?>yCrBKMZxK-$bs*~0S zzJ0*% zLTZbbO|>KQA>&?%DPh1xK-1Xj&AsQR`Dr`nZf*cLl9mEUudQ=p z$;qYTg#p*=ZlPDH$srKi3sq+GixVq&F>DxD9I`uP45&9N11@k&yV11h?n_=K{b%B& zZ{M!WcxZ_~Sx^es_g|+K!fw|~?t1?MH-+7rm%Kpf`RjB_cuQn|#&=Y$5(!XDJMs#> zitqpZW7}i3>5Sd#UhnA3pZ<5hvinjQmbK10zui8Nux9}>^e)9~bnKUug%962g?n#* zyHx^sCs3+)dw*tkGpfIzHOx+?7C-AH?Rz(K)Et;|F)sb*PYYsN>UuC4(L5XC*?m;v zMe}AU&$Ih}jM{(mJN3E9cdx&5b1s{1e=N_%?qJJqMnK(U_iA?4b*0XfK{+Yt*DmH& zr%LwIW17CUq+S2pyXpCY2h;68e$8SR!P~*RX)o0Q0rno!f_I((tx4ki+hHQPWYjx` zsq-;;Jn4{8FRpp^NjvB9zWc5|g14QUkgHco;th?J%5Md8vqSDPv-!TX-pthZKhj!> zeWzwV6@3>Pni^R51HdG8Lwji-j>aK)9Wkh_mz2UDp}X%7;~#GF*tG&4u&(Yl`5Sj{ z#XW;}1~Ius&L6p-#5~_clqqMf3g?>|$qFb=;zlDEpthTStt^jd7Wy78AzH3C^mXxsE zuc2q$Rfb9yQw-2|SN`w3u((6!(Xa0E{l9*@t9#XdwrZCY7&yo-ycxj1U1ZypzU?oF zSQ2g=7%*#FZAq8{n!w+8^K;Wb85l}wuv!eu2Ox=7c5RlYgv8THi2>J3TWUghe7-7^ z)cd_PzNSIT9)yHX0~Sajau>DSS~2_{L&1boESO0FtM^dIDQ1TJuj1s;(Dkr&yNPtrwR7Mpgg;?dQTmg^s8M>*c=D;~ zr-q9sx9pFDS^V|Y(<2y6rme0wqtZ`X)s^}D4pRnhWhOpDE{l#cfOAY=9|6!ZJ)9Fi zmnz}zEO~$gzimwuZTcS;kQ^91{Tch}cGYdPrCg%rjSeDu%oLXIK}bNZH_yY=c=Yctx>4n0p2yUYQSvpTE( zBoR@lsAV_grab#efyQvcH+vr#!LJ=mX|?M)ffm?|I)`nfX>-sN`gwO#XD2spWp`_j z-U zVc>yh|1hTX&s~i2B+x@Aq-Zep2uWaDrrfBL(?> z2U99woZ3HuR+uZCHP0Z-SO+gHbFqv1RM@b-4g>F%3fFg#V+@~y|`s?Q8p3y4?S zzl|P0qL1TA|DK>d>taZbKWloz6+eX$@!YSsyNxAM2cLDH)08^p>{Usi1b#2iJ^&AP z`yS2#+41}Ukf=-UgrQbw7v3VcLnp<(;+>G(mg9uCetmuS&58X)?24deRyd^kOr znY5UC917PwSNoIbWwj(M$H;FdTA5%U=>PV|Fzs<>>;w>=vb)U=c2XkoegnJayQjQu z8h*#?@0A663F2qz&xspuE2L?VW2W7txw&){UHqqmUz8Fi-->)@yICc%7}4Cw zEfL?lERM${e`7S9HyWG2XgwSmLH}ZcXTY#)JQ^;D!%_i%Z%kSI?Qc)>igO@SG0_Qw znB3mSZQU*Fg!Gv$i-k)^pU{j^<=df==E)9iO~IFo>o=pXp0S&G9^eOwlt!>z}ZF#8<{Jrg^8}6sx?1)ztSo2rAj+fA0PJtcBLqDykfbitb znsro->1hr)K0l9|8@a|n)h<-kbIjp#{YAI*_!{Rt?Ep`14p0sIW-DaQAc}|Y%dq#= zJ%8cA1~1Xz9Z|&!4RN#V-Fq#?6YEVDzKYO63;kCv`%FE}g+VkpX(+boI$+6MI?e_b z^~=r_ZZJl_7OF^cCYy_Zkgp#~+_8p2>Mfy--}u~HU>o7 zBita))&em4!zJtn)f9(Bf$(UGyI-}Bu2klZAJDe)>~WfNMd#Ou9u%CMJC9~IMSH?| z*QImWxb{%%5;}U}p$kJO`hGd)#alxyk`Qip@^>kx1fQZOe!*pP%PAxM;7OhCcL_W%Viqs=PUlgb-oJ%p5aI1jusZHc9LI2^7U%i zEfUB&^Ppmqsarx1XKFjz#f-?#1VsUBg*?b{W+&<*THqC#FTzSy)wk=Txr-wO1}V|r zsK;RnofjpB$_Rd1UOv;rt0D6@>{HdR#!@>8()AKNtZt;}b#Xs;(v(94r8^UfO`nHb zbxRgW#JOH(0VV%3httIURS$`VN<2{OZ%o9-Bg1ma>}KBJ7F)UrFgP}T?|wdz#on*A zkz}AuRwp4jbANPwi1sCoN{&!eD9pv}9-8C*(KTO}e|{mC(%?hP=4f?Uh4@WDm{oS2 z%GSfLwdXS)vPL+aK@Q{wtSLLqdah$)F$Tmu2C|Bxt6pbyu2yAjWN2B~QM>7%@yihQ9my=qr30?Dnuk#Hc^LO@rWWgoZZVs0iyqGI~1 z?I`yW`37$$M8N3O%xLZDg8akXIOCPCfj>yVa5CE3b0S_J`EeS8tPi?${Ypdxas-JA zCd*h=THiT&@=V5`a|SwQ7yER_lVAeAi}qt2;|41BXZSq9q-Dn1snOc0!tW3H2o_VE zz&(XN)MIJNONzqhWFr00rWmoLYBX&0Fmf2lA^g46IX+xMMet*P3aBU0S0Bk{c#eFwKO z2}X)2#P)T}DxIO|jBQ=)jC~4kuSOC_!LaI^3Mx;$T^9N>n zh%KgK?*sc+-tv3CaXci0O})7<#PeTVUr+ep&>L0LLoZEM2%AgDQaNq(pCOY|8u$v* z3c_Bx%E5i)q(;gM>&EKnY;#b-&#^9XQ@2A)qIT;JX7mC@$2L>{!O*;DYVZ3?@=Y8< zAk$i)N%5H8jyQ*55keX5EGBr^G#Z==0OJ_QbbfF#at{m0p6!|sFTzLCray-x%FxL}pYZ^~JNvyn_@vVTt@ID%fR`O)lMG&KLsd4-hnPK>0+Ao4c&o z0n{%Lnxf{Wy`G^e8X9A4;buUsNdHGb(NrGiwKCY|qh2~kH`%yjKF@cAI!OugCniR( zsF-(q^dkkeX|Kv!b6&frgdH;7Yq?WVy5(OptI7o)j#YK&QX@d$csTtJ&rP(I)RbZk zBoY)hbo8Y3SZihL4!={CyQ6r+jc~!1>KL@O0WXa3J28%5%`?sEuaLHH?5D9u)WD=_>F0(WFJ>X1+3%{Q>ftO3by_}bhRv2yQzGAeSa1gPrS|L ziMm%qtZ_t9a377+oC0O7x4gYgAv-`VBay`W9{!39Cn2gc)>kcO1E)r)6OTX6gjJ1Z zJgS=_1&^s=%g~e`BP+NrBW$xYn)OoSkkx@NJ!R@wUb^;Ca@>SSF(4TK+av>11ypoI z-?7jU|UZ` zcTEeO``Jac_!7TaYDHhdNt@~=CHl&PHNh)=0y$aFe4SMR*e;F_$w03NU5kWQEO2A= za)8@KEDQ{R;RD|C$kbp3U;5FUuZ0e5uFSTP0h}+kXfj%zMzEelp*Y&O;AUUYa>AH^ z5Puior47W_0xG$^1ALS?oH#1En+}deug{x+YzU4@GgB#*+$=xI$v%M`e{>3UZ3`yi zd4(X=I7VRYK(@R}Nb|Flp5p`jh`1Zw5AqXZNz|0YT$O-!6(>RN`54Cg7scZuF36}* zc&tKi&X@4fHqO;uMp-Q?nzTxY+!Vq`;|K7T&)Ox2E<3!P(NQy|OILTmq5*qvEQZX> zQFHt-($TtY_LUGGJF6XB!zpx!WQ1@{YoBcM*!cTq>M=xqN=$x*wYvoruDOfrvo|$J zE6s=nsXxtx_q_#;zVg0!?Zwz1tq;7k*8sOgxXd@+|%D!KE(?yR5Z2*M4C zl(GsDRQ7%lG;^{XG;LakHhziW@FYW+gfm;LVo^3eF}i1KBKlX+mP1nYOjlhLf%noX z(A$?-QBmdX>@)0ZU1(jijwQ`q(uVim_Fb~U&qRE=4lz5Vyyf6Bc zLebgaIR4wx;rxUXSU&)jB*Pa`q?mUk8LhQ%_I>VmSY#IQ!FsiyK`qnKO5y%t7)f{O z%PgN&RdcQM`CiU$XxdQ(ST4YS6-45A{J^d7OW>U}iuKl#r?#UYUWqRhtKD_zQk-)s z`dL&FQ_c(Z78g-GkBV4P{OYq1^jQ=tqk~(VfFqQkLDIBUUj3Yfo&lO4DIBe1o8iXH zGvWWmQ3}k0r)eWxh%XZo&6K=+mzg7yr9E*xeh>YSr+pMp9zn!BvO0^(F^@MQ!QY)^ zX3bF$V|{>K0mq5!L{U?JU275;C2PAMt|}QAx!I8=3Z-XrM&J`pR#zcsD7B=Md5zk; zA@|Pfya=1|OdCf@4UBA|$rp~Xjf5Gtzz*t!UZm~|#<0+i|3In{xkyA8k51_~jTyzj z;)Q?^hIDvS6KjiG8F4s-?GLcuJ%lcT$P9pKNI2wm7zg)8I{FGJ$se)_0)=m;=AWGZ zhXqi{5m2H@-8rx0K6PhduE*7lAsF z+owERMaLDevM)z5VjJ~C`0bWqvNP{VDI`|<0^G_KjFMp&eV0bjqrYE}K+s;=`|G*f z>pXoXV(y66Bgk%D*N=;(Jkh_9+{T$6ez{r6mS6n| zNNaX}J`UQH#6or;jL6hhLAi&P2NR+gdZg!w-9!EI7Xs9yyYEyZ`LYEtT;W8HVwTLj zjf;aj=_ls0?*fl$rV$^6g{mOvv98_1UJC;d+{*);h)iJ+y%3ALRnd@W(-lVbE|DCa z5KFxYxvs2_Txi_{YK9+b;e{c2(#3H+@b*Q7V!o0h2z@x3I!NA;i9!vIr3nOou2R&F zPEaoSh+e&I+B(r5#aPoN(|=tTcS8U~$Ht*_d6ov1EX;wxNRV5>WLN&DiYgOsgE>Ab z_F0z+z&7a)%*f`X4Y-i3(t)u=p(8*%zAd_Nc7`0B$(UrxuQmZOK2IJ*d@5gM7l~HS zJvF9SQE$@5RJj$N;tRtAS6!y*3%0ZXT7*AXdQpohG!+!#RWa%SZZ(pVC}><7H7^hb z0l5I?ley9ty0&NZEqm6uVBKVAkZo8Bl^TMeB#J<@_fpF?7mx+cQAVJt?sa|4Pch=X z7{7ilx^nF2tCC_oh7?OC2SM^8j|b`$^_*Hy8Le-8NwCBmMmt-ZncAsT^e+kKrreWc zVv&QXlq(>v5A6!=cM0E16rO(bQ}y zt7!OA0#OG;;es=&mndj@%vqYv>b_h%P9h1*71b@ji9%Ld)Z~G+r3Co5;Vqb~cD_yl z($B|dQ+m(G?{rFKXZIgPu^q_uLABpFOft<+Gj2>nImUlYz1gUvQ5w%qD_2y)REXU( zp(;u$7IVV;D*y3DB6D+~k7~#SnE3Swd{+QY5c;wQg)tShR_;S>8Ta0TSY^GY)gYi) zsjRO=v*5BRTb>PuP)5Ev| zq)hXtwZoni*D4N_Qj`kdIWRt3_L7lx8 z)`J(~aQ3v6$7`5-<92jIIgqN_Rb^>kUMtwG2vrC32~Bhj05p}NkNk!k2Dfy^vL&}P z?qP8_L9Ljd*FLl8oWxJP1t|iyfxdBFmem$@%|<{t8!uk@`cKq1m;p166w2qYj*qnV zv9Ne8m{}P(f6SAgIPBB7nwbQ*?Lh_bDbX@o56|Rd{OA}nm;m#4-?zG%@>NFfWOL`o zwGJl1#9Kwc-gf#D#D}MIF2A}U3ZIe$E0uE+Ra^)=vqxp!id<8fb zkdJr_jL@n>(E(#Mur zY3t%U4&=D0)P!lwz?vH8>7XBi{G56fwe$i}?B*or{P%3m-EX(R&D{N)+P z>5m|AW){m7V_k-}a|ZV`7!FN-gAl4v%Gbg1a6KRr(j22IS|3&*%l7_!B%?3qfYQG9 z6t(`MFQN-p{)9336RQ@Gy5%O;yTtJ5UV%I?{z>vXyfl%{rXiu*3$7(*FI=tpa+!7= zILqawu<}=cS5Y(=7IiEd%;XRGyPhkr5_{#CWbrz{O7`hgZ26o4_J9^qK-+vv$Sg#5 zxJxBh*W4wcjHMqXT1GgzP>4pN{pH=s-j^|TO{@%oXiQK0J%*{_YUa=!HepUi7N93V zlTB`(ukQ=Vu9=EvEq4n5b?RTSL$rJXsl0rMgUre&BpqE8@2&S}PTqIsLhG_~U^79g zDR!O;AWBCmo2Wa+C9l%KkWWmGz69|LK5{B+={(Hrg(*F5iE@mEI%b7M{i>yOrSBDd z?lg0btx)xCS^6s0O=uN1pJ zB^JauHhX^d*Kw3gF}&xI2}DyAXao@j*HW|o5RlzT{BH;da8!m#UYd}fq$|h%Mo7pl zlV+lglEjAOMP6c%7@1a>3{wJPaC}bMV;IXvF>w4E4)fmgLSP>I9xwiEp^~$OCF#v6h=wfl1x zIeMk=0G_E!Bp^Ha67qyoFASLFsY4GsS))?|G{Y)l=oy0Ocut=)xq1qV`tYcxR6-nh z1;EPtD1`7V5}VBL_?RQAVi?YrxUR&(a16Xj`4x3aUgPg^IUP}|5fVwN0MmqOV}Vc} z_+HFUCMQDoU0@|2A2iLHremCRdUf#sQgq{gQ*_N~-n#>QHFbl&VoW(KEzu}Axq#2_OPRh%8jA4r9u3|UsgTWjPR{7a%?AUfc zVPN(_&O;nm4##0Y>=eXqRG=}tUq^LZg!E#4W_sH0z49R^9?K#APaMCvQmrr4*3=|) zZVF)=Yes=r@DdT6gC7ohM9zSi*~}U{cd~cXB_5G4sM*~2QBkJCDN|$5J3oc~B<|hr zq!UeU@n%0DmI=94nE)qbbT%)5y2aUx{{TpnHi1zv9(LmB@K$B7V}+ZwLQbh_#&{1Y zlf0yAK#=#CcaxZJAN?ffT154wxdm2q!Lwo~%r|?%x-;kJVK(;71ZI+16Re+u$mIf3 zbe#lYruh@1I6@LEECQX20w=ckwo*7ox$-5x@ou88iCzKlGb!=-ha8*wcBPvFsEju* zc<-_|JKWGHGma=?20W8MB^R!X3pQEJ);`)uPrfX^Ky|ad6kW3=HfiL$^fIK5s4qCvbn~t)urlsY`?erwE8K^%(3MPUW-p$Fu9me{*Dbn(A%>ex za%rJE8t8}?D=S$572{opZt^z|Rm>0QsHYcyCREqZQ1Y5(_Z@?#OE+xo@9uqX9|7+H z(BfCpo2AB+i#qP9X0zlP*|*J7LisTX=i!$~;Q;SRpv_{VXv(!(Ozc-8oHW+(!?qQy z&pF}`{`^y7rvDAI4b{u`af-9dF)hvN41vz^H!WC6?UKm89s)c!U!noMHs%IUhtDXQaM?eayZ%+DDC?qiX2W~D=(@+OMv zJh%uVlqqHou`iR%#DQU7#L(EI@h=`z4Mwg(nLb$5%=Rk+YwV-;ie? zs;l>*W?C6gW&THqSr#n|&qKR2`_UbhwcX7C?^fHzFO1aHNy>~=$v_7Az(|S3*sfbH z-R^fiLd73(PLXXJfSG=XQzC4?68sE^@klm^_)Lkl_i;B8;TtQ&-djbhlngoY6O>&0 z&^in|ZrLY=aj$9kC!4y{ptrg{?k>3CDk?b=W5$EuCB*hxr61)dN}Y<@KF8qb$r-q( z9E&c^fDLjtQ$(R`ujcdQtH$hx*Y1)#l}EAo^CihFt(K<1GmM@<;=W1gzH_7a2FTJm zn{6XerdR1w3A&@wV&_=Qw+=d>=EyC&c1i(TCRw0m*hiwMhVCcaWOFBp7Wi^bJ%g3P}Goj9nt~gAgA_ z1Y|9fc=0Ch>v9q{Cb6oagQHM`Ax?-&ioA>-GAg4jXi`&)2+nEKP(p%O;@qFuh*9NZ zdV#@`Eq`6Fya;h-40N^{dvNxaI!Ok|zIDx3YG8q=IfG?6*%QP~E0EmiDXtdGaE z8KqX{7|8vCUpXzGt(Ju`yHyXJBLoaJl?JI3^V|v<%(nVT_8nms2-oMaX;&ntr~LXK z7I5HiG$*+-_GJj)WLw3ca%`p9dIpgzkq)~k-AE>AQ3_YjGXIF*_&4^QGcN!=Jg|71 z;TG%mXUemj4Eo5`N}ja?tSo$N>GWV9@{3ur43TCju6dLPOtZi+9B925y3);0|5sHK zhFQ?Sx{T5%R6|_w(H+yT174`$i)lcv{d0`WhXzAsG`bT^y)g{Ts{97Z)?f13j;tgVo z;z%6y=Ifhpi6*czo{PLRzxSWNl^<>JXBZ-arIDG0 zmdx!OA0Q85or4KDEp|8!NiTn&Hr65c8z=RJ^CxAR*Pnq{ZmW3RNdDsD`!0Jyt1K|l zjUka6ffP`-C) z5{YEn(CIV!A(ql=yO-jDHASr@7PtIJn?h8Zpe^;23?S4AG`MHRjAVWivQ^n~0G8F# zF1S0|7e_mtNS%xK!qz?#17QfHW1SyrMLV!WDJnIm*dbEXQH&kmNwa|ELdv6=qAyxG z3eUQ&m_WXsLE0%aWSblnR_^2bIF6r+y6O-?0GJLa<)dr%OGR~`c|GsAd#IM(X{3)Y9vEpI5Ehp`8##M`1M!h)I3kp0=<%=l;9%cg zU8YlB>W#iN=T>6amt^o}r%m#xc{kfze+bH8 zlff!2TH~`g0Z|89IsY+jO4diOvfVL(GQ`0}H~|5TeYyS8xd&vY3VNJiv~2M4%c0+0 zROS&_fzzy!$u5w}Zkv_OfBYBSMYUc%X=A3IYp(#9wnBi1VvA{xmCK)%ahsmSu{?#* zd@_hUr5k1S+R=>Sr4cqkBGXPRJ4QV&X>?tNpG$xeVhX~hjd!XFQj#j3W3>0nW7Yz} zYAM!(Gen`T^}6&xFJKP5G#>Y@Gj7F!ej`qZMequ1Fi(484=ZtR(12>J4`Fx@mC(jQ zyohKQru&>@V$#o#bT2fWye#QRhWPX091MemfrPdOCO=gbETDJXk{P_*XxXOgYL@jx zZfY23kKcpIcHhx`MGiif-SXATd;f=SX1?TgtecAJ7Tq|TSBl9=yq=D{={t+(`TG)* z^IMT7!-nc0ORJ2ZjTyi$;FT%%ViYT}7n9NvsuS>90giuMDsz4_egeNE7@0`(fd$|e z1IM?Xuj=4}aR4W@uAnH}w;v9GgZ-JlF%S?5P#cs0()#8b4#7I+xWMceFbBZf8%6T0 z&k_WR%D`iwm2-QmyAJf#p%4(icr^aC6!Z(tkSLVzl{Qsv8*hR9#JEY6lYu3TB<>!7 ziZ%eP1Vl4X=?C>xA;bA`)MA2o%TeQ9UNsG?Mr=N;Y_n@4rqW_2_DyCd9UwA<-cxN8 z#jI41Bt_;lqZo~BcxH5`!~Y-F52mD;&U8ro7wz{L!5@OzBaOe)Yy^*GD#Ozki4(?L z90G|u6C<0?sw$r!-A#lY%u@-QcX!AT@*IEzNpQ19Vt|5P?}=sm;%aMN$J5ftp;7wy zOf|-bu23hGGqx{VeV+OCab?I^`l)U!?to11yl$(KxR1*goi~HlBOR?~QIk^x$228; z!H^E$uSVD`E$sbRd~mdvWf6>Z8U8}q+&}7#L0H|h%-%}8v5#@Fzz(pRFKJ$2yb087 zB|*PlC8u37>hyO?YTds8U_wFF^U<+OYWxKqT${3xBCY&)ED}mF_1tRL52W}stSnw? z&UAC&5d^}L17)K$GE5MMKo8LHVEMA4$LD&Hgz{N6>iCsBqfxtPglpPTYPW?=&9-nb z?|ZgpDa>b9Rf{=U2|#=hv*4+RiAl>LS}C7PBAZ)h(i{CRbCQYUc z0A1LRaPwe!;Dl#x!p6*f3=eNLH1*q>t^|_#Y^UZvuD2BBCo=WN%_hs9L>ImaAR+Y9 z&($`6UgU`N)Yi_UIKQbw?p@-0Ay#V_V;x4u#NE5aGWpy+Cz#MN%r7U#N9%V(qxkumi zTsBm2B-D;sObdY@anmu=%2j1SL)cZIJ*|w>?@!N`bjE0uyVI*>K=;kqg@Te&vyUy; zMLV;(byVLQ*WX*byM>beQe>=;=&Nf|b3U$XVa2O}f{c!MnTob%Ocd(xNg>d+MUxRM9QEPRZ%aQ5v`Go%JUa>OpAp(G6Cf}F!yGl; z(4sTek~4N)AGzRfx-jPA4rkdhqBy%u!Ri%sbRJ{uOkPQ1%$;Pm57P=f6$S_*SB;n? z@=Hryh+)(naU5;j1Lt`I*YHLW!qI(!U6=>Qc10+++W)?YFL#7Q(4AvTuWACRD5rpc zwrMYXiImbHFwA-hb!3K8!5nBA+4@B`b0)R-L);fkt%%#xT{@@m7sn}dr9AWFVX|B{ zcMFzQ2J4dL&4YV!@ZTk_AN6|ss;0n8C#fYja@|qR&(Z4Zhc@J|+1dfB)-KAK*Y1f$z^SqlECRWbBstyJWxZ7)w(d>gwmxh_2MvcG?!R8m z$Tf?YG9LgW;eW120#N1gt|g0K#kky^>~=+`_|{9VE%oWh)~s5X|XBF!VD5b zNTx4;9Z!DdZ^4aX;<=pz4WEfyB1%W*n|lT9%<=yZ={GkeBM78QM^5_QUEribGyeVM zq8mFxKz57pphlgrKBKApGpPni4i?IY1#bS z+qkXDUvOD>7xxDxOwAMkt4O~hlySD4I+>~ZiFeb8?Zja*p2@!HiA$hi8B*T_QuvWX z=5H`Zy(jC4AQCDG#){&3bA?5za>{-{bSvJhP&><}s9YFdsMa$ggt1VHz5a(QrVz}9 zXDckNV|^ChI_>ZWPg>8;?*c+ob{0<|Iv?>Tt(C(oB}S~Cl4FFAKMvc2KRMAR!re6D zSm4v_J11E0fhfr_Ve#pXcx*CDV*T!SZf-yn9%!z76plI3NXB8CPtgy;E#~r4y7dkm zt#|v}f2?7x_BurSn&iaY|4G}F#^z5SR_{4}q6*OKUZ1|gGYqa;cx<5>ifC#FymFeE z@}%(6D)@#)OZ%a-6ffgiwb zsV{NU;xWzI@8Q)zQeO~}Ctl-ArmwQ4cw84H9RE#pGV`I1I2rcv&ov)_`cMw^4CGgg z<2GAl^4E~28I05pGTAjy?!y`Lg(oDH>=SqROgv5LIyT$O7ZF1T#SlsDkE#=gZc>pa z5|Sk=!GKu`Qtuf{n2+@9d6c`OHqA-jd?W->i?6Oj>~V-3{yh{%!yesaHAcwq0B%;+ zC@p+3HLyZ~e)dUUhZn!bHcY5ug`$iwSVY!*tD9J~8GhWrp78bAOVAYw$WRjFK-;vO z92cX+!?ymK+`ZwMVIT_;(UHt!_rN%Lf+oD2 zvKtl4hiK}qfM_=CNts0+5*u;V7Ssyd%`fZ-xRSn{9Q6P^)z2#S%*X}e2@*2R$E@qR z_%gKUdK=Z(MJ9jPs%}KtPH51v5i%^9$03%CwKQ%IcpyP&_>h53enA#pn9zN z@?yee^KF&I!$>pVfvPS{mlnN<9s;;^jWnZ;Vk}b%-bW=QbF|}ui*6LFlm7Zrm*NN# zBBY=|ecxPD`HhCYSC*Yi86>zhR@^RwjQA0L;}Fvv!8KvGJXKB%?Crn^GFE}1b`(E) z4Bo7lK@yJ4J!MYf>7SNf-cO{R#1jfqULiBW+@#7|Q~}gXjZ-}?Imobl2W_^~mLe?4 ztoct(wU1g&zz1`?nRLVmP-o|jAG=uSY5|Avy1!&~!6HqP>EsKhkkt@j(@=O4)Q*gW z<1{>nNH7Q8jpwb(XPW zHo_bIu1!Q>(i^p`3hrwIL~@SEx~ny3ai z)7cJ1z|r`=FboXM7V(<;>6D_-{KpGPip}lDM-u>7{1E$qsKK}h zk)vUU@6Hx*oSZYi(quK4H3hB72);@3mQF7|rCG+{F}SAs^0q%B<-D`}&Nr;*I41D? z&3_MaIB#=J%DxqcbCdp%#FR;WTIB9IDcAevQVNC#q3MrD$ZvO9r+_*Royp}Sspba~ z63Awop`QvE2ohU5KAMs!l*LlnTgo!BCPYqWoN$};YA9IY9{TjuCQ#Tk*orL5C51h! zbd&1Q>9|EFOIq$tD#Ex=OyQno3SX~|yf|v%p>uF!@XUf`?+JCV(W+&H3YTNX1Rk^V zFCdViP~6@*w#paGgcJ+It2R0W`F$i=EJK+?4`7(r)cxBG-JaPep zYK4XMVl|tvxWBBm?$3?!i($+6&Y$?=hEZ={POI?nvW^MabihlbLipe%;HWtFb!%`@5hA{OAZ{yT@Q z9hE>)O`yGp7y6uY0$dDRmR=e(x!{RT+I5@^X7wDdy)0HJ_g}d{;;q#5{yBmXZQ1`) z$CpTB63O*EA@%i?O;E6FO?8EhQXBgH&LHp+=6xg5S-{XCk)Zk?i&vFTNb_Zsa&nPA zbDH7FZ+c!CDrU58vp&wBX#sS?&r;BUKERuOHUyF0eY?|fbCEAf{nfE|(eKC!U%{fz zN+$!_4`Nt}L?}A1cr57luDgwxmD+j^MP=*-rI&uzTo$XPipNXHQ~c2zNU6R2+*hxv zYmBK=a{Tq*r7U2;^34Qm%{`JR`o|2hXr{EuL5wmNZ&oJ>w`FRplGQoWPdIiQpQjY~ z4>s{MLhvNsMbZo3E=xWq6~j<8w5%AwDM#fh`hu`*qI$I|I>cp)M^j>_G0(U;-UK*|7{h1=jny zv@l`kMCZTH_<)6WoH+lp^aV3Xzh(Ih<9B|Im&=X+F60J^>BQMUWY}g0L1|J ziU}z(I%GI7@{(y?I{q7@sRItXvkFAH+5d#qUZJt3s>8ZR4(-v8F&wNW54-eQZUc9DU06`6-7)dQSDaD_CParSw7*O~3pM4uEcb zAO;eyguGg*=$n5;3gjDvQYw zyM!J^L>;`ZNPXjkS^!R*N7@NARf_WOZ;$N}%Yof>0)ZM>G`q6kXPln>IHBg@i)p{M z2CRFWS+DK$l&?JET~a=}5i z=zi;2oYm0+!?}f@gp9WpL>A??_jP3PmYuGv553PDjlcOxU^Awm#?kLKCfjBI43#O~ zc^arA2}c>b?FBW`c|8b?cN{Ky!w2o*F`H|X_Kw!54p>4cfstV=bniRhm=)H&c;Irs zd>yW<5BM*O#0pb#w-d-aaAjD67lw#WXH!$RT!+|U&}fx5xb8PmPXD(lx2qL9A!i9kN0W^osH@h^^Caw%T?n2`wqz#ylnSw6 zCF5XQX#|>qq8X77nbEWLm8$1ZmzoDP&Qw8vLgbLxwW_YL4BbvAv|EQ^bU*ne9RU=9Sqh8;HI?vY>RWmm;&-~COs+%kIy zW!zJ6Pm`|nffyJo)FB+r6WUnp&QZDG4QmBPx@Zl~rK5e!>7YAj>7-qj3iQosi*bp7 zB}zy(sIXa&DBph8e4?iT?V8Ibqz9^QCg-7~!ekR1aEoW2?DWL7oF^k!6NoND)Z~ za_0>?au+|6@};;Q+1Aqvieg){KJ!&USD)m+k5T@;UP5Mygy%F42W~rO>J=)8HGO

1EMLAjrO7W2(-`(V{2G1niy_>kfp@2RK86Ta-8G0l*16P2<|rc*o=PAI zU+(#5wM;6vp$DF0il=ntmh}<{%LUGdfG*ZT1ah>G@Ibk%nD$Wo6eZgqt|6M4EcV=l zWM2dlxXQl~ckc^`P-a~9bS_ra_8Q7U~y;)wRJxw_IQyv-4bmQAkJgU#vDkn37Qj<7wm_Oj7uFK-2#J`sP0 z4qT(p#n-!1Ni+`@fAzvz+T8K`MC<*x&7Y}S;Q1F9ldU;mpe`x+)GJ-n`2jD9RjZU~ z#u;U|o}b}sC)1{(b&u#+;O=}2=gF0f*^h4Pe!^h4x!gO!DFPs<1efW5GArWj!F%ZE zpVo6TpyFT1Ycc}XcVV%e_SiSE%U<~`ZSaq{C@ox{%YP~2J>i1%;b)b}o-w^TwXAYm z$Y0meS!b$)EC1cRYN}03bg#SKmWT1qxGqTz4@#xT_~)BQ5l&hhnL6 zhfozggJJnf&7IPN@C6jD-Fn?e2)Ia!u|{WM4g;4e(-kGR3uz&~p7=*_F@`lPf+|ZP z7h(Agb(;}}+Ge&NL3zlbU@GP$SmZ?VWAQD7a z(i;N$)I44^B5V2B2{@zr_qygc<)?57Q5bA1tO+0W1^jW zl&^Kt?)F^2C`^q%Q~jf7klh&kDFH)<|71|--<*yJdfF>OW1Ane7ai)JD?Ym`036L^ zPPTaVoAR>`lZ|Y^H(ieNHvaVL9Lp?%;CkI5-mZJ-4x9+(6)ACw9f!n>xupV#H6HWJ zz#k~RP>fGFzK~e>?||=`;e5CjgF3o2<1!SdmHaqT| z37I$nzNHrf^W>_4f9`N>{u`D!Orurf6u0y|TIbO_HAFcX0$+{uI}la>(oTd7DfRz) z-!uW{_EH3uJAAmCo=5|M6>n4GtL^Y)$?z1_y18m;Mc)5hC{xQmQHwHm$|3$vQ3wlB zKn<{q9IoFMld+ur@ne$sz~I0Ary~6S@bs2(O~3#9w@OQ=N~cIjrNZ(l|jwBJ^wQE5uOAk?`Gpk51uS_G2w<|nm}<9;4~ zyW^B`T>u+kK6RF96FePfte@r?itp_)XJC8tz1UbpB%e>Is7NQ1fU%%X{s#Rtos{jawq?a-ZavZ;qeBHZJ@0TKh_@scMj_( zV7Qsn8KU~N$=)f^`vh$zlK1r_4NIxaxiIYsSL9L8^G6yD-0V0T6^-Y!#_mf#dzIav zMRV`&0(Fu9rcA`{B@TZIT=hoz0aP_Czr*V#(#Bf?cc6V$8D|y#p@mtRFeWdA~ zcg5w2%hBciwJ1ex z6(-AESTE%SzpDv4WwA{N28-qXOX|3BzR`%LCJQoG9@5cNr5zK#{x%ZAAsESw7xLfX zZ&hKNeD^NHQ6dl_`(yl*=-I~}W@$&sy?s8kp&~4QJ;xO8<#=TvDSAB=EDz_a&3TGy z<-3&sq^Z!^{s=pP=^mdG#)jkUNDSGZZ0cM6ESbQ+-#mZA|(IG9Il=s zusxo6#e8?%$U>a*zgj>qYbN|IkP7g+Sz?M3X%ok@2Jxq6-frbadT+jG^*MLRG_C(S z1DKOREjmNtq|Qe>IeI6iOJ656MFHN+T-Y--n?ZPagnxnfyd@|oRhG&1ESaz0esAoTvu`QI;vY2+JDBKhU_-QYlBp$op`d99cJDYf_U&dlN@mg z>m&`BUO*S0P~mETMJ6zwuDKNU%8KVnGbg4>)fjUc2Cap^^#-7Gcly6N70?_=^Z3jaV9WI_PINtfYv&Ov|bbpJ}^ zDntyZcO1J;nP!Rwtg1f|UihkfK&0jPF=sNv8~J{2INi>8P+MKfXTfy}?a*r8k|(R~ zsU}v#I6d?7NX2VmC#UFtf|8{sB*Y=MZ!iVRaTyp234`3aFGFg>C9V`=B5Qn~pgs}H zW3e4;HFVoQ6i8z8Tg_anBj?&|6?wrKPx6$LJS!8mgS%0fnBxM}g7I&Y4NRicZgMQX zei-24+FYP)(ZD$A+8~zsVvXZQpUDGa&hosvrear{UIKOExu)>|K-Vg4W+B%fh>ucW z0;e_{kl)md4M%Tb3{wPO_`}T;&!GoSy9Raya;N0ZiSsS zH`MU-C$}%o{bhb`)YZYguVVV`woOKFuD9s4>y&Y%)Ir^Rs~5mol{_DfIz@qeZ$enZ zm1Fm30BlIc^}(7TfS{0^DiF`<@$=c3v4PbZ>`$4?Ce{tAjZ#m&QQN(A)BH_kVk{lV zrwd=MZ6=`V3J6Rr5Yz7S5WuG!@m#B=mw&jsYMH+b@wL`%{Fth2I&j_q8|(%%-t$yB zW5D`y_|_qTA!$o8+y{e*a*|NAN`4$s?#TAE#<}u1b!_J6toF~rZ8_an2HKP%=PX4% zkF8ZweR~+NI`{LIZto^<=p=`^{UES503!K$g(#Ft@6WiJgw;@*sCBrMGk_4`+}FFKZ=}vIQnooKUo5s@IQ52ZR?bKK#{R7(KcMC_ ziT=LvsC}LaeH`r4Agc8;@AvuRGGQ*?ZgUYfPhgW&vuL`P#xb(C?);Nei!+GOh+4*QHD(xowBHV-tJ-XlQbu6&G+%L9sp~;+_3o$wwbw94IUS6 zI&(N)C=w{)*d$t|eMb+bg)Y_mr?P0RE(`R|O|^Vy`T-9M=y$vh<581sAVfpBIXvYW z6=qy46?FOGv8F`6@Oz}9-Nxgg?2uki%-_4*!LUXzzsRDdDmBRA-XGxWK%+gb?k|-3 zOg6X1-L=cE%mOuCsLb3 z=~Us2#Z|$E27fR=`s?gfyt2WM!SCq<%~sp)Ocb4&?n@fK8Xhivd0qIWUoNq-% z+Ya<#+4{XJOiWa-O@C|>h-5Xfw6&F02MV6-L_7}U$v9%cdL*tEnCn&9^e(!Y=^H-Tf-8g_mH46D zvfN5utlB&f5j?7PI7Miy-|aU0tV|2joh+i@-G7rqb>ABF3fg_x1eRl{0kEz*nDkr? zVI5d-6Wg8s0q4gMbh>N)S29H=8juM5*=)R?_d(5ES4g7#)PeJ*tuDF_Y`g1jzbhLj z#sIsiQu6oydq%rc6(9jh)I5_5b|&P5am5=;b@gFZ$va-IyG2y9lry5|WLPZf{<59! zR)6cUd|8C$u2LhC2t*gdUf>W(=%Cazq1K2^h3WI01u9l49w1f-GS)_!``JCbc8~K$ z@M0O&p;|IX^PZx8d{5?b@gt3bbbiW|O=Z0?x&@i?Iw~Q@KngZ@CR2~b3-?`1o}9!q z&sT8N>rXp%=QRmM={0Vjy?r#X5iiCPP*ixGR9^ogYF7hBK1nj*AV^6NYNWkIL#zlE z*AEOaOL4Uuq!f~ir8k)@1Gb1lP433iF&tKS`r%D^NR7*ANoPT-@cdVoyWuj|y9}?P z1xK+d9%lMmXw9xd1ZBu()+$!uRJlpw{UzYEv3c*z<8{8bxY$I^Y48^@w*=eb&%BHC z;d7hl(=9>PStt=?uioC?iU`zO1FnXfNxZqP5N~_zSai{l1Q3>H@4|b@nDGNaH~Qcd z@tf)_ilAlY4L@lGo#%51o`H}z)Fe)FmUBy4BR)Zs6F$>Rvk&>`qmXL)|CUR-(-zlR zVv|%+O-boX8ujij8Fv)j-3rbX;j>sls;vFEhD51mv~pO~-8c-fCDDkfo#wAdU}R(l za?6B8z8cAQ#)c7(q>0~GgcFTV%z{yf3i7sl zH>-~N+I}i0zUG+xs@ePm$QO;ELifAjk`v4NdtTY}h$zjl*y=|aAaOcrJ# z34=Ib3u!-;Ps_giKJF6C-a?Zof7o3sZ_{fTF)0Wc55&K6EKk}w9Rrdvsij8RCU?t{ zs2mz-V7pt81l0f1B}|IW>oKht@xdLxxS>53ft^tF@GX(#jS#X4s`0C4-Z@{(u`~)A z(2WLx0`uvS@Yk0Cw496qI~G8@_ufPY@fc{IT5OV%06GmIW_7#$b`Ag?SUI0oQeyvA z9i`0Eb-|XE6Tbist4j-D3Ap#S-}gAobp4({0udeMjqvxj8tPO|yx9(sI;>tJ0A38$ zQrJ~tNqJKWPNBImv3VuADht5+<{mnpht2uif$qLdzPz_6)zq}5b6c-qP;mcQUaF71VZck_I7u(b_3MG@Jl6Q4$ z1X6oEER~Wk28*CjRD|$ik_gzM&~G+mPFlx#^*L14lJiBI9F0niT7A-+B+s?)CU}pm z?KMyVFt{Jmv}5ZfOJap#oOTfI+T4oei*oexs(qW5q)XV;A1kOYyl zYu?4|JoepcgE-E7GJcU-<|g9RbJ6bx2EJyxc;I+}nsY?n6A)bfL4`!)Cxwp>er>*YN_#6d))G)B`Fph(@cF*t7ji zcmsn#eEIEYaz}M_-AEiAcw!~?rFsglWeK<6WpmSgJKbP%;~?Ph2N2?{06VO8O+{Mp z#)|?v=MiZ*B1b+9`80nxX~?c&EmF+H_cETd|L&OXUiALt4l6X??1GP<-&!TJVPT$n z-fP%LG!xoQTQ9K}rct8%1tY~K!}olt`AB}(vrJvVW!A1j;__@K?5`6C+(&M9G|f@a z4XDjqL2qjocwUO1j)N1wg2FyLlwE8^NHTI$+69F@mSbs$E8gFHFA%n$o__T@Sxh+S zK(s4f3OR5U|1O&mbuHtt_=_`0lz9Evc24mJi;{qFirH>A>i zCX?lHe6M+Ipo72YhGfDruTFtQxJssRpT6^Jx?2(hAl$s&)VII6?>G0|Zy2J60LxdK zwJudAh127GtOE7)z9^r)V+*RQ_5Ot6dC%Rl+LX%An#3i(!x)>*>#w^HD!UKXx}IG| z2+!+_6rP}FNudlN7*rR?g;xRY;lxy4+B~+oTFF!?G_xUHoc31X zp_uVUf#WA^_ETNWyv&rZsMreP>kGSI?-qjz?g@Ayt|B5z4nCgg*{G&U!W8=|A;cZc z7icT}m%@s<3N3y{*i{AbWOEKe-rJ2%UC+#2q!2MhrVBV&hd&1B34=)Rs6S>rsXZp2_`a$ zVLPdE=JV(Do&9}@lhg6b*Vjw;w~MBCNJO$th9pRxEJoWXCT{*~|Lf+ff9t44cT?IQ z{hf5)_t#QqMNv_dqU_y}oBhy!zsoSwn;j0*>sh^~tC}SM^yc1tk}f56`RSm4U3eL= zTkr4RgAPlAB+rwohQ;}Jdz+32K~kuB7d_LHIp9|;4qgRSG#n(`WO~gRMrBGmq)38B zK?nfJ23yHbb-tND8Q%21sRN5PpN{qW9<|doOzkRQyVNh=oB(Dn_iDrRj~v?f=}ErY z08as-`JhVO#Rn7T7$3B+0u*D@QtndqGJcfMl817Pq2R7#JFfEBTM1ZG;%CUN@yN74|HuxeT2X!t zjL;iy@3}l`8*#U`$g5G;e!WuL!UUDfiR-qK)Vn*Ziv6ZC6Tjm$%a`^NHQW|XoW`m^ zY4vx|NcWQF0lsrzm=9wAD8lZk_uSf_Pb}#|l^EI~H3sreSfK0H-h?TGgm}HmP8CCF zal|so>hClHqRoLMOm36(3C<>|Q0VS@89)O8x>GRJ{O3ZEY!U9Gs?T`oS)BY#QAkdg zjRC;3pv^||j7?T8oKHli?)soX>h!1`i`EGsRMPp(8%7T1de;qwf7(oQndI$;Z{OdZ zG$XiXJ{S&roWpDY*8ea7(7jo@YrH5v0Ze&COt{ETLZbXmvx8`RX=J>+cFP+b0ef+P z+`C!owjtm$IdFKtj8&rd?0v|1zM918h^+HbxqG_H0B`qON{5<%A3mf%{7&pJF?9^2WBRQu?hg{rRJyOkwP%|9&naI-)mVPk*hzP$9a$83X2J}nauI8VW~p)<(7!2kt{Wqec2;C4D7 z&G1dKrIl{6e?rIAvmpCKf68Wpv;9$x6K%&YN#?k9tUv^dK7z-L;kfn`qfOz;y8zV2r0o`qZPQV%6S&n{QF6yzMu-kwK`&!?sxU zH?{!eT{rjhxlom3sXHs^Z= zRbOwbr@8NdQWm1=dasQ6{&vaK=VC>1V_*?ecysCg%oISh^El2=XWncD*-e)3@9w7y zgQDg^1O3Abjtv&dcww(7COH{^1}i^)~@nPjN)- z9Fw_hjpyPeb*!hCHXdqwxN&^#9bjD!t0Wg;%&k8E$|j-bj~}(F2dY5SF_*6AIYAOj z4c0U1Q57758jhYxZ*jj4Y5p?`g&!3KJq_ZKI@=C{j(|$Fo$Nwxf!ng2vQZjmLB ze5GZUYEWe#W8V%KP(OL9&S6>6yDy|>c$84RYw&f4iKQ@V&@}4KM|rcFzT{Zu^#j{XED*} zg2BT1dAY{kwqTEr)j5z);P#Dnw z(ySA6_kqZ#^N^9+$51W*jS6_v_V3PAQIji$WAd#`6a8jKruARG6nyo&$2IVqTEmDW ziL!#=Q?kblbw^>miILy#!$U#S6JxobC&>zx`hw>F;C$puy}%$TbE8n}C67y=Q1LYb zo~9(2-wi7*RbIAtTVNMZ%57GG9~AbyMu)MQb73_~F@OlW-l&Oq1W$;Brn2mq9p)`C z)?OSjl=OQ8F=n8pzYr#v?6{GFdQmR5?V#*Ysp~|`U1*A((dX&6`ezM-|6LI)Z`<1~ z7;unUYGR@9Q^7UAxF&Ce{q)bI^f+aGuhN=f=sWeZa3NUS9N1b(9k%&G2d<%bcdF+3QnQ@!=e{plRukqiA28@^t z08q!sANjI&pGZDLlKvHxfh7BykgVft;gpGE4m6Uz26(6HWbT@my$%vH-ja*?e_?49bEi+XA0El zE4YI4w~H`3G5vK3R|H(et0zDz@#D_eOxO7NT_lZlz`hd=u)oJxH0uoMkMNdZvfk>z z2^nJ5Npe=Rl4P*OF0FbxGZWGH)Q%%B?OlHz`yrlYZ-`~nOJ~ZKk%$dKbWJ+UQVZeJDgBv0`5 zCBsP<_22I2z6o?j4Hj)ldoG(Qx|@O@Uc@_?G=B#OsL+&OK1e?k<66GD?<=h8>Btc6 zf+XL#@9qnjAHmp}r3XZkY&31FbE97>rDM^8T79rDD~N|0_;?exI)B%O1ICn}4opWc zKS(lsWD+1@Mbra%7_t$)DEI~PK;|+)XM>c49q@<{;+cPLFB-r`hBVVg$YMbKvBwm( ztVe3Y2a3bP_Zzhnh9dW5BAt%`!k2){k0$$rDU@;Z$v+N&w1HU5S2~n3e_Wf*dw@t!gm+zb_XU zbSCpLI!Gl*>Aw=!epgCEGtlI=eah3z4Khv+i=6c;Wx&$$k87^`Y#;NPqMt+X1n@&sLAIVTWda6mh#Lp@9#hiBBGTE1Nk`;WHnM2#R z(@Jfx!ZQ5aW~$?hCAGeNbcHa`f-&S8MTZoDKe88$BGZ`<^NK~P2?^f(@~XR zhWukT-6A?SJVOV`UD2ZM%X&2G^_N`fwM#Jx#WO`_0I1bMx1#@xOSp{iU33F8qEX&E z3Sg?e%uWU{Tcp34p#UQ7J}B$?s*5UpZKq>2%=aCoGYdXd+xw0BOWcaN^KrHVzL#=@Hy~4OjMM`O=QoT_tfC!?4lT1Tz00UBL6ArbN_>L0)JslBrBzj;H3$$s#v`7PKT=LmqHG)Y#FdguTp(My>AwMTU{ zlw|2L`%})yc}cdhv9xr2#Kc192wI%K zU+tH_axnQ*wq-7(L2cdHJeQ|puwh4%M;Z*TFgAD%t4LTk+%9PSo`E(cFxL3 z2|-K(ST1&MsjhAp(%h!2z(w4FlSPWT6OKU=90u69k6+?nhGG?{1a3 zxfkKXYPpiWeVt}bVn`xPCcfrgdi@sWTNv*l;ihyse;7V%j+uhlHR{8%Soy^hOMYRYD+J!)!7or+A8RYJKAAJ0$Tz&;RzxxnW@ z2YxXqS}4HgUGRZ&rXnw1NDib-G~B+!@UV{KzS}QN(qYG-Ak#iEYV4L!61lbJ_huV# zsEMSBy}ukKY+}{nf87f_mfjZ`mgjSjPm%YV zv4)$IlHGL!Lv!IG_QSk$YKN-!?LmjPA$8R?#tr;k>?87n>unn}c1 z$ogL`04(*!N9*wPy~G%Gt8bh?ima<$6iWMRJ01f!(|6-COZFYwR&{f|ge=3Nl2@Z|Z+=zE4089y4Sb|8FzL4DRVDl6 zsKe256Iylwa(%=IdIas`an`my+dtjZB4#$zAgmFS!l^W+7^*t@CCLDpjK39GUmQri z@DjR6_Nm)Cyk&Shz6wOS|D0CVuw=>vP%u4=0Xoa8pTuRb;d#NNp-I_aIOL&a?17Ap z4r^4P(s{S@(t-?MuKSY%ufzKj(~P0v=93{YTJV-M?9=wxI&=Yo0-SzaQuu9BnFz%^ z04J8ideoh2s?hCKv7NfuWq|O9^7`$BITJ6RW5jz>w^{IY8{nPpEPJV&{7 z-ouh!hspPAzcx)gj$;%vMa~eUI?0BPjkyH{dfVylV~$L;wVzx~Ur<<5s4A{Mm*6DB zjHVW#?V6akUt4_IGW(9h?&W)XBSgnRH2vs!hw>!wJjsRup^?CgV!^>F+ABbCMuGydFn>OZOOG@%GA_z6{mY$@%CU+tai@LjaIf@SZNNyzVLdBhnS?V^Z1E}*N`q?8H1cda2CiQIm+sh*k^>m(4rn98Y%&*@ zmi!j*p?|`hH*7;U1dEP1K9r{knE&7yz8v-icOut0oG@TsKyyqq-Rs;I@O(tCrT#n_ zD;-Di+EW5Fm-n8uKqccVVkHQ1Und>~xw}}+^t-ryRu_aY7I5wR^pC9S%mXi=xx09^R2&XwN_>q8x#!)3fKOJIv4g&YyXk(7|Hg$o*)Ky z+HzXRmic_dh2h{_F9tdL!3GgB^& z6R1_5OPRp@uH4>vTD7*x63j}W5~@nw4;wevzl*4khxp?lz(ar$$X?mUcLLBcCJ=nR z_QiO9@pQWU?Jd6W452pdlflL%n%X7D%TV%Y74F8rW=oCd@cqaJo8gQaz~E}#ot-e} z{QT@Eo=#XnkF3?Bz@kefpEPvd%%?|kpTM$eD&<&=tEC4kGtkGy0`1>iIE-F9u>Mq2 z=nuqtxir6;_@7QC2AGwOr9k+HLLHmx2=$uhQ9S2Tc?|#G_3VVgc*AXMCGj+D(J#gOAp~~FuS1_*jp|g2h z?#8=W3)e-XCi~9N`KUqT`Cd|13)r(w)H*OiG-unrUluQQ(hzNW&5~&BDClrrntUj} z?Le)bCUVzui%J%B*_}ItQ}qR-E|KIQPQzM@>;2kv0r0Be)PjiJZdq42Z4G$5i`a)I zil}~phq_01_f>bSgO^A}(vue-7ASK!tW&iHRkEE`9j}kDZ{N&gYN-l3%*64HVM!4T z+WH5J5e`Xzw_7BL94zgtqqiY^`&zyCw!OQS14QH%)fk|N=wJb;;1NJA%e=5MnCNe_ z1X!kTh<%FEfhb*FKP~IKr%@LU3udG=^cnjaMwm&e2)OM6YbS#9b5=s?_2K%Bq>g2{lH%q1}P0rKvxciT1)+2lAMS7h~^HM|Nu>msnDMTde|7iKDrBH!1#?_11fDI-%vMhNhT@vM?$nFuuuK7_#h z9C{f#&cn!+2cDt?(a0MHM1};c>B#vad4p-lU#qd$r0Qz9SqFw)m}|57d*E&oB40A+h~GxAXhX>K+@D{B=jY1)0fGAotH$ z$J>rGp~65uwtv0f;0_oJr3zUOY^8ywtJw%MH`D|t?ZU=GpU-RZ!Pzd0ESsm5Rwl!Q zMu7R<6mf(g|C^HE+1d#iz}Rf_q;L~3Aoz*#wdF5*x-odlrHpvehc>hxTfVNB@>O+% z)W5(DFb^~Tj*dPmfXR^qQgX$2m;v#~HSU;@_$|_#FaaVklzQp2zKIX(q(AgogP=N_ z6hdUyQAZ zeupq`fsLj?Af)|#qcWtAb33BMZ)MDplJyCNV(Jg#b>VLC|EdW;Y8(O z3e2JS4JR@eTJ|2jL-DyKMA(}Ae6W^P|tPse+VS~fk0Brg+bor zf%zGmpl=~)x;%0S4Zs1L8WY8^_tn>|NSqak zT=kVXE&qnT|MvN&!c8>Y?p`)S?$2*i^t#7y|KS2hD;6w6ERV1o=7IP}&iYc|NquZK zZ+Cz$)s#+;epks5SvhJa_`tD*r^eVrMB^lRQ)E$4jj~Gav_op?)l_$8udYUa>Zh~@ zCOm&@eGV_^Svyg=!mE;Z`r>J40=wSA@3oUm^60s7MgWQ|QpJkC^-EKBwO1#b9du)$ zzPiv?5aNhBjMIq4#!VKI@h^sW>P~*-LYZXtT1~l63_3bVKt3>&w!(A_DZL@LVbT=@YET9 zEJs(!TAtV%i@)F$TPF=?h-faq;q#3HX;00FaPZEx4tL4sniVi@c4KpBztTb)bJu(=d-V z9Yi`hK~&S}Nj4^0$s*zj%s=~Z5|9^9oS8M>S=tL5Jh>!QuR^&@l%i9&6pjQ+f;wk5y zr?bjMfK`W%@YshcC&_H?pg)E0_F?9ocvLD7=Al$QIc%JXz`nO6(y5n-yI>Ip)y2V-te=%5wc3-y6%QqgBIAdxQz^zez6F=q5)s0kCE4BGz;ODKI5Qzc z$ww^XMncRQ$BMbV7*a^@uY(}{A!S{-7GSnPf%FWYm#Mu+7mJ|buXe!=Te`kJTXq(e z1FLd%UJv%Kt#QZ1vOgcGY|mtbe)r_5bb4Cgd(iX)Q7zwz^IjX-qwf^*a`{)RbZ2&w zGaqW|^>!2RxN78lc$wJhZ;OlMayp~WE4P)21y(}Xzo9DxOO~Uu$PtPQ#QxL-Iz&;? zMqTL9|9{L>$Wl;R2!E^~l@c;hUk~lg!K~|V1>4}}4}WY;F4hxlS9@fwRYTz{;YI1R zT`6aO>{)0QpcHsZgcF&Ih0EX8i2$GPuO~PLy!<6ROaO+o0c4<{k)~k@h_8A3Udu_C zm~p7}fDc}5M4ol2mWqu$s&opN3JA#qLo@dq{+YuuG=I-M@SOZzLZ<26mUS1iZmJkCvB=5a6KeOKRY*wq_%uows#mIJS{2TFtEW1p)0moeJMOfS8_1&MINSqf6;I;s7fWn0b)HC6w^_X&g9sbw%!Y_sn@`c|N%_dV%61Y*J>CE9TK+&R12n*7M#so~v=D52^EamN%u~M96>&m^L86 zND8pC684`PDv<-o10=Gz$fwjK2+47*R8^W*qHah4(Pyb7UmCS%_72Uha!5k?v`!m2 z?4Q{1z{m0mPutiXaRKdLr8qH)ie+dW|4WVB`U|9f&U}nXPnCVghuGd=VmI7EOG@dn z8v9YQLk<%7I8C=8d0b8(HYv*A8*9x_d4TYmrSwn+kj2r?W#tH z_B0ZDpt-Yx{@|opG<_uq5@qgLOFEWUHvbBkhEC${Q<4+QM1F4Jc*j&+q0|^eFQWDi zJU~<-w$sn5!x2exW(PV9*7TXFexF@+HMcZTgJ;Ny)2Q>FZsAV7TX1FqM-YF0Isec@ zH59WHwUes%po*Metg&dyd#>dlXLmgxWHtK1DOI$9O#J!w8Tq4#yYSLYzn2GZKbxb5 z35fe=)=I*WfUq{~i9*^)AFJH+PE1JJNUS<=>q$tw_*R2y+X{AMNF-`g{xx`;I_a3T zEA;2&`*KGsF@df~b$E6TT+}F}AgWnT?PH5s z;SRQIe_bw4TZ$3+9BWvn2w0@^IN>YGuGrRz*g+aFgHw$rq}L;C&gZoy2COjVL_;iW zUseB@8^}fv8ptJskOZQ`=qii}sf5NBGa|GUNKVii4@^lV%|ACwYnSsz5%r)l1U z+xo?<6J$ zV1$a{ghQUz)58lfaq?$Ms|<}^rH!1aX1**IwH*~>rP@{y0N@cNt>!0Qki zA0H+mhnM^X(J@T)>!A{5I8LU*c;Y{2Wl73=R=4Av-DQ!*o5j8PZbkIRW~c1Qa%a~3aGO4E`H zxZv`aasYBy`ajz>>!+Q1EB~zicGa8Ie?mw?%dh>Q zSMIT_lND$Azi&}EoL9(*aso^&Ds2zUs)|_fwnf|}CQE~?N}fs`{nQJidD#{9vAa>) zAV9>C;kb`w=u!G3cCA-ZME$b*3fMIc*-NAv$F7B4$hy$W01|Db%IKy!SWe-T_3O5W+ykQc47#+q#ZR(?6Y1A2Bqu;Hr({0WE^T5DH2aY5z#$7A!mV_UF*nJp z07o;G&Vere!edSyp;+`oXWaT2NvSEg)zCj?3__XmntD8Fau~Gb^>Xy5cP{;3zC4+t zlzb-u-$3is-XN0vk_XS4C)G?k45V*l|31U8`s%Xa7uwgUaappkxnyFQ#5qA6u?!Jj z*-dKN@*4pwH_fM8<=CY+5=7lRDnGz7>bKS}PAuwH@_9X+;!AekxX(@6=J3f4gI|`d zV74hFPhLep0#mM)Kcwwe=~PUop>U}vL%9oo4i4@T`|9+GTMV!bh>>*Xi!jQxN1&c* zyfMPBcwdUqsYEm!O%VHUj+R_STnH%}|HgmwZ~dQGhmw&`qxb)2t;-_lJ@kphhztMbJp?G9ODs?o-oNR36qGEsQC^Su*!vpvC2zA&x zcyH*Rtm8|D$5Iq1tfJ42&nMr#T{Ip%#FWpNN?@|Nd(X-6gy=3LfE}guMlrdXhRp>m zXf4E1=q(c&=HA=ZH1YhCWOmoPl5Zaa@g5gX($`41-j%0#El!sVeG2$a#6mn17?A@O zIpIOuAAjb+&=$oCx1-XThBDHAwjfF}9ELOs4Sj4Q`sEU$-4)HDqB7v9_J}n6vO=bu zXXz^Dz7Lfwr|(qjz^t3Zr=pr><$X@9h=Z4-mLTv{f&z249c*pZ#}hBY57A_m;s%kg zN)S*8Rv{(aBz~bjFSe%?f_-%eOA3O5DW7tE6+B!R=rGcA;oOm~TgH24^UQ`g?bQ&b zy826AHXb`Ev4rFB6WZ@M?Z429@%d&jCoP@DDVTn2L%QzzI+94bNz7dTT0-7Oc1EC& zF?A7FNSi(3<9IG&y;i(@S=kAXt_1&UU!*%0d$lv6Ph)2$B&j=h(M&t4_8lhqYA7Xl zmrcnTr@NFTxiGUv$$8`c84K@l-o^=KUF`{_M}pvr;2((A1fdQMM>W~V)}ymD3I{aJ zs0oD`AxYGR`->XYfw4WxB1(hK%D)aGXlZ{eAzlHsRe*)((+O{m*%m=&3}~#~pi0}a zMd=E*doUo;}w^t$=!C{)c~OcO)(!X zt{BPe7I=I|Fe!5NXTZI@Tt;>s(GyCv!d&c&McyMyBiJ)BJ(6GUM3zbrQi@;QfABmF zlV&0@xvuITf1y8m{;|e|A?-bR?hrJ;&Y{yFxFCJvL@nJQ>NYtT zi?Xin-@N07&BXH-aiMR=sDH_&rWT`QHHyl-J$$#AT%?EGPuo7SuEips6;)m-D@$r2 zoUB|z$Mm(zFAF)&`l*i@iv{o8W9!exG^fHIYXWMVcFYgEmR=l3yJZBy^- zpRm$z=_cu&SWd0i4!)^0AZE|0YTAbd3@@r=)@+24c=+ZAcx>+188t89_McJH#=8@( z=`;Sa$>Y{_e;N8jP|2`lE*<>>Bbi zq*hh)17YMs5#V0g*Z#tju|xxpI3H)CkgnzYmQlZkL5EMa)>pH+C(SmgonVHsBiOv& zEj-m`tM@H#4~KIa*5@AB?;kvOSG-5@aof^tMxPi$LG&1oq|X*3MG`mW=SEFGrp(9b z4H|L`_UG=({jU~~vir3wtM1Kk9sOw4E86!B_T@tp3D;i;7BIxej*XX@X7|n4Z{NJ= zp%*vtdbyIq);FE-Y}C>8DmGr^Wg5e)*M~*gUK&X!v@Slc(}dsAzr)t;QbImm$ zCP&oR)hhiz-j#FiWWWA!)c(HCCP*H>26M5C3O~5ihyZ$2EyR^$23M68elsq1aq6j^ z@A?{BER(}sy zD0SdMZk6+>{xyYWV*-_8I{Umc3M08nIxxx96dk(@;!G)}iWhI}Z9|-`T;hXJc^2+v zz(*VqkZi3iLr1}g-Whd>5G_JL(&x|XP^2zDkN!$hdf z!Z~vFi^#QhA~JNuD?hHBPjB>zWXK8%8&lv`axOH0w2I6ao0SlA!uAIchU(M)R)4= z1=U7ELqg$}Mq(bm)nOlhi17n9$Y1;P`;LC1d&|@Zc0)7fUPySZ?N((A+WLY&o-v4* zvCZA1SBM#F-D8c?`%RbjyxoYeXjaZrbny&pnbz=7Y}S74QPPjZ8w*a*nnqVoIP6tW zWF4_TR}T~a?q`)5)=LTJ3bWmU-g@Q{(Z(B1*LHEORvGWS9T7V$=m0Qk&V0Zgr;uOx zw2QV+8%wpVy&FE~UI;1bssCq=@T*<^b{KPeHsAN!eZ!^pF!5f|qi=hz^N9doOaLd$&b!XKE{cn*%FL;y0S@9cp5D zC|?V~iv8PcMt|{?2K9^mShJHrM0FUJ-@9h0?^0+Jm9bjQ!o$K-H0{lpcAv+Wr`G=-Hqp17Yus!3LR|&gSEfbWXvs&6X`MAT_P|7I3~3jLuAiZG7y}Qy&9SCmhB7yd z9%=|_(?2aa=k8fUSssaHz)YzZebf2_O{!DGO;5Kr$?VST&T^1#wg({R9-V^6hFR)a z_7kn;aiEzTo}7C_T&GwDaaL;4V(_BhWfye#)0q)}FMEol`$qs^rfeDgCo?;Ty&j!# z<|MSJs6Mvm_#Y+?2*E?=EtV6<3vDws;kqCtQ%bF7ld7qHc3L&w43=ye0lR;h6qNAT zgznJ#{cHMV%>5DqafK~~L1CBmjm#4@q-ON{7clYVzj6QI)i2{`Rjj4m9*JF=$?#2}xT^;? ztO!nVC=o(B1Rug45LfKA4hgbHXTY#G^CSojy~%)KPpd|o@12v3kMU)&WO(q27IJ=G zhY?6{{)~&{NL2PF9gdvy`-R^XURbOCo>-tVWXbtqL0*K0p3Fl=Ayz#mzAit(!5`ar zn(jt~@U8K%+C?z95CB=SD&q{?)?;p1An)u|p+QIP|99sX$EDjkV#?ERfGZ}~=T&<9 z5;K#VMebwqEE|%K>U(E7g(X|3i`NmP=KK+v;t*m~`eP@_ywTF%xD$$y=;0f3L3}zm zVy2Z&E00BQ{j_DP;@-^5Dc!Zg+hN!0JqA- zG*J(^AgjUw+g|(@LW0Y7(u3uQq#fi*tyR}Gg#$9GWSzK$)@g0`4l!SOlhhr}VZ4on zW!_E|e6&M>FNqQ_}d7D#H^ni?qDaFuWn&6kc?JF+(Ek3x^e zVdGS}Dv)?a6R-4rIJ7Z5<(WyNT5arGgvM?Q>8osI-Q)938`c}8Mf!tsN8o$*`qX> z#2A9!G6Iu~tBC*3J{28j9KX2V@$LY=;!XZ(U9CFa-^KEQtSGhd<%^^)#V)m zuq;w5cTg`5GrdDj48SUgdIyQ~EzoXd%&i;J`&Q~pyhB*G6Y_))QIi{hctHgw>9MH# z9_l47mibJ!gp%hMZM8qwc9Li3eKrKXxg_4l%tv3Zv?f{^v=b@PU)3=Wy~5!}VGuj{ z?`#jKqqj`eQ&I5fDRs2sn~_s-QIV9clXwfCRbsuR4R6unrF1mB@vt~O)D3abCDXD| zI}sneoVSQ^rgE)PFH*m9(Au*en#MODR2vhLAfD4n@7L-)fur4;J6kK z`!`mfbI)d#A$sD7m|Tjn?ioys@0$tSCUpc{8E_ey{Ihk21^p%8WgI?cH~`oVi}I)A za0BV-JVAYLzdf$#sO>y;hT`{iUg10HMc5Qa&JHZ=9%>R>u!B1Ipx*4qH}VC^8EPr-f1199QAk$#OEqpugl4X_`l+%K6=) zZyFy@xn|uwsg=ONmAcGCDdZfNI>HTdz$esg`}z=j(FwLOI~1#McZvO=Vm_TE9)msu zltr!J`|aOGk@VPz6P$%T*~p3BaMU!sv1!;_s6(4NG_r4O^)Is)f^Y4){#Ry-L%0%G zJE$NMd*pHabr}zc9Gj<4(*&T{<);c6X<{7DsB=!L4;Y?-k3OD%10_Q@Np8lH13Vd& zMZXC8KZ)pmX#S2v$MR-EdVHM|jTuljdw^V%P+k}!Zwu&JDx-PqMR*)Ud;eO1;Vp4F zbg3JA-~97+33;$bM~9!eX*)k&V5ra%^I@szu#-fNU7q1);99q<<0RK5u4%=% z7FF}du($3ERM;#wZgT2^qsKz_dEPTu$mU^MeV_L698}p6EHEPFouna)6&wS_ zibvddN54yTWkqoqZJ_<5fpdG)kjN+8;M5=T#gtSeKBB&iaHO$+)MJ^-8pV_!$Cuw~ zow-%tspjmFGAGMhUFymmYl#G?n&g0QE@x-xkpMF#^W2M-;Q%Rkm%|&{f3H4?XhIa+ zd9~dSFI~F)f}SDIDY)Vy_C8RC3>Ym;gvhN|33-~2Jz-tsR;bh8pT@USlROMrT9Wti zP*3%3DlGI%SCzh3Kzc+;wfG;NLZ5J0k2>sV0>c}>8pL?xwyn{$mSmO%34&W=rqNX3 zV;72NK{A|V@(-rsb*D*AtjkJ?s^yy1ErP`kG~Y<3hKHlKLzqV_9Xm};58i=vWoFJ# zFQ|*cr5xN2pPbAb<&*PEkc$4aBycs0mLMEBjj`#LCA}pn(J0VNz1!(hjT5Qnp*cVd z`TfI58hI~kLS6pdMfTmi>QNo~ySK$yb;7`mTs^ zUqpG_Uuf8tFiX(k;NrgUb~m|%tI%UM`vmN19QgeE4Cj66Bb;hmEpN+jACGsa?rDUY zl*cK{^gS3Q=ke>>*SWFF^5XXT{mdH2so5uK%vIcc(O~-(;lU8KYS~Hqguj9sI92Z| z`UbffE~WdPpGL`#)}$kU3kc};PYuetAev$>RY?&dn9|q%DTqiwh>9_b*%lWFwe2Y@ z#i{9PCb(U)5OrPAd8#kW`LPC9mZOhsB@Q-{q3e9Dv>3v(jOxJ{@69(zy>oGpIc>9Z z1tsqN#q{vIVXjF4{fN&(+3vkEd+-zB15V%wT0LxV#q`v%p&zai=(1SltdJsg#7rHI z_SVo(GThCm{Y^haZ(@(09lfa#mZzC&sdIZnuLksfevt2^{6&q>bh4UKN-`4|Udv@X z?pRQvmny@apE6D+J_ht+v*ceq%i8FG@!@|2Q=EMLEtaZwPY2L{>Bgy+BeK?tgoEGq z$VC{Lm-VC+>j+E$s{uJ}#OUausf@&+`>r7kw9lJNc%onzwmJ@goUF)g9AU;3jHHz&2rSCQ^& zV|P(k?-0>;*7pkn$AY1#X7h`6$2$JUX$=IHx`TS@$_q1y(2Cj21WE{Wz2TSWGa}M3 zuVJr*xE3`N6@{G~=VlM+wO`TF&TTnM3}|B-1@@=f92rowta!41Y#wbz?sllP_GDuP zS2BL-*6utW$BQ+l1ZH-mX=PVCP6HvaqH6M;uD4;=Cvxl;!8p5bFf4byJIH}{RPVL&4zAi{uGq0N=8Fx^{wkw?r%8UrmcBa$28z%tb=&s9eibVTmc7D?_czlfhXjRKja! z9iVB6(vmSOpfneXy(V$F>_p_ihN72Oj&qynH{z^&VIKp7QWlOPI!VNg#5La@_J3Ny ze@o}XBt!pY*=bI49fDLvTX5n5k||v|!8k6&{*8{rF)Fkrqx6bao!iLEY18TP=j|fO z$w$-{!blEdv2`u(2BQQh)ho(i-(g+!lw_>whn#m>E?uN^=>ra;5S;QBachOMe#uSt zmmvQyQQNz)*YNcsrNN?zgpRafk$*WfDyYl7@|6eGGBk8^*4+5WxQg#q8d`S0rbrLz zj45UJ^py1>Tr|5iS7KGyePbcyZLvu7z3QQGM0V`M@JiSC9TM#-+xOqfCr|H)jKXnQ zZ}_yx-4W@9l`hu8i9)1|Kn0Yeh&8j> zE97U3UbVQdf{q^?Il#f7lvZ5P%7l+NU@)r#o>qzV;^MX#95q8p9b@67Y*QXgZdc~s zdj8JQ7#+7Scxc#kDXg^#W@8k7SK<{9nBv*ZbYEv z%rSkZBT+3J8JvXzFL*}*{sfj8Q3{F}rK}#sV`hwOE^JYQ>3g^Zru085kZ{ttqWov$ zxn0rVI@Ls}GHy;)h%IpigCp@o0LsFcyn4ZI27lW6r3;>=J=VUg#KiZkL$GA+79EVe zYKDP|oHy;~&?3L4isz9ho9&&oHBAq;S`i0PQRZWxFa7)GY$*S*I1h%0e@lTUA*+X2 z%3=pgiq+1{&p`9xf9nv~>wltFV@M&Z*@+9!HgM-FS(2%0!fRGAMXg4&-#3zHo;V(b zPCeek`q4n{wd5vdT(Eeqop@^*qrXaEjiX<6G{?R{3S>i@BPuf{{Qs0r_Px3PQ984J z7hA_(sM9z`MO2+)v^S_GvowX1kRPL!{(r)#T~O%3q=~35!2jHmRMd|>M~#)?z2uMJ z9$JDuqPby5gu7ACt|u2&#KAU%AgkoB2?afV9H2wYYeWYWX>Vc5GqmT3MLO#U7G!&0 zY=}}W;}ok<9?J%`BU*Y#2&ThtfK#u)wSGT zseEp)FSe%p7!IW>biCMswqSGkcbea)e>>#d_=nPqiFh{bALLOCcKW7<_qniG|%E`l@R^E z!Tc}{7sh0SMpYR~3YsMYEh|0i6o=E$PZ>U)V04?!XrFHk^1J_yap5V!Ybe5fKqL`fSESr@+y9=!>5Nd4=0#5+E(RQ}g~0OTMT1CKjPW&-5HXpUDt zv(zAOD1jyGT>p|{$}A!Y3}NTE@&Y5e3bIAk+O_bKqLQ6*O^$Tz3UTX~R1`{2LA$b6 zb5>|86HF9Hy6FWRDG8i48d3$jO?@V?Rdun5H6lO|%CEI>Y6Q(jI#=`D4e&22epsX%cr3Deo-RUSo<*Gq| zI=h76*lgBqyiP{WRIj0W&>Zd=th&*~uH|GbEd@Ks>m{KExL{|fKEyq{MP%KJ7j*u({BH>-)0dZito*Qt)h`0|C9}8C=BcTD?6ArmyXboh- zXe2x!vN4w{lI3&pC)Y?s&x=o58o<@064TuxmLBFz;fhp%;GuwZ5E7wq-02djsGs@n zQ5DOKP(^4d0{{W!jEQ#%MB8&|Nq+1(O4`kgkQ94t5vStdKxc?aW5NI__q>kEt}J>t zw_{uw>QhY(VZ6SHC){FiR6q}Gcp!c=>_9kMn~22F8=ZhR2z=~N<$Tsj3mJ3ISHYBh zFW3d zMB#K2MZ3d6yCsNi zyn;El;+M@I+08V8#l-Td{=bY;y;v$ik?#=T>f#b3i|FGKv+@Eb(yQPQF`I9~u~Cjp zBKg21Qy@oEY1oREveyqtv+GbR{{#_Qe#lgclgjz(INEj5T|j6_+)CkAIDWv#xJNg` zNU>>irnFU#mXm@IaIq`%%1_KyCmjx7vr|M%MW*y>ap}zX!BMZ_Q0m?pItMP}fG-5m z*I3g_T2#>gX_-l2V1&^oRNnO%zT%sh%FKZHqe9zDBxk6JKWznZ+0&kSpF|YHv_-ts zc|Lnyc$Y*tWWdMzYpWUM*9VB%+$@qS=7$wpXFF{&aDCeVniB4S3*L(Hl%P7fJA4ES z4KSzmFD7(~usz%bzcG}XP{rS2VgmG`unf9%TosX= zaVig$`SkMeAu!sTa3^xd84z6=0C;;Z@!SPSlPdBFg)7E7Tg2MlA@EW+&}6pJzW^c( zx5){^x5(SEE|Hn~zLYMJMGNK|==M!p=17%>jdziqq=y0w7g??8evPINImZyZIrEEcnaEd| z+A)MO1I}Awn6l`;ei!-n6|v&jd{PbmXdjlF74S)SAT)R=n_hcu=8qy`&zP%KmJ0&7 zca$+sNR`$g2N<=9^>{cgO%^d}!j3dzT}d~j-Uwz@!!YsDnBn)8XryQlT0ZURD8~3t z8!Pu>5%bJ+4EzBc4dKXbi*ng|O2Ey*blGinJRD?aDJoFi(g=?`dEJWi`85H_RDy+@ zg)E4|)hnMoZJ6bL_zv+u8Fim;i=bwdMC?zIm$I`WOðz*U4LwApAlOH!cG@s*zgRuj3A9{+d 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

BLtBF{_p0E-SY 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 M)W)RH=s?8(13#!zjsO4v literal 0 HcmV?d00001 diff --git a/src/bot-registry.ts b/src/bot-registry.ts index 9f8c61149..2f21ddd5d 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -1167,6 +1167,12 @@ export interface BotConfig { * routing or permissions. */ brandLabel?: string; + /** + * Whether ordinary reply-card footers show native Context Usage and Token + * Usage. Missing/true preserves the default display; false hides both metrics + * without disabling Usage Ledger accounting or other footer chrome. + */ + showUsageInCardFooter?: boolean; /** * botmux slash 可注入的 CLI 原生斜杠命令 allowlist(如 ["/compact","/model"])。 * 缺省/空 = 通用注入关闭。/cd 永远被拒(见 core/slash-inject.ts)。 @@ -1363,6 +1369,7 @@ export function __testOnly_resetBotRegistry(): void { loadedConfigPath = undefined; oncallChatCache = null; brandLabelCache = null; + showUsageInCardFooterCache = null; } // Wire the i18n lookup so `localeForBot()` can resolve per-bot locale without @@ -1635,6 +1642,7 @@ export function isChatOncallBoundForAnyBot(chatId: string): boolean { // Per-bot brand label, mtime-cached for the disk fallback. Keyed by larkAppId → // the configured value (undefined when the bot has no brandLabel key). let brandLabelCache: { mtimeMs: number; map: Map } | null = null; +let showUsageInCardFooterCache: { mtimeMs: number; map: Map } | null = null; /** Resolve the bots.json path the same way loadBotConfigs does, without * requiring the registry to have been loaded (works in one-shot CLI processes @@ -1687,6 +1695,41 @@ export function resolveBrandLabel(larkAppId: string): string | undefined { } } +/** + * Resolve the per-bot, default-on reply-card usage visibility. A freshly loaded + * registry wins over the spawn-time env so long-lived panes observe `/botconfig` + * hot updates; sandboxed/env-only processes carry the same field in their + * synthetic registered bot and otherwise fall back to the injected env. + */ +export function resolveShowUsageInCardFooter(larkAppId: string): boolean { + const inMem = bots.get(larkAppId); + if (inMem) return inMem.config.showUsageInCardFooter !== false; + if (process.env.BOTMUX_LARK_APP_ID === larkAppId + && 'BOTMUX_SHOW_USAGE_IN_CARD_FOOTER' in process.env) { + return process.env.BOTMUX_SHOW_USAGE_IN_CARD_FOOTER !== 'false'; + } + const path = loadedConfigPath ?? botsConfigDiskPath(); + if (!path) return true; + try { + const stat = statSync(path); + if (!showUsageInCardFooterCache || showUsageInCardFooterCache.mtimeMs !== stat.mtimeMs) { + const raw = JSON.parse(readFileSync(path, 'utf-8')); + const map = new Map(); + if (Array.isArray(raw)) { + for (const entry of raw) { + if (entry && typeof entry.larkAppId === 'string') { + map.set(entry.larkAppId, entry.showUsageInCardFooter !== false); + } + } + } + showUsageInCardFooterCache = { mtimeMs: stat.mtimeMs, map }; + } + return showUsageInCardFooterCache.map.get(larkAppId) ?? true; + } catch { + return true; + } +} + /** * 只读 accessor:该 bot 配置的 tuiSlashAllow allowlist(TUI 通用 slash 注入用)。 * 仅读内存态注册表,daemon 进程内使用;无需 bots.json 磁盘回退(不同于 @@ -2173,6 +2216,8 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { // Preserve '' distinctly from undefined: '' means "brand off", undefined // means "use default botmux brand". Don't trim-to-undefined here. brandLabel: typeof entry.brandLabel === 'string' ? entry.brandLabel : undefined, + // Default ON: only explicit false is meaningful/persisted. + showUsageInCardFooter: entry.showUsageInCardFooter === false ? false : undefined, disableStreamingCard: entry.disableStreamingCard === true || undefined, silentTurnReactions: entry.silentTurnReactions === true || undefined, receivedReactionEmoji: typeof entry.receivedReactionEmoji === 'string' && entry.receivedReactionEmoji.trim() diff --git a/src/cli.ts b/src/cli.ts index b1c8fb8c6..a8e41be58 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3054,6 +3054,7 @@ interface AdoptedFromData { herdrTarget?: string; herdrPaneId?: string; originalCliPid?: number; + sessionId?: string; cwd?: string; cliId?: string; } @@ -3108,6 +3109,8 @@ interface SessionData { // here, so they're typed loosely. Used by cmdList to avoid reporting an // unconfirmed /adopt scratch as a crashed CLI session. cliId?: string; + /** CLI-native resume id when it differs from botmux's Session id. */ + cliSessionId?: string; lastCliInput?: string; adoptedFrom?: AdoptedFromData; /** Deliberately suspended by the resident-session cap. No process/backing @@ -4240,6 +4243,102 @@ function findDaemon(larkAppId?: string): { return all[0] ?? null; } +function normalizeCardUsageSnapshot(value: unknown): CardUsageSnapshot | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const raw = value as Record; + const rawContext = raw.context; + const rawTokens = raw.tokens; + + let context: CardUsageSnapshot['context'] = null; + if (rawContext && typeof rawContext === 'object' && !Array.isArray(rawContext)) { + const c = rawContext as Record; + if (typeof c.usedTokens === 'number' + && Number.isFinite(c.usedTokens) + && c.usedTokens >= 0) { + context = { + usedTokens: c.usedTokens, + ...(typeof c.windowTokens === 'number' + && Number.isFinite(c.windowTokens) + && c.windowTokens > 0 + ? { windowTokens: c.windowTokens } + : {}), + ...(typeof c.percentUsed === 'number' + && Number.isFinite(c.percentUsed) + && c.percentUsed >= 0 + ? { percentUsed: c.percentUsed } + : {}), + }; + } + } + + let tokens: CardUsageSnapshot['tokens'] = null; + if (rawTokens && typeof rawTokens === 'object' && !Array.isArray(rawTokens)) { + const u = rawTokens as Record; + if (typeof u.in === 'number' + && Number.isFinite(u.in) + && u.in >= 0 + && typeof u.out === 'number' + && Number.isFinite(u.out) + && u.out >= 0) { + tokens = { in: u.in, out: u.out }; + } + } + + return { context, tokens }; +} + +/** Prefer the resident daemon's incremental transcript cache. Older/offline + * daemons and isolated environments fall back to the local reader; either path + * degrades to explicit unavailable facts without blocking the reply. */ +async function readCardUsageSnapshotForSend( + session: SessionData, + larkAppId: string, +): Promise { + let daemonPort: number | undefined; + try { + daemonPort = + findDaemon(larkAppId)?.ipcPort + ?? resolveDaemonIpcPort(undefined, process.env.BOTMUX_DAEMON_IPC_PORT); + } catch { + // A stale/unreadable daemon registry must not prevent the reply. + } + if (daemonPort) { + try { + const path = `/api/sessions/${encodeURIComponent(session.sessionId)}/usage`; + const response = await fetchDaemonIpc(daemonPort, path, { + method: 'GET', + signal: AbortSignal.timeout(1_500), + }); + if (response.ok) { + const body = await response.json() as { usage?: unknown }; + const normalized = normalizeCardUsageSnapshot(body.usage); + if (normalized) return normalized; + } + } catch { + // No host secret, old daemon, timeout, or transient IPC failure: use the + // same bounded local parser below. + } + } + + // Old/offline daemon fallback. Sandboxed panes receive this per-bot value + // explicitly from the worker because bots.json is intentionally unreadable. + if (!resolveShowUsageInCardFooter(larkAppId)) { + return { context: null, tokens: null }; + } + + try { + return getSessionUsageSnapshot({ + cliId: (session.cliId ?? session.adoptedFrom?.cliId ?? 'unknown') as CliId | 'unknown', + sessionId: session.sessionId, + cliSessionId: session.cliSessionId ?? session.adoptedFrom?.sessionId, + cwd: session.workingDir ?? session.adoptedFrom?.cwd, + fresh: true, + }); + } catch { + return { context: null, tokens: null }; + } +} + /** * Authenticate the human who opened this exact turn against the target run, * then return the only daemon app that may receive the mutation. Inherited @@ -5862,11 +5961,18 @@ function withCustomCardMentionFooter( // Card v2 body builder helpers — extracted to im/lark/md-card.ts so the // daemon's bridge fallback path can produce identical cards. cmdSend // keeps using `buildImageCardElements` from there. -import { buildImageCardElements, brandFooterSegment, prepareCardMarkdown, type LocalHomeLinkMode } from './im/lark/md-card.js'; +import { + buildImageCardElements, + buildReplyCardFooter, + prepareCardMarkdown, + type CardUsageSnapshot, + type LocalHomeLinkMode, +} from './im/lark/md-card.js'; import { applyInlineMentions } from './im/lark/inline-mentions.js'; import { renderBrandTemplate } from './im/lark/brand-template.js'; -import { resolveBrandLabel } from './bot-registry.js'; +import { resolveBrandLabel, resolveShowUsageInCardFooter } from './bot-registry.js'; import { config } from './config.js'; +import { getSessionUsageSnapshot } from './core/cost-calculator.js'; import { resolveQuoteTarget, validateMentionDecision, @@ -6046,6 +6152,8 @@ async function registerSelfFromCredFile(): Promise { larkAppSecret: cred.larkAppSecret, cliId: 'claude-code', brand: cred.brand as 'feishu' | 'lark' | undefined, + showUsageInCardFooter: + process.env.BOTMUX_SHOW_USAGE_IN_CARD_FOOTER === 'false' ? false : undefined, } as import('./bot-registry.js').BotConfig); } @@ -6110,6 +6218,8 @@ function riffModeSession(opts: { evenWithLocalSessions?: boolean } = {}): { sess brand, cliId: 'riff', allowedUsers: [], + showUsageInCardFooter: + process.env.BOTMUX_SHOW_USAGE_IN_CARD_FOOTER === 'false' ? false : undefined, } as unknown as import('./bot-registry.js').BotConfig; const session: SessionData = { @@ -7263,9 +7373,6 @@ async function cmdSend(rest: string[]): Promise { // Brand segment honours this bot's configured brandLabel (unset → // default botmux, '' → suppressed, else custom). Same resolver/rule as // the daemon's card builders so both send paths render identically. - const footerParts: string[] = []; - const brandSeg = brandFooterSegment(renderBrandTemplate(resolveBrandLabel(appId), s.workingDir)); - if (brandSeg) footerParts.push(brandSeg); // All real mentions land on one footer line: human addressee first, then // explicit @ targets (incl. handoff bots), then cc. Ids already inlined in // the body prose are skipped. Top-level publish keeps sendTo empty. @@ -7275,9 +7382,13 @@ async function cmdSend(rest: string[]): Promise { cc: footerAddressing.cc, inlinedIds: usedIds, }); - if (footerRecipients.length > 0) { - footerParts.push(`${t('card.sent_to', undefined, localeForBot(appId))}${footerRecipients.map(id => ``).join(' ')}`); - } + const usageSnapshot = await readCardUsageSnapshotForSend(s, appId); + const footer = buildReplyCardFooter({ + brand: renderBrandTemplate(resolveBrandLabel(appId), s.workingDir), + recipientOpenIds: footerRecipients, + usage: usageSnapshot, + locale: localeForBot(appId), + }); // Footer line (brand 个性签名 + 发送给) and the optional 🔊 语音总结 button // share ONE row: footer text on the left (weighted, fills), button pinned // to the far right (auto width). When voice isn't configured the footer @@ -7293,9 +7404,7 @@ async function cmdSend(rest: string[]): Promise { voiceOn = isVoiceConfigured(appId); } catch { /* voice module/config unavailable → no button */ } } - const footerContent = footerParts.length > 0 - ? `${footerParts.join(' · ')}` - : ''; + const footerContent = footer?.content ?? ''; if (footerContent || voiceOn) { elements.push({ tag: 'hr' }); if (voiceOn) { @@ -7324,11 +7433,7 @@ async function cmdSend(rest: string[]): Promise { ], }); } else { - elements.push({ - tag: 'markdown', - text_size: 'notation_small_v2', - content: footerContent, - }); + if (footer) elements.push(footer.element); } } diff --git a/src/core/cost-calculator.ts b/src/core/cost-calculator.ts index df4d189b7..aa8d51ba0 100644 --- a/src/core/cost-calculator.ts +++ b/src/core/cost-calculator.ts @@ -28,6 +28,24 @@ export interface SessionTokenUsage extends SessionCost { out: number; } +/** Latest Context Usage reported by the Agent CLI. A missing window means the + * CLI reported usage but not its model's context capacity. `percentUsed`, when + * present, is produced by the source parser from native facts and remains + * consistent with the displayed measurement; the card renderer never infers + * it. */ +export interface SessionContextUsage { + usedTokens: number; + windowTokens?: number; + percentUsed?: number; +} + +/** Card-facing usage snapshot. Context is latest-turn state while Token Usage + * is cumulative for the Session; neither value is inferred from the other. */ +export interface SessionUsageSnapshot { + context: SessionContextUsage | null; + tokens: SessionTokenUsage | null; +} + export interface SessionTokenUsageQuery { cliId?: CliId | 'unknown'; sessionId: string; @@ -37,7 +55,7 @@ export interface SessionTokenUsageQuery { * (CLI-data-redirected) bots' transcripts under BOT_HOME. */ larkAppId?: string; /** Bypass the reparse throttle (stat short-circuit and incremental folding - * still apply). Use at low-frequency exact points like ledger snapshots. */ + * still apply). Use at low-frequency exact points like ledger/card snapshots. */ fresh?: boolean; } @@ -144,6 +162,33 @@ function extractCodexTokenCountUsage(entry: any): SessionTokenUsage | null { }; } +function extractCodexContextUsage(entry: any): SessionContextUsage | null { + if (entry?.type !== 'event_msg' || entry?.payload?.type !== 'token_count') return null; + const info = entry.payload?.info; + const last = info?.last_token_usage; + if (!last || typeof last !== 'object') return null; + // Codex's native absolute gauge is last_token_usage.total_tokens. Cached + // input is already a subset of input and must never be added again. + const totalTokens = pickNum(last, ['total_tokens', 'totalTokens']); + const usedTokens = totalTokens > 0 + ? totalTokens + : pickNum(last, ['input_tokens', 'inputTokens']); + if (usedTokens <= 0) return null; + const windowTokens = pickNum(info, ['model_context_window', 'modelContextWindow']); + // This card reports raw Context Usage, so keep the percentage consistent + // with the displayed numerator/denominator. Codex's TUI separately exposes + // a "user-controllable remaining" meter that subtracts fixed baseline + // prompts/tools; that is a different metric and does not belong here. + const percentUsed = windowTokens > 0 + ? Math.round(Math.max(0, Math.min(1, usedTokens / windowTokens)) * 100) + : undefined; + return { + usedTokens, + ...(windowTokens > 0 ? { windowTokens } : {}), + ...(percentUsed !== undefined ? { percentUsed } : {}), + }; +} + interface TokenUsageAggregate { inputTokens: number; outputTokens: number; @@ -152,6 +197,7 @@ interface TokenUsageAggregate { model: string; turns: number; latestCodexUsage: SessionTokenUsage | null; + latestContextUsage: SessionContextUsage | null; } /** Per-CLI transcript dialect. Each kind only counts the events that dialect @@ -180,7 +226,16 @@ function usageKindForCli(cliId: SessionTokenUsageQuery['cliId']): UsageKind { } function newTokenUsageAggregate(): TokenUsageAggregate { - return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, model: '', turns: 0, latestCodexUsage: null }; + return { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreateTokens: 0, + model: '', + turns: 0, + latestCodexUsage: null, + latestContextUsage: null, + }; } /** Claude Code / Seed: one JSONL line per content block; blocks of the same @@ -199,6 +254,15 @@ function foldClaudeLine(agg: TokenUsageAggregate, seenMessageIds: Set, e agg.outputTokens += num(u.output_tokens); agg.cacheReadTokens += num(u.cache_read_input_tokens); agg.cacheCreateTokens += num(u.cache_creation_input_tokens); + const contextTokens = + num(u.input_tokens) + + num(u.cache_read_input_tokens) + + num(u.cache_creation_input_tokens); + // Synthetic/empty assistant records around compaction must not erase the + // last native context measurement. + if (contextTokens > 0) { + agg.latestContextUsage = { usedTokens: contextTokens }; + } if (!agg.model && typeof msg.model === 'string') agg.model = msg.model; agg.turns++; } @@ -210,6 +274,8 @@ function foldCodexLine(agg: TokenUsageAggregate, entry: any): void { const codexUsage = extractCodexTokenCountUsage(entry); if (codexUsage) { agg.latestCodexUsage = codexUsage; + const contextUsage = extractCodexContextUsage(entry); + if (contextUsage) agg.latestContextUsage = contextUsage; return; } const m = entry?.payload?.model ?? entry?.payload?.collaboration_mode?.settings?.model; @@ -558,7 +624,7 @@ function readTokenUsageFromAidenCheckpoint(path: string): SessionTokenUsage | nu }; } -export function getSessionTokenUsage(q: SessionTokenUsageQuery): SessionTokenUsage | null { +function readSessionUsage(q: SessionTokenUsageQuery): UsageReadResult | null { if (q.cliId === 'aiden') { const sid = q.cliSessionId || q.sessionId; const checkpointPath = cachedTranscriptPathLookup( @@ -571,11 +637,23 @@ export function getSessionTokenUsage(q: SessionTokenUsageQuery): SessionTokenUsa { retryMiss: q.fresh, refreshHit: q.fresh }, ); if (!checkpointPath || !existsSync(checkpointPath)) return null; - return readSessionTokenUsageFile(checkpointPath, 'aiden', { fresh: q.fresh }); + return readSessionTokenAggregateCached(checkpointPath, 'aiden', { fresh: q.fresh }); } const resolved = resolveSessionTranscriptPath(q); if (!resolved || !existsSync(resolved.path)) return null; - return readSessionTokenUsageFile(resolved.path, usageKindForCli(q.cliId), { fresh: q.fresh }); + return readSessionTokenAggregateCached(resolved.path, usageKindForCli(q.cliId), { fresh: q.fresh }); +} + +export function getSessionTokenUsage(q: SessionTokenUsageQuery): SessionTokenUsage | null { + return readSessionUsage(q)?.result ?? null; +} + +export function getSessionUsageSnapshot(q: SessionTokenUsageQuery): SessionUsageSnapshot { + const read = readSessionUsage(q); + return { + context: read?.agg.latestContextUsage ?? null, + tokens: read?.result ?? null, + }; } export function formatNumber(n: number): string { diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 5af39699a..caad26364 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -66,7 +66,18 @@ import { readGlobalConfig } from '../global-config.js'; import { normalizeChatReplyMode, setChatReplyMode, type ChatReplyMode } from '../services/chat-reply-mode-store.js'; import * as chatFirstSeenStore from '../services/chat-first-seen-store.js'; import * as scheduler from './scheduler.js'; -import { listActiveSessions, findActiveBySessionId, closeSession, getActiveSessionsRegistry, transferSession, deliverWriteLinkCardToOwners, forkWorker, suspendWorker, killWorker } from './worker-pool.js'; +import { + listActiveSessions, + findActiveBySessionId, + closeSession, + getActiveSessionsRegistry, + transferSession, + deliverWriteLinkCardToOwners, + forkWorker, + suspendWorker, + killWorker, + getDaemonReplyCardUsageSnapshot, +} from './worker-pool.js'; import { listOnlineDaemons } from '../utils/daemon-discovery.js'; import { isSessionStopped } from './session-liveness.js'; import { getChatMode, replyMessage, sendMessage, resolveUnionIdFromOpenId, listThreadMessages, listChatMessages, listChatBotMembers, getUserProfile, getUserProfileStrict, resolveAllowedUsersWithMap, type ChatBotMember } from '../im/lark/client.js'; @@ -553,6 +564,16 @@ ipcRoute('GET', '/api/sessions/:sessionId', (_req, res, params) => { jsonRes(res, 404, { error: 'not_found' }); }); +/** Low-frequency card-display read used by `botmux send`. Keeping the + * transcript reader and per-bot visibility decision in the resident daemon + * preserves its incremental cache and live config instead of making every + * short-lived CLI process rescan the Session or guess from sandboxed files. */ +ipcRoute('GET', '/api/sessions/:sessionId/usage', (_req, res, params) => { + const ds = findActiveBySessionId(params.sessionId); + if (!ds) return jsonRes(res, 404, { error: 'not_found' }); + jsonRes(res, 200, { usage: getDaemonReplyCardUsageSnapshot(ds) }); +}); + /** Canonical daemon-side close used by the dashboard and `botmux delete`. * Host callers authenticate with HMAC; a read-isolated CLI may close only its * exact live session with the rotating per-turn capability. */ diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 70631f38d..0e85514a0 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -30,7 +30,13 @@ import { traeHome } from '../services/traex-paths.js'; import { botLocale, localeForBot, t as tr } from '../i18n/index.js'; import { claudeJsonlPathForSession } from '../adapters/cli/claude-code.js'; import { findUniqueClaudeSessionByCwd } from './session-discovery.js'; -import { buildMarkdownCard, buildContextualReplyCard, type LocalHomeLinkMode } from '../im/lark/md-card.js'; +import { + buildMarkdownCard, + buildContextualReplyCard, + type CardUsageSnapshot, + type LocalHomeLinkMode, +} from '../im/lark/md-card.js'; +import { getSessionUsageSnapshot } from './cost-calculator.js'; import { renderBrandTemplate } from '../im/lark/brand-template.js'; import { replyToDocComment, chunkCommentText, unsubscribeDocFile, removeCommentReaction } from '../im/lark/doc-comment.js'; import { listDocSubscriptionsForSession, removeDocSubscription } from '../services/doc-subs-store.js'; @@ -67,6 +73,61 @@ function daemonCardLocalHomeLinkMode(ds: DaemonSession): LocalHomeLinkMode { : 'filesystem'; } +/** Read one frozen native-usage snapshot at the reply boundary. Card delivery + * remains best-effort even when a CLI has no supported transcript or a usage + * resolver fails. */ +export function getDaemonSessionUsageSnapshot( + ds: DaemonSession, + effectiveCliId?: CliId, +): CardUsageSnapshot { + try { + const resolvedCliId = effectiveCliId ?? ( + ds.session.cliId + ?? ds.session.adoptedFrom?.cliId + ?? ds.adoptedFrom?.cliId + ?? getBot(ds.larkAppId).config.cliId + ) as CliId; + return getSessionUsageSnapshot({ + cliId: resolvedCliId, + sessionId: ds.session.sessionId, + cliSessionId: + ds.session.cliSessionId + ?? ds.session.adoptedFrom?.sessionId + ?? ds.adoptedFrom?.sessionId, + cwd: + ds.workingDir + ?? ds.session.workingDir + ?? ds.session.adoptedFrom?.cwd + ?? ds.adoptedFrom?.cwd, + fresh: true, + }); + } catch (error) { + logger.warn( + `[${ds.session.sessionId.slice(0, 8)}] Failed to read card usage snapshot: ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + return { context: null, tokens: null }; + } +} + +/** Card-specific usage acquisition. Keep the display preference out of the + * native usage reader so accounting and dashboard consumers remain intact. + * A concrete empty snapshot (rather than undefined) also freezes "hidden" over + * final-output retries. */ +export function getDaemonReplyCardUsageSnapshot( + ds: DaemonSession, + effectiveCliId?: CliId, +): CardUsageSnapshot { + try { + if (getBot(ds.larkAppId).config.showUsageInCardFooter === false) { + return { context: null, tokens: null }; + } + } catch { + // Missing runtime config must preserve the backwards-compatible default ON. + } + return getDaemonSessionUsageSnapshot(ds, effectiveCliId); +} + import { normalizeBrand } from '../im/lark/lark-hosts.js'; import { dashboardEventBus } from './dashboard-events.js'; import { composeRowFromActive, composeRowFromClosed } from './dashboard-rows.js'; @@ -3951,6 +4012,7 @@ function setupWorkerHandlers( locale: localeForBot(ds.larkAppId), workingDir: ds.workingDir, localHomeLinkMode: daemonCardLocalHomeLinkMode(ds), + usage: getDaemonReplyCardUsageSnapshot(ds, effectiveCliId), }); scopedReply(cardJson, 'interactive', msg.turnId).catch((err: any) => { logger.warn(`[${t}] Failed to deliver adopt_preamble to Lark: ${err.message}`); @@ -4129,7 +4191,9 @@ function deliverFinalOutput( msg: Extract, t: string, attempt: number, + frozenUsage?: CardUsageSnapshot, ): void { + let cardUsage = frozenUsage; const managedReceiver = !!ds.session.vcMeetingReceiver; // Wait Mode / HTTP Sync Override: // If this turn is being waited for by an HTTP webhook request, intercept the @@ -4320,6 +4384,7 @@ function deliverFinalOutput( : imOrigin?.replyTargetSenderOpenId ?? daemonCardFooterRecipientOpenId(ds, effectiveCliId); const localHomeLinkMode = daemonCardLocalHomeLinkMode(ds); + cardUsage ??= getDaemonReplyCardUsageSnapshot(ds, effectiveCliId); const cardJson = msg.kind === 'local-turn' || msg.kind === 'local-turn-headless' ? buildContextualReplyCard({ title: msg.kind === 'local-turn-headless' @@ -4333,6 +4398,7 @@ function deliverFinalOutput( locale: localeForBot(ds.larkAppId), workingDir: ds.workingDir, localHomeLinkMode, + usage: cardUsage, }) : buildMarkdownCard( safeAssistantText, @@ -4341,6 +4407,7 @@ function deliverFinalOutput( localeForBot(ds.larkAppId), ds.workingDir, localHomeLinkMode, + cardUsage, ); const proposedOutput = { @@ -4474,7 +4541,7 @@ function deliverFinalOutput( return; } logger.warn(`[${t}] Bridge final_output attempt ${next} failed (${err.message}); retrying in ${FINAL_OUTPUT_RETRY_BACKOFF_MS[next]}ms`); - deliverFinalOutput(ds, msg, t, next); + deliverFinalOutput(ds, msg, t, next, cardUsage); } }, FINAL_OUTPUT_RETRY_BACKOFF_MS[attempt] ?? 0); } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index ecf82b745..996ebb138 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -394,6 +394,7 @@ export const messages: Record = { 'card.config.p2p.thread': '🧵 thread (separate session/DM)', 'card.config.p2p.chat': '💬 chat (continuous session, default)', 'config.label.disableStreamingCard': 'Disable live card', + 'config.label.showUsageInCardFooter': 'Usage in card footer', 'config.label.silentTurnReactions': 'Disable status reactions', 'config.label.writableTerminalLinkInCard': 'Writable terminal in card', 'config.label.privateCard': 'Private snapshot card', @@ -1071,6 +1072,8 @@ export const messages: Record = { // Markdown / contextual reply card chrome 'card.you': 'You', 'card.sent_to': 'Sent to: ', + 'card.usage.context': 'Context', + 'card.usage.tokens': 'Tokens', // Adopt preamble card title 'card.adopt_last_round': '📜 Last exchange before /adopt', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 0b9505a13..ba206aac8 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -397,6 +397,7 @@ export const messages: Record = { 'card.config.p2p.thread': '🧵 thread(每条 DM 独立会话)', 'card.config.p2p.chat': '💬 chat(连续单聊会话,默认)', 'config.label.disableStreamingCard': '关闭实时卡片', + 'config.label.showUsageInCardFooter': '卡片页脚用量', 'config.label.silentTurnReactions': '关闭状态 reaction', 'config.label.writableTerminalLinkInCard': '卡内嵌可写终端', 'config.label.privateCard': '私有快照卡', @@ -1074,6 +1075,8 @@ export const messages: Record = { // Markdown / contextual reply card chrome 'card.you': '你', 'card.sent_to': '发送给:', + 'card.usage.context': '上下文', + 'card.usage.tokens': 'Token', // Adopt preamble card title 'card.adopt_last_round': '📜 /adopt 前最后一轮', diff --git a/src/im/lark/card-builder.ts b/src/im/lark/card-builder.ts index 51ae0be10..9f0b1842b 100644 --- a/src/im/lark/card-builder.ts +++ b/src/im/lark/card-builder.ts @@ -15,7 +15,7 @@ export const CONFIG_UNSET = '__unset__'; /** 布尔字段按配置页的逻辑分组(与 dashboard 的 Bot Profiles 区块对应)。 */ const CONFIG_CARD_BOOLEAN_GROUPS: ReadonlyArray<{ sec: string; keys: readonly string[] }> = [ - { sec: 'card.config.sec.card', keys: ['disableStreamingCard', 'silentTurnReactions', 'writableTerminalLinkInCard', 'privateCard'] }, + { sec: 'card.config.sec.card', keys: ['showUsageInCardFooter', 'disableStreamingCard', 'silentTurnReactions', 'writableTerminalLinkInCard', 'privateCard'] }, { sec: 'card.config.sec.autostart', keys: ['autoStartOnGroupJoin', 'autoStartOnNewTopic'] }, { sec: 'card.config.sec.security', keys: ['disableCliBypass', 'restrictGrantCommands'] }, ]; @@ -1990,4 +1990,3 @@ export function buildCodexAppThreadSelectCard(threads: CodexAppThreadSummary[], }; return JSON.stringify(card); } - diff --git a/src/im/lark/card-handler.ts b/src/im/lark/card-handler.ts index c37170dd3..7bfbe404b 100644 --- a/src/im/lark/card-handler.ts +++ b/src/im/lark/card-handler.ts @@ -10,7 +10,13 @@ import { getBot, getAllBots, getOwnerOpenId } from '../../bot-registry.js'; import { canOperate, canTalk } from './event-dispatcher.js'; import { updateMessage, deleteMessage, replyMessage, sendMessage, sendUserMessage, sendEphemeralCard, getMessageDetail, isHumanOpenId, resolveUserUnionId as defaultResolveUserUnionId } from './client.js'; import { buildSessionCard, buildStreamingCard, buildTuiPromptCard, buildTuiPromptProcessingCard, buildTuiPromptResolvedCard, buildGrantResultCard, buildGrantNotifyCard, getCliDisplayName, truncateContent, buildConfigCard, buildConfigTextCard, CONFIG_UNSET, buildRepoSelectCard } from './card-builder.js'; -import { findConfigField, applyConfigField, coerceConfigValue, getConfigCardData } from '../../services/bot-config-store.js'; +import { + findConfigField, + applyConfigField, + coerceConfigValue, + getConfigCardData, + resolveConfigBooleanValue, +} from '../../services/bot-config-store.js'; import { updateBotGrantPrefs } from '../../services/grant-prefs-store.js'; import { writeTeamRoleFile, deleteTeamRoleFile } from '../../core/role-resolver.js'; import { addChatGrant, addGlobalGrant } from '../../services/grant-store.js'; @@ -1327,7 +1333,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe let r; if (value.action === 'config_toggle') { if (spec.kind !== 'boolean') return { toast: { type: 'error', content: t('cmd.config.invalid_bool', { field: spec.key, value: '' }, loc) } }; - const cur = (cbot.config as any)[spec.configKey] === true; + const cur = resolveConfigBooleanValue(spec, (cbot.config as any)[spec.configKey]); r = await applyConfigField(larkAppId, spec, !cur); } else { const raw = (action as any)?.option ?? (action as any)?.input_value ?? ''; diff --git a/src/im/lark/md-card.ts b/src/im/lark/md-card.ts index a4b4ed5d5..23c97b0db 100644 --- a/src/im/lark/md-card.ts +++ b/src/im/lark/md-card.ts @@ -32,6 +32,32 @@ const MAX_LOCAL_HOME_LINK_REPAIRS = 256; export type LocalHomeLinkMode = 'filesystem' | 'lexical' | 'disabled'; +/** Native usage facts rendered in a Bot reply-card footer. Context is the + * latest context-window measurement; tokens are cumulative for the Session. + * Missing facts are omitted independently and must never be estimated. */ +export interface CardUsageSnapshot { + context: { + usedTokens: number; + windowTokens?: number; + percentUsed?: number; + } | null; + tokens: { + in: number; + out: number; + } | null; +} + +export interface ReplyCardFooter { + /** Fully wrapped markdown content, reusable inside the voice-button row. */ + content: string; + /** Standalone footer element used by ordinary reply cards. */ + element: { + tag: 'markdown'; + text_size: 'notation_small_v2'; + content: string; + }; +} + interface LocalHomeCandidate { id: number; start: number; @@ -227,6 +253,12 @@ export function normalizeLocalHomeLinks( /** Default footer brand when a bot has no custom `brandLabel` configured. */ export const DEFAULT_BRAND_LABEL = '[botmux](https://github.com/deepcoldy/botmux)'; +/** A visually empty link that survives Lark's simplified card representation. + * It lets message-parser identify our footer even when brandLabel is disabled + * or custom, without exposing implementation text in the rendered card. */ +export const REPLY_CARD_FOOTER_MARKER = + '[\u200B](https://github.com/deepcoldy/bot%6Dux#reply-card-footer)'; + /** * Resolve the brand segment to render in a card footer from a bot's configured * `brandLabel` (see {@link resolveBrandLabel}): @@ -241,6 +273,87 @@ export function brandFooterSegment(brand: string | undefined): string | null { return brand.trim() ? brand : null; } +function compactTokenCount(value: number): string { + const units = [ + { threshold: 1_000_000_000, suffix: 'B' }, + { threshold: 1_000_000, suffix: 'M' }, + { threshold: 1_000, suffix: 'K' }, + ] as const; + const unit = units.find(candidate => value >= candidate.threshold); + if (!unit) return Math.round(value).toString(); + return `${(value / unit.threshold).toFixed(1).replace(/\.0$/, '')}${unit.suffix}`; +} + +function isNonNegativeFinite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +/** Format usage as one footer segment shared by direct `botmux send` cards and + * daemon fallback cards. The caller supplies native facts; this module only + * formats them and never infers a context window or token count. Returns null + * when no valid native metric is available. */ +export function cardUsageFooterSegment(usage: CardUsageSnapshot, locale?: Locale): string | null { + const parts: string[] = []; + if (usage.context && isNonNegativeFinite(usage.context.usedTokens)) { + const used = compactTokenCount(usage.context.usedTokens); + const window = usage.context.windowTokens; + const windowSuffix = isNonNegativeFinite(window) && window > 0 + ? `/${compactTokenCount(window)}` + : ''; + const percentSuffix = isNonNegativeFinite(usage.context.percentUsed) + ? ` (${Math.min(100, Math.round(usage.context.percentUsed))}%)` + : ''; + const suffix = `${windowSuffix}${percentSuffix}`; + parts.push(`${t('card.usage.context', undefined, locale)} ${used}${suffix}`); + } + if (usage.tokens + && isNonNegativeFinite(usage.tokens.in) + && isNonNegativeFinite(usage.tokens.out)) { + parts.push( + `${t('card.usage.tokens', undefined, locale)} ` + + `↑${compactTokenCount(usage.tokens.in)} ↓${compactTokenCount(usage.tokens.out)}`, + ); + } + return parts.length > 0 ? parts.join(' · ') : null; +} + +/** Build the one canonical footer shared by all Bot Session reply cards. + * Ordering, i18n, the parser marker, grey styling, and recipient rendering live + * here so direct sends and daemon fallbacks cannot drift apart. */ +export function buildReplyCardFooter(opts: { + brand?: string; + recipientOpenIds?: readonly string[]; + usage?: CardUsageSnapshot; + locale?: Locale; +}): ReplyCardFooter | null { + const parts: string[] = []; + const brandSeg = brandFooterSegment(opts.brand); + if (brandSeg) parts.push(brandSeg); + if (opts.usage) { + const usageSeg = cardUsageFooterSegment(opts.usage, opts.locale); + if (usageSeg) parts.push(usageSeg); + } + const recipientOpenIds = [...new Set((opts.recipientOpenIds ?? []).filter(Boolean))]; + if (recipientOpenIds.length > 0) { + parts.push( + `${t('card.sent_to', undefined, opts.locale)}` + + recipientOpenIds.map(id => ``).join(' '), + ); + } + if (parts.length === 0) return null; + + const content = + `${parts.join(' · ')}${REPLY_CARD_FOOTER_MARKER}`; + return { + content, + element: { + tag: 'markdown', + text_size: 'notation_small_v2', + content, + }, + }; +} + /** Build a Feishu native `table` element from a `table_open … table_close` token slice. */ function buildTableFromTokens(tokens: Token[]): any | null { const headerCells: string[] = []; @@ -644,8 +757,8 @@ export function hasMarkdown(text: string): boolean { * * `brand` is the sending bot's configured `brandLabel` (see * {@link brandFooterSegment}): unset → default botmux link, `''` → brand - * suppressed, else custom. When brand and recipient are both absent the whole - * footer (HR included) is omitted. + * suppressed, else custom. When brand, usage, and recipient are all absent the + * whole footer (HR included) is omitted. */ export function buildMarkdownCard( md: string, @@ -654,20 +767,19 @@ export function buildMarkdownCard( locale?: Locale, workingDir?: string, localHomeLinkMode: LocalHomeLinkMode = 'filesystem', + usage?: CardUsageSnapshot, ): string { const elements = md ? buildCardBodyElements(md, workingDir, localHomeLinkMode) : []; - const footerParts: string[] = []; - const brandSeg = brandFooterSegment(brand); - if (brandSeg) footerParts.push(brandSeg); - if (recipientOpenId) footerParts.push(`${t('card.sent_to', undefined, locale)}`); - // Empty brand + no recipient → no footer at all (skip the orphan HR too). - if (footerParts.length > 0) { + const footer = buildReplyCardFooter({ + brand, + recipientOpenIds: recipientOpenId ? [recipientOpenId] : [], + usage, + locale, + }); + // No brand, usage, or recipient → no footer at all (skip the orphan HR too). + if (footer) { elements.push({ tag: 'hr' }); - elements.push({ - tag: 'markdown', - text_size: 'notation_small_v2', - content: `${footerParts.join(' · ')}`, - }); + elements.push(footer.element); } return JSON.stringify({ schema: '2.0', @@ -710,6 +822,7 @@ export function buildContextualReplyCard(opts: { locale?: Locale; workingDir?: string; localHomeLinkMode?: LocalHomeLinkMode; + usage?: CardUsageSnapshot; }): string { const { title, @@ -721,6 +834,7 @@ export function buildContextualReplyCard(opts: { locale, workingDir, localHomeLinkMode = 'filesystem', + usage, } = opts; const elements: any[] = []; @@ -749,17 +863,15 @@ export function buildContextualReplyCard(opts: { : [{ tag: 'markdown', content: `*${t('common.empty_paren', undefined, locale)}*` }]; for (const el of bodyElements) elements.push(el); - const footerParts: string[] = []; - const brandSeg = brandFooterSegment(brand); - if (brandSeg) footerParts.push(brandSeg); - if (recipientOpenId) footerParts.push(`${t('card.sent_to', undefined, locale)}`); - if (footerParts.length > 0) { + const footer = buildReplyCardFooter({ + brand, + recipientOpenIds: recipientOpenId ? [recipientOpenId] : [], + usage, + locale, + }); + if (footer) { elements.push({ tag: 'hr' }); - elements.push({ - tag: 'markdown', - text_size: 'notation_small_v2', - content: `${footerParts.join(' · ')}`, - }); + elements.push(footer.element); } return JSON.stringify({ diff --git a/src/im/lark/message-parser.ts b/src/im/lark/message-parser.ts index 30f03fecd..96ccb01c3 100644 --- a/src/im/lark/message-parser.ts +++ b/src/im/lark/message-parser.ts @@ -627,9 +627,25 @@ function extractTextContent(msgType: string, rawContent: string, mentions?: RawE * too — vanishingly rare versus the value of a simple format-agnostic anchor. */ const BOTMUX_FOOTER_MARKER = 'github.com/deepcoldy/botmux'; - -function isBotmuxFooterLine(line: string): boolean { - return line.includes(BOTMUX_FOOTER_MARKER); +const BOTMUX_HIDDEN_FOOTER_MARKER = /github\.com\/deepcoldy\/bot%6dux#reply-card-footer/i; + +// Defense in depth for Lark clients that discard the footer's zero-width link +// while producing Format A. This intentionally matches only a complete +// usage footer line carrying Bot addressing chrome; the caller additionally +// restricts it to the final non-empty paragraph after real body content. +// A pure usage-shaped line is ambiguous without the stable marker, so it is +// deliberately preserved rather than risking silent body-data loss. +const BOTMUX_CONTEXT_USAGE_PATTERN = + String.raw`(?:上下文|Context)\s+(?:不可用|unavailable|\d+(?:\.\d+)?[KMB]?(?:\/\d+(?:\.\d+)?[KMB]?(?:\s+\(\d+%\))?)?)`; +const BOTMUX_TOKEN_USAGE_PATTERN = + String.raw`(?:Token|Tokens)\s+(?:不可用|unavailable|↑\d+(?:\.\d+)?[KMB]?\s+↓\d+(?:\.\d+)?[KMB]?)`; +const BOTMUX_USAGE_FOOTER_PATTERN = new RegExp( + String.raw`^(?:${BOTMUX_CONTEXT_USAGE_PATTERN}(?:\s*·\s*${BOTMUX_TOKEN_USAGE_PATTERN})?|${BOTMUX_TOKEN_USAGE_PATTERN})\s*·\s*(?:发送给:|Sent to:\s*).+$`, +); + +function hasBotmuxFooterMarker(line: string): boolean { + return line.includes(BOTMUX_FOOTER_MARKER) + || BOTMUX_HIDDEN_FOOTER_MARKER.test(line); } /** @@ -738,11 +754,29 @@ export function extractCardContent(rawContent: string, numberer?: ImgNumberer): // Drop the botmux footer chrome so a receiving bot's prompt isn't polluted // by the grey `botmux` badge / `发送给:@owner` line. Line-level (not // part-level) so a footer never takes adjacent real content with it. - const cleaned = parts - .join('\n') - .split('\n') - .filter(line => !isBotmuxFooterLine(line)) - .join('\n'); + // Stable markers may occur inside a multiline element, so remove only + // their exact line first. Keep the remaining element as one paragraph: + // splitting it into global lines would make a body paragraph ending in a + // usage-shaped line indistinguishable from a standalone footer paragraph. + const visibleParts = parts + .map(part => part + .split('\n') + .filter(line => !hasBotmuxFooterMarker(line)) + .join('\n')) + .filter(part => !!part.trim()); + const lastPartIndex = visibleParts.length - 1; + // The marker-less fallback is permitted only for a standalone final + // paragraph with recipient chrome after a substantive body paragraph (a + // title alone does not count). In ambiguous cases, preserve user data. + const hasBodyBeforeUsageCandidate = visibleParts + .slice(0, Math.max(0, lastPartIndex)) + .some(part => !part.trim().startsWith('[卡片:')); + if (lastPartIndex >= 0 + && hasBodyBeforeUsageCandidate + && BOTMUX_USAGE_FOOTER_PATTERN.test(visibleParts[lastPartIndex].trim())) { + visibleParts.pop(); + } + const cleaned = visibleParts.join('\n'); return cleaned || '[卡片]'; } catch { return '[卡片]'; diff --git a/src/services/bot-config-store.ts b/src/services/bot-config-store.ts index e59d4be84..16ecc588d 100644 --- a/src/services/bot-config-store.ts +++ b/src/services/bot-config-store.ts @@ -45,6 +45,9 @@ export interface ConfigFieldSpec { clearable: boolean; /** kind==='enum' 时的合法取值(已小写)。 */ enumValues?: readonly string[]; + /** kind==='boolean' 的缺省值。省略时为 false;默认开启的开关设为 true, + * 持久化层只保存与缺省值不同的显式覆盖。 */ + booleanDefault?: boolean; /** kind==='string' 的最大长度(trim 后计),超出 coerce 报 too_long。缺省不限。 */ maxLen?: number; /** kind==='stringList' 的自定义解析器(自由文本 → 归一化数组)。缺省用 @@ -69,6 +72,7 @@ export const CONFIG_FIELDS: readonly ConfigFieldSpec[] = [ { key: 'skillInjection', configKey: 'skillInjection', kind: 'enum', effect: 'next-session', clearable: true, enumValues: ['global', 'prompt', 'off'], hint: 'botmux skills 注入方式(仅影响 codex/gemini 等全局 skills 目录的 CLI):prompt=注入会话不落全局盘(默认)|global=装进 CLI 全局目录(会被独立 CLI 看到)|off=只留提示+botmux --help;切到/离开 global 需重启 daemon 才完全生效;unset 回机器级默认' }, { key: 'defaultWorkingDir', configKey: 'defaultWorkingDir', kind: 'dir', effect: 'next-session', clearable: true, hint: '新话题默认工作目录(跳过仓库选择卡片)' }, { key: 'brandLabel', configKey: 'brandLabel', kind: 'string', effect: 'immediate', clearable: true, hint: '卡片页脚品牌文案;unset 回默认 botmux 链接' }, + { key: 'showUsageInCardFooter', configKey: 'showUsageInCardFooter', kind: 'boolean', effect: 'immediate', clearable: false, booleanDefault: true, hint: '回复卡片页脚展示 Context / Token 用量 on|off;缺失项仍自动省略' }, { key: 'autoStartPrompt', configKey: 'autoStartOnGroupJoinPrompt', kind: 'string', effect: 'immediate', clearable: true, hint: '被拉进新群主动开工的首轮 prompt(配合 autoStartOnGroupJoin)' }, { key: 'allowedUsers', configKey: 'allowedUsers', kind: 'allowedUsers', effect: 'immediate', clearable: false, hint: '管理员名单(邮箱/on_/ou_,逗号或空格分隔);改后需加 确认' }, { key: 'skills', configKey: 'skills', kind: 'json', effect: 'next-session', clearable: true, hint: 'bot 级 skill policy JSON;unset 回底层 CLI 默认行为' }, @@ -111,9 +115,20 @@ export function parseBooleanValue(raw: string): boolean | undefined { return undefined; } +/** 解析布尔配置的有效值。默认关闭字段缺省为 false;`booleanDefault: true` + * 的字段缺省为 true。所有 UI / 持久化入口共用,避免把“未落盘”误判为关闭。 */ +export function resolveConfigBooleanValue( + spec: Pick, + value: unknown, +): boolean { + return value === undefined ? spec.booleanDefault === true : value === true; +} + /** 展示某字段当前值的人类可读文本。 */ function formatFieldValue(spec: ConfigFieldSpec, value: unknown): string { - if (spec.kind === 'boolean') return value === true ? 'on' : 'off'; + if (spec.kind === 'boolean') { + return resolveConfigBooleanValue(spec, value) ? 'on' : 'off'; + } if (spec.kind === 'allowedUsers' || spec.kind === 'stringList') { const arr = Array.isArray(value) ? value : []; return arr.length ? arr.join(', ') : '∅'; @@ -214,9 +229,9 @@ export async function applyConfigField( if (effective === null) { delete entry[spec.configKey]; } else if (spec.kind === 'boolean') { - // 与 parseBotConfigsFromText 一致:true 才写,false → 删 key(bots.json 保持干净)。 - if (effective === true) entry[spec.configKey] = true; - else delete entry[spec.configKey]; + // 只保存与缺省值不同的覆盖:default-off 写 true,default-on 写 false。 + if (effective === (spec.booleanDefault === true)) delete entry[spec.configKey]; + else entry[spec.configKey] = effective; } else if (spec.kind === 'json') { entry[spec.configKey] = effective as any; } else { @@ -230,7 +245,8 @@ export async function applyConfigField( if (effective === null) { (bot.config as any)[spec.configKey] = undefined; } else if (spec.kind === 'boolean') { - (bot.config as any)[spec.configKey] = effective || undefined; + (bot.config as any)[spec.configKey] = + effective === (spec.booleanDefault === true) ? undefined : effective; } else if (spec.kind === 'json') { (bot.config as any)[spec.configKey] = effective; } else { @@ -436,7 +452,8 @@ export function getConfigCardData(larkAppId: string, modelChoices: readonly stri quota: typeof q === 'number' && Number.isInteger(q) && q > 0 ? q : null, admins: bot.resolvedAllowedUsers.length, booleans: CONFIG_FIELDS.filter(f => f.kind === 'boolean').map(f => ({ - key: f.key, on: (cfg as any)[f.configKey] === true, + key: f.key, + on: resolveConfigBooleanValue(f, (cfg as any)[f.configKey]), })), }; } diff --git a/src/utils/child-env.ts b/src/utils/child-env.ts index 016622565..2ebc9f1ab 100644 --- a/src/utils/child-env.ts +++ b/src/utils/child-env.ts @@ -175,6 +175,10 @@ export const BOTMUX_INJECTED_ENV_KEYS = [ // Resolved display footer for sandboxed `botmux send`; avoids reading the // credential-bearing bots.json from inside the child. 'BOTMUX_BRAND_LABEL', + // Default-on per-bot display preference for Context / Token usage in reply + // card footers. Injected explicitly so sandboxed offline fallback cannot + // drift to the default when bots.json is unreadable. + 'BOTMUX_SHOW_USAGE_IN_CARD_FOOTER', // Pi deferred long-first-prompt extension reads one exact per-session file. 'BOTMUX_PI_INITIAL_PROMPT_FILE', // Loopback port of the owning daemon's agent-facing IPC. Read-isolated CLIs diff --git a/src/worker.ts b/src/worker.ts index dea3c19fe..daeeadedd 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -90,7 +90,12 @@ import { type SessionMcpRuntimeManifest, } from './core/plugins/mcp/session-runtime.js'; import { prepareCliPluginGeneration } from './core/plugins/cli-generation.js'; -import { loadBotConfigs, resolveBrandLabel, type BotConfig } from './bot-registry.js'; +import { + loadBotConfigs, + resolveBrandLabel, + resolveShowUsageInCardFooter, + type BotConfig, +} from './bot-registry.js'; import { readGlobalConfig } from './global-config.js'; import { deriveTerminalViewToken, @@ -6612,6 +6617,8 @@ async function spawnCli( BOTMUX_SESSION_ID: cfg.sessionId, BOTMUX_CHAT_ID: cfg.chatId, BOTMUX_LARK_APP_ID: cfg.larkAppId, + BOTMUX_SHOW_USAGE_IN_CARD_FOOTER: + resolveShowUsageInCardFooter(cfg.larkAppId) ? 'true' : 'false', }; // Session scope for `botmux send` inside the sandbox. Thread sessions // anchor on a real om_ message (reply_in_thread); chat-scope sessions use @@ -7288,6 +7295,8 @@ async function spawnCli( const bl = resolveBrandLabel(cfg.larkAppId); if (typeof bl === 'string') childEnv.BOTMUX_BRAND_LABEL = bl; } + childEnv.BOTMUX_SHOW_USAGE_IN_CARD_FOOTER = + resolveShowUsageInCardFooter(cfg.larkAppId) ? 'true' : 'false'; // NOTE: under read isolation `botmux send` gets this bot's secret from the worker- // written cred FILE in its BOT_HOME (send-cred.json, see sendCredFilePath) located // via the BOTMUX_LARK_APP_ID above — NOT from the env. The secret is deliberately kept OUT diff --git a/test/bot-config-store.test.ts b/test/bot-config-store.test.ts index a2a59aebd..dd4889184 100644 --- a/test/bot-config-store.test.ts +++ b/test/bot-config-store.test.ts @@ -390,6 +390,32 @@ describe('bot-config store', () => { expect(registry.getBot('app_default').config.disableStreamingCard).toBeUndefined(); }); + it('showUsageInCardFooter defaults on and persists only the false override', async () => { + const { registry, store } = await loaded(); + const spec = store.findConfigField('showUsageInCardFooter')!; + expect(spec.effect).toBe('immediate'); + expect(store.getConfigCardData('app_default')?.booleans.find( + b => b.key === 'showUsageInCardFooter', + )?.on).toBe(true); + + const disabled = await store.applyConfigField('app_default', spec, false); + expect(disabled).toMatchObject({ + ok: true, + oldText: 'on', + newText: 'off', + effect: 'immediate', + }); + expect(readConfig().showUsageInCardFooter).toBe(false); + expect(registry.getBot('app_default').config.showUsageInCardFooter).toBe(false); + + await store.applyConfigField('app_default', spec, true); + expect(readConfig().showUsageInCardFooter).toBeUndefined(); + expect(registry.getBot('app_default').config.showUsageInCardFooter).toBeUndefined(); + expect(store.getConfigCardData('app_default')?.booleans.find( + b => b.key === 'showUsageInCardFooter', + )?.on).toBe(true); + }); + it('codexAppCleanInput is immediate, default-off, and deletes its key when disabled', async () => { const { registry, store } = await loaded({ cliId: 'codex-app' }); const spec = store.findConfigField('codexAppCleanInput')!; diff --git a/test/bot-registry-grant.test.ts b/test/bot-registry-grant.test.ts index 149455745..a8e436f0e 100644 --- a/test/bot-registry-grant.test.ts +++ b/test/bot-registry-grant.test.ts @@ -64,6 +64,19 @@ describe('bot-registry grant additions', () => { expect(cfgs[3].brandLabel).toBeUndefined(); // non-string ignored }); + it('parses showUsageInCardFooter as a default-on strict boolean', () => { + const cfgs = parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'usage-default', larkAppSecret: 's' }, + { larkAppId: 'usage-on', larkAppSecret: 's', showUsageInCardFooter: true }, + { larkAppId: 'usage-off', larkAppSecret: 's', showUsageInCardFooter: false }, + { larkAppId: 'usage-invalid', larkAppSecret: 's', showUsageInCardFooter: 'false' }, + ])); + expect(cfgs[0].showUsageInCardFooter).toBeUndefined(); + expect(cfgs[1].showUsageInCardFooter).toBeUndefined(); + expect(cfgs[2].showUsageInCardFooter).toBe(false); + expect(cfgs[3].showUsageInCardFooter).toBeUndefined(); + }); + it('getOwnerOpenId returns first ou_ in resolvedAllowedUsers', () => { registerBot({ larkAppId: 'a2', larkAppSecret: 's', cliId: 'claude-code', allowedUsers: ['x@y.com', 'ou_owner', 'ou_2'] }); expect(getOwnerOpenId('a2')).toBe('ou_owner'); diff --git a/test/bot-registry.test.ts b/test/bot-registry.test.ts index 555c15da9..16fcf382e 100644 --- a/test/bot-registry.test.ts +++ b/test/bot-registry.test.ts @@ -775,11 +775,17 @@ describe('getBot / getBotClient', () => { describe('resolveBrandLabel — sandbox env-first (footer role name fix)', () => { let mod: Awaited>; - const saved = { app: process.env.BOTMUX_LARK_APP_ID, brand: process.env.BOTMUX_BRAND_LABEL }; + const saved = { + app: process.env.BOTMUX_LARK_APP_ID, + brand: process.env.BOTMUX_BRAND_LABEL, + showUsage: process.env.BOTMUX_SHOW_USAGE_IN_CARD_FOOTER, + }; beforeEach(async () => { mod = await freshImport(); }); afterEach(() => { if (saved.app === undefined) delete process.env.BOTMUX_LARK_APP_ID; else process.env.BOTMUX_LARK_APP_ID = saved.app; if (saved.brand === undefined) delete process.env.BOTMUX_BRAND_LABEL; else process.env.BOTMUX_BRAND_LABEL = saved.brand; + if (saved.showUsage === undefined) delete process.env.BOTMUX_SHOW_USAGE_IN_CARD_FOOTER; + else process.env.BOTMUX_SHOW_USAGE_IN_CARD_FOOTER = saved.showUsage; }); it('returns the injected env brandLabel for the own appId WITHOUT reading bots.json (the sandbox path)', () => { @@ -800,6 +806,35 @@ describe('resolveBrandLabel — sandbox env-first (footer role name fix)', () => process.env.BOTMUX_BRAND_LABEL = '[self]()'; expect(mod.resolveBrandLabel('app_other')).toBeUndefined(); }); + + it('resolves the default-on usage-footer switch from registry or sandbox env', () => { + expect(mod.resolveShowUsageInCardFooter('app_default')).toBe(true); + + mod.registerBot(makeCfg({ + larkAppId: 'app_registered_off', + showUsageInCardFooter: false, + })); + expect(mod.resolveShowUsageInCardFooter('app_registered_off')).toBe(false); + + process.env.BOTMUX_LARK_APP_ID = 'app_sbx'; + process.env.BOTMUX_SHOW_USAGE_IN_CARD_FOOTER = 'false'; + expect(mod.resolveShowUsageInCardFooter('app_sbx')).toBe(false); + expect(mod.resolveShowUsageInCardFooter('app_other')).toBe(true); + }); + + it('prefers freshly loaded registry config over a frozen pane env for the same app', () => { + process.env.BOTMUX_LARK_APP_ID = 'app_hot'; + process.env.BOTMUX_SHOW_USAGE_IN_CARD_FOOTER = 'true'; + mod.registerBot(makeCfg({ + larkAppId: 'app_hot', + showUsageInCardFooter: false, + })); + expect(mod.resolveShowUsageInCardFooter('app_hot')).toBe(false); + + process.env.BOTMUX_SHOW_USAGE_IN_CARD_FOOTER = 'false'; + mod.registerBot(makeCfg({ larkAppId: 'app_hot' })); + expect(mod.resolveShowUsageInCardFooter('app_hot')).toBe(true); + }); }); // ─── getAllBots ──────────────────────────────────────────────────────────── diff --git a/test/bridge-final-output-retry.test.ts b/test/bridge-final-output-retry.test.ts index 9e556abba..574850115 100644 --- a/test/bridge-final-output-retry.test.ts +++ b/test/bridge-final-output-retry.test.ts @@ -55,6 +55,14 @@ vi.mock('../src/config.js', () => ({ }, })); +vi.mock('../src/core/cost-calculator.js', () => ({ + getSessionTokenUsage: vi.fn(() => null), + getSessionUsageSnapshot: vi.fn(() => ({ + context: { usedTokens: 12_345, windowTokens: 100_000, percentUsed: 12 }, + tokens: { in: 67_890, out: 123 }, + })), +})); + vi.mock('../src/services/session-store.js', () => ({ closeSession: vi.fn(), updateSession: vi.fn(), @@ -92,6 +100,8 @@ import { } from '../src/services/vc-meeting-delivery-store.js'; import { listVcMeetingActions } from '../src/services/vc-meeting-action-store.js'; import { listVcMeetingListenerMessageIds } from '../src/services/vc-meeting-listener-message-store.js'; +import { getSessionUsageSnapshot } from '../src/core/cost-calculator.js'; +import { getBot } from '../src/bot-registry.js'; // Build a fake worker child process whose IPC `message` event we can fire // manually, then wire it through setupWorkerHandlers via forkAdoptWorker. @@ -195,6 +205,12 @@ describe('Bridge final_output delivery (P2 retry)', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); + vi.mocked(getBot).mockReturnValue({ + config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code' }, + resolvedAllowedUsers: [], + botOpenId: 'ou_bot', + botName: 'TestBot', + } as any); rmSync('/tmp/test-sessions', { recursive: true, force: true }); mkdirSync('/tmp/test-sessions', { recursive: true }); }); @@ -1272,6 +1288,50 @@ describe('Bridge final_output delivery (P2 retry)', () => { await vi.advanceTimersByTimeAsync(15000); expect(sessionReply).toHaveBeenCalledTimes(3); expect(ds.lastBridgeEmittedUuid).toBe(SCOPED_DEDUPE_KEY); + expect(getSessionUsageSnapshot).toHaveBeenCalledTimes(1); + expect(getSessionUsageSnapshot).toHaveBeenCalledWith({ + cliId: 'claude-code', + sessionId: 'sid-final-out', + cliSessionId: 'claude-session-xyz', + cwd: '/tmp', + fresh: true, + }); + const cards = sessionReply.mock.calls.map(call => call[1] as string); + expect(new Set(cards)).toHaveLength(1); + expect(cards[0]).toContain('上下文 12.3K/100K (12%)'); + expect(cards[0]).toContain('Token ↑67.9K ↓123'); + }); + + it('does not read or render usage when this bot disables the card-footer switch', async () => { + const sessionReply = vi.fn(async () => 'om_reply'); + initWorkerPool({ + sessionReply, + getSessionWorkingDir: () => '/tmp', + getActiveCount: () => 1, + closeSession: vi.fn(), + }); + vi.mocked(getBot).mockReturnValue({ + config: { + larkAppId: 'app_test', + larkAppSecret: 'secret', + cliId: 'claude-code', + showUsageInCardFooter: false, + }, + resolvedAllowedUsers: [], + botOpenId: 'ou_bot', + botName: 'TestBot', + } as any); + + const ds = makeDs(); + const { __testOnly_deliverFinalOutput } = await import('../src/core/worker-pool.js') as any; + __testOnly_deliverFinalOutput(ds, finalOutputMsg(), 'tag', 0); + await vi.advanceTimersByTimeAsync(10); + + expect(getSessionUsageSnapshot).not.toHaveBeenCalled(); + const card = sessionReply.mock.calls[0]?.[1] as string; + expect(card).toContain('[botmux]('); + expect(card).not.toContain('上下文'); + expect(card).not.toContain('Token'); }); it('gives up after 3 attempts and does NOT commit dedup', async () => { diff --git a/test/card-builder.test.ts b/test/card-builder.test.ts index 40bf6f544..a01ffa12b 100644 --- a/test/card-builder.test.ts +++ b/test/card-builder.test.ts @@ -161,7 +161,7 @@ describe('getCliDisplayName', () => { }); describe('buildConfigCard', () => { - it('renders silentTurnReactions as a card-behaviour toggle', () => { + it('renders card-behaviour toggles, including default-on usage visibility', () => { const card = parse(buildConfigCard({ larkAppId: 'app_cfg', botName: 'Config Bot', @@ -180,6 +180,7 @@ describe('buildConfigCard', () => { quota: null, admins: 1, booleans: [ + { key: 'showUsageInCardFooter', on: true }, { key: 'disableStreamingCard', on: false }, { key: 'silentTurnReactions', on: true }, { key: 'writableTerminalLinkInCard', on: false }, @@ -196,6 +197,14 @@ describe('buildConfigCard', () => { expect(toggle.value.action).toBe('config_toggle'); expect(toggle.type).toBe('primary'); expect(toggle.text.content).toContain('Disable status reactions'); + + const usageToggle = allActions(card).find( + (a: any) => a.value?.field === 'showUsageInCardFooter', + ); + expect(usageToggle).toBeTruthy(); + expect(usageToggle.value.action).toBe('config_toggle'); + expect(usageToggle.type).toBe('primary'); + expect(usageToggle.text.content).toContain('Usage in card footer'); }); }); diff --git a/test/card-handler-config.test.ts b/test/card-handler-config.test.ts new file mode 100644 index 000000000..1003ecd23 --- /dev/null +++ b/test/card-handler-config.test.ts @@ -0,0 +1,75 @@ +/** + * `/botconfig` 交互卡片:默认开启的布尔字段必须按“有效值”翻转,而不是按 + * bots.json 中是否显式存在 true 翻转。 + */ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@larksuiteoapi/node-sdk', () => { + class FakeClient { constructor(public opts: Record) {} } + return { Client: FakeClient }; +}); + +const deps = { + activeSessions: new Map(), + sessionReply: vi.fn(async () => 'om_reply'), + lastRepoScan: new Map(), +} as any; + +let root: string; +let configPath: string; + +async function fresh() { + vi.resetModules(); + const registry = await import('../src/bot-registry.js'); + const handler = await import('../src/im/lark/card-handler.js'); + registry.loadBotConfigs().forEach(cfg => registry.registerBot(cfg)); + return { registry, handler }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'botmux-card-config-')); + configPath = join(root, 'bots.json'); + writeFileSync(configPath, JSON.stringify([{ + larkAppId: 'app_config', + larkAppSecret: 'secret', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + }], null, 2)); + process.env.BOTS_CONFIG = configPath; +}); + +afterEach(() => { + delete process.env.BOTS_CONFIG; + vi.restoreAllMocks(); + rmSync(root, { recursive: true, force: true }); +}); + +describe('/botconfig default-on toggle', () => { + it('first click turns showUsageInCardFooter off and persists false', async () => { + const { registry, handler } = await fresh(); + const result = await handler.handleCardAction({ + operator: { open_id: 'ou_owner' }, + action: { + value: { + action: 'config_toggle', + field: 'showUsageInCardFooter', + loc: 'en', + }, + }, + }, deps, 'app_config'); + + expect(result?.toast).toMatchObject({ + type: 'success', + content: '✓ showUsageInCardFooter = off', + }); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].showUsageInCardFooter).toBe(false); + expect(registry.getBot('app_config').config.showUsageInCardFooter).toBe(false); + + const rerendered = JSON.stringify(result?.card?.data ?? {}); + expect(rerendered).toContain('Usage in card footer'); + expect(rerendered).toContain('⚪'); + }); +}); diff --git a/test/cost-calculator.test.ts b/test/cost-calculator.test.ts index df914a290..7d068c5c8 100644 --- a/test/cost-calculator.test.ts +++ b/test/cost-calculator.test.ts @@ -81,6 +81,7 @@ import { findTraexRolloutBySessionId } from '../src/services/traex-transcript.js import { getSessionJsonlPath, getSessionCost, + getSessionUsageSnapshot, getSessionTokenUsage, formatNumber, __resetSessionUsageCachesForTest, @@ -410,6 +411,37 @@ describe('getSessionTokenUsage', () => { }); }); + it('reports Claude latest prompt-side context without inventing a window', () => { + setupJsonl([ + assistantLine({ input: 100, output: 50, cacheRead: 10, cacheCreate: 5 }), + assistantLine({ input: 200, output: 80, cacheRead: 20, cacheCreate: 7 }), + ].join('\n')); + + expect(getSessionUsageSnapshot({ + cliId: 'claude-code', + sessionId: 's1', + cwd: '/tmp', + fresh: true, + })).toMatchObject({ + context: { usedTokens: 227 }, + tokens: { in: 342, out: 130 }, + }); + }); + + it('keeps the last valid Claude context across an all-zero synthetic record', () => { + setupJsonl([ + assistantLine({ input: 200, output: 80, cacheRead: 20, cacheCreate: 7 }), + assistantLine({ input: 0, output: 0, cacheRead: 0, cacheCreate: 0 }), + ].join('\n')); + + expect(getSessionUsageSnapshot({ + cliId: 'claude-code', + sessionId: 's1', + cwd: '/tmp', + fresh: true, + }).context).toEqual({ usedTokens: 227 }); + }); + it('returns null when an Agent CLI has no native token usage available', () => { vi.mocked(existsSync).mockReturnValue(false); @@ -500,6 +532,101 @@ describe('getSessionTokenUsage', () => { expect(findCodexRolloutBySessionId).toHaveBeenCalledWith('codex-sid'); }); + it('keeps Codex latest context usage separate from cumulative Session tokens', () => { + vi.mocked(findCodexSessionIdByBotmuxSessionId).mockReturnValue('codex-sid'); + vi.mocked(findCodexRolloutBySessionId).mockReturnValue('/home/testuser/.codex/sessions/rollout-codex-sid.jsonl'); + setupJsonl([ + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { input_tokens: 3_579_709, cached_input_tokens: 3_404_544, output_tokens: 22_920 }, + last_token_usage: { + input_tokens: 159_508, + cached_input_tokens: 158_464, + output_tokens: 308, + reasoning_output_tokens: 148, + total_tokens: 159_816, + }, + model_context_window: 258_400, + }, + }, + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { input_tokens: 3_739_570, cached_input_tokens: 3_563_008, output_tokens: 23_299 }, + last_token_usage: { + input_tokens: 159_861, + cached_input_tokens: 158_464, + output_tokens: 379, + reasoning_output_tokens: 100, + total_tokens: 160_240, + }, + model_context_window: 258_400, + }, + }, + }), + ].join('\n')); + + expect(getSessionUsageSnapshot({ + cliId: 'codex', + sessionId: 'botmux-sid', + fresh: true, + })).toEqual({ + context: { usedTokens: 160_240, windowTokens: 258_400, percentUsed: 62 }, + tokens: { + in: 3_739_570, + out: 23_299, + inputTokens: 176_562, + outputTokens: 23_299, + cacheReadTokens: 3_563_008, + cacheCreateTokens: 0, + turns: 0, + model: '', + }, + }); + }); + + it('keeps the last valid Codex context when a later cumulative snapshot omits last usage', () => { + vi.mocked(findCodexSessionIdByBotmuxSessionId).mockReturnValue('codex-sid'); + vi.mocked(findCodexRolloutBySessionId).mockReturnValue('/home/testuser/.codex/sessions/rollout-codex-sid.jsonl'); + setupJsonl([ + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { input_tokens: 100, output_tokens: 10 }, + last_token_usage: { input_tokens: 80, output_tokens: 10, total_tokens: 90 }, + model_context_window: 1_000, + }, + }, + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { input_tokens: 120, output_tokens: 12 }, + }, + }, + }), + ].join('\n')); + + expect(getSessionUsageSnapshot({ + cliId: 'codex', + sessionId: 'botmux-sid', + fresh: true, + })).toMatchObject({ + context: { usedTokens: 90, windowTokens: 1_000, percentUsed: 9 }, + tokens: { in: 120, out: 12 }, + }); + }); + it('reports TraeX rollouts via the codex fold, capturing the turn_context model', () => { vi.mocked(findTraexRolloutBySessionId).mockReturnValue('/home/testuser/.trae/cli/sessions/2026/06/30/rollout-traex-sid.jsonl'); // Real TRAE rollout shapes: codex-format turn_context carries the model; diff --git a/test/dashboard-ipc.test.ts b/test/dashboard-ipc.test.ts index 160a86e66..1f78caf61 100644 --- a/test/dashboard-ipc.test.ts +++ b/test/dashboard-ipc.test.ts @@ -638,6 +638,74 @@ describe('GET /api/sessions/:sessionId', () => { }); }); +describe('GET /api/sessions/:sessionId/usage', () => { + it('returns the daemon-cached native usage snapshot for an active Session', async () => { + const ds = { session: { sessionId: 's-usage' } } as any; + const findSpy = vi.spyOn(workerPool, 'findActiveBySessionId').mockReturnValue(ds); + const usageSpy = vi.spyOn(workerPool, 'getDaemonReplyCardUsageSnapshot').mockReturnValue({ + context: { usedTokens: 12_345, windowTokens: 100_000, percentUsed: 12 }, + tokens: { in: 67_890, out: 123 }, + }); + try { + handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + const res = await fetch(`http://127.0.0.1:${handle.port}/api/sessions/s-usage/usage`); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + usage: { + context: { usedTokens: 12_345, windowTokens: 100_000, percentUsed: 12 }, + tokens: { in: 67_890, out: 123 }, + }, + }); + expect(usageSpy).toHaveBeenCalledWith(ds); + } finally { + findSpy.mockRestore(); + usageSpy.mockRestore(); + } + }); + + it('returns the card-specific empty snapshot when footer usage is disabled', async () => { + const ds = { session: { sessionId: 's-usage-hidden' } } as any; + const findSpy = vi.spyOn(workerPool, 'findActiveBySessionId').mockReturnValue(ds); + const rawSpy = vi.spyOn(workerPool, 'getDaemonSessionUsageSnapshot').mockReturnValue({ + context: { usedTokens: 12_345 }, + tokens: { in: 67_890, out: 123 }, + }); + const cardSpy = vi.spyOn(workerPool, 'getDaemonReplyCardUsageSnapshot').mockReturnValue({ + context: null, + tokens: null, + }); + try { + handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + const res = await fetch( + `http://127.0.0.1:${handle.port}/api/sessions/s-usage-hidden/usage`, + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + usage: { context: null, tokens: null }, + }); + expect(cardSpy).toHaveBeenCalledWith(ds); + expect(rawSpy).not.toHaveBeenCalled(); + } finally { + findSpy.mockRestore(); + rawSpy.mockRestore(); + cardSpy.mockRestore(); + } + }); + + it('returns 404 when the Session is not active', async () => { + const findSpy = vi.spyOn(workerPool, 'findActiveBySessionId').mockReturnValue(undefined); + try { + handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + const res = await fetch(`http://127.0.0.1:${handle.port}/api/sessions/missing/usage`); + expect(res.status).toBe(404); + } finally { + findSpy.mockRestore(); + } + }); +}); + describe('POST /api/sessions/:sessionId/rename', () => { it('updates the canonical title and requests native sync from a live Codex worker', async () => { const dataDir = mkdtempSync(join(tmpdir(), 'dashboard-ipc-session-rename-')); diff --git a/test/md-card.test.ts b/test/md-card.test.ts index 7a91c10d8..041a824e3 100644 --- a/test/md-card.test.ts +++ b/test/md-card.test.ts @@ -15,6 +15,7 @@ import { buildImageCardElements, buildMarkdownCard, buildContextualReplyCard, + buildReplyCardFooter, brandFooterSegment, DEFAULT_BRAND_LABEL, hasMarkdown, @@ -659,6 +660,86 @@ describe('normalizeLocalHomeLinks', () => { }); describe('buildMarkdownCard', () => { + it('renders native context and cumulative token usage in the footer', () => { + const json = buildMarkdownCard('hello', 'ou_abc', undefined, 'zh', undefined, 'filesystem', { + context: { usedTokens: 159_861, windowTokens: 258_400, percentUsed: 62 }, + tokens: { in: 3_739_570, out: 23_299 }, + }); + const card = JSON.parse(json); + const footer = card.body.elements.at(-1).content; + + expect(footer).toContain('上下文 159.9K/258.4K (62%)'); + expect(footer).toContain('Token ↑3.7M ↓23.3K'); + expect(footer).toContain(''); + }); + + it('omits malformed native usage instead of rendering NaN or Infinity', () => { + const json = buildMarkdownCard('hello', undefined, '', 'zh', undefined, 'filesystem', { + context: { usedTokens: Number.NaN, windowTokens: 258_400 }, + tokens: { in: Number.POSITIVE_INFINITY, out: 23_299 }, + }); + const card = JSON.parse(json); + const rendered = JSON.stringify(card); + + expect(card.body.elements.map((element: any) => element.tag)).not.toContain('hr'); + expect(rendered).not.toContain('上下文'); + expect(rendered).not.toContain('Token'); + expect(rendered).not.toContain('NaN'); + expect(rendered).not.toContain('Infinity'); + }); + + it('does not invent a percentage when the native snapshot has no percentage', () => { + const json = buildMarkdownCard('hello', undefined, '', 'zh', undefined, 'filesystem', { + context: { usedTokens: 12_345, windowTokens: 100_000 }, + tokens: null, + }); + const footer = JSON.parse(json).body.elements.at(-1).content; + + expect(footer).toContain('上下文 12.3K/100K'); + expect(footer).not.toContain('(12%)'); + expect(footer).not.toContain('Token'); + }); + + it('omits missing context while retaining cumulative token usage', () => { + const json = buildMarkdownCard('hello', undefined, '', 'zh', undefined, 'filesystem', { + context: null, + tokens: { in: 67_890, out: 123 }, + }); + const footer = JSON.parse(json).body.elements.at(-1).content; + + expect(footer).toContain('Token ↑67.9K ↓123'); + expect(footer).not.toContain('上下文'); + expect(footer).not.toContain('不可用'); + }); + + it('omits the usage footer when the Agent CLI reports neither metric', () => { + const json = buildMarkdownCard('hello', undefined, '', 'zh', undefined, 'filesystem', { + context: null, + tokens: null, + }); + const card = JSON.parse(json); + const rendered = JSON.stringify(card); + + expect(card.body.elements.map((element: any) => element.tag)).not.toContain('hr'); + expect(rendered).not.toContain('上下文'); + expect(rendered).not.toContain('Token'); + expect(rendered).not.toContain('不可用'); + }); + + it('keeps brand and recipient chrome when usage is entirely missing', () => { + const json = buildMarkdownCard('hello', 'ou_abc', undefined, 'zh', undefined, 'filesystem', { + context: null, + tokens: null, + }); + const footer = JSON.parse(json).body.elements.at(-1).content; + + expect(footer).toContain('[botmux]('); + expect(footer).toContain(''); + expect(footer).not.toContain('上下文'); + expect(footer).not.toContain('Token'); + expect(footer).not.toContain('不可用'); + }); + it('appends footer hr + grey link element', () => { const json = buildMarkdownCard('hello'); const card = JSON.parse(json); @@ -683,6 +764,31 @@ describe('buildMarkdownCard', () => { }); }); +describe('buildReplyCardFooter', () => { + it('centralizes brand, usage, and ordered recipients for every reply-card path', () => { + const footer = buildReplyCardFooter({ + brand: 'Acme', + usage: { + context: { usedTokens: 12_345 }, + tokens: { in: 67_890, out: 123 }, + }, + recipientOpenIds: ['ou_owner', 'ou_reviewer'], + locale: 'zh', + }); + + expect(footer?.content).toContain( + "Acme · 上下文 12.3K · Token ↑67.9K ↓123 · " + + '发送给: ', + ); + expect(footer?.content).toContain('https://github.com/deepcoldy/bot%6Dux#reply-card-footer'); + expect(footer?.element).toMatchObject({ + tag: 'markdown', + text_size: 'notation_small_v2', + content: footer?.content, + }); + }); +}); + describe('hasMarkdown', () => { it('detects fences', () => expect(hasMarkdown('a\n```\nx\n```')).toBe(true)); it('detects headings', () => expect(hasMarkdown('# title')).toBe(true)); @@ -877,6 +983,24 @@ describe('buildImageCardElements', () => { }); describe('buildContextualReplyCard footer brand', () => { + it('renders the same native usage footer as a regular reply card', () => { + const els = JSON.parse(buildContextualReplyCard({ + title: 'T', + assistantText: 'a', + assistantLabel: 'Codex', + recipientOpenId: 'ou_x', + usage: { + context: { usedTokens: 159_861, windowTokens: 258_400, percentUsed: 62 }, + tokens: { in: 3_739_570, out: 23_299 }, + }, + })).body.elements; + const footer = els.at(-1).content; + + expect(footer).toContain('上下文 159.9K/258.4K (62%)'); + expect(footer).toContain('Token ↑3.7M ↓23.3K'); + expect(footer).toContain(''); + }); + it('custom brand renders; default botmux omitted', () => { const els = JSON.parse(buildContextualReplyCard({ title: 'T', assistantText: 'a', assistantLabel: 'Claude', recipientOpenId: 'ou_x', brand: 'Acme', diff --git a/test/message-parser.test.ts b/test/message-parser.test.ts index ba036b520..1e9060c45 100644 --- a/test/message-parser.test.ts +++ b/test/message-parser.test.ts @@ -303,6 +303,147 @@ describe('Interactive card parsing: botmux footer is stripped from prompt', () = expect(result.content).not.toContain('botmux'); }); + it('drops a Format A usage-only footer when the bot brand is disabled', () => { + const card = { + elements: [ + [{ tag: 'text', text: '正文内容' }], + [ + { tag: 'text', text: '上下文 159.9K/258.4K (62%)' }, + { tag: 'text', text: ' · Token ↑3.7M ↓23.3K · 发送给:' }, + { tag: 'at', user_name: 'Owner' }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文内容'); + expect(result.content).not.toContain('上下文 159.9K'); + expect(result.content).not.toContain('Token ↑3.7M'); + expect(result.content).not.toContain('发送给'); + }); + + it('drops a Format A token-only footer when context is missing', () => { + const card = { + elements: [ + [{ tag: 'text', text: '正文内容' }], + [ + { tag: 'text', text: 'Token ↑3.7M ↓23.3K · 发送给:' }, + { tag: 'at', user_name: 'Owner' }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文内容'); + expect(result.content).not.toContain('Token ↑3.7M'); + expect(result.content).not.toContain('发送给'); + }); + + it('drops a Format A context-only footer when cumulative tokens are missing', () => { + const card = { + elements: [ + [{ tag: 'text', text: '正文内容' }], + [ + { tag: 'text', text: '上下文 159.9K/258.4K (62%) · 发送给:' }, + { tag: 'at', user_name: 'Owner' }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文内容'); + expect(result.content).not.toContain('上下文 159.9K'); + expect(result.content).not.toContain('发送给'); + }); + + it('drops a Format A custom-brand footer through its stable hidden marker', () => { + const card = { + elements: [ + [{ tag: 'text', text: '正文内容' }], + [ + { tag: 'text', text: 'Acme · 发送给:' }, + { tag: 'at', user_name: 'Owner' }, + { + tag: 'a', + text: '\u200B', + href: 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer', + }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文内容'); + expect(result.content).not.toContain('Acme'); + expect(result.content).not.toContain('发送给'); + }); + + it('keeps ordinary prose that mentions context and tokens', () => { + const card = { + elements: [[ + { tag: 'text', text: '上下文和 Token 是两个不同指标,请分别分析。' }, + ]], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('上下文和 Token 是两个不同指标'); + }); + + it('keeps a usage-shaped body paragraph when it is not the final paragraph', () => { + const card = { + elements: [ + [{ tag: 'text', text: '上下文 12.3K/100K (12%) · Token ↑67.9K ↓123' }], + [{ tag: 'text', text: '这是正文中的观测值,不是页脚。' }], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('上下文 12.3K/100K (12%)'); + expect(result.content).toContain('这是正文中的观测值'); + }); + + it('keeps a pure usage-shaped final body paragraph when no recipient chrome proves it is a footer', () => { + const card = { + elements: [ + [{ tag: 'text', text: '本轮统计如下:' }], + [{ tag: 'text', text: '上下文 12.3K/100K (12%) · Token ↑67.9K ↓123' }], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('本轮统计如下'); + expect(result.content).toContain('上下文 12.3K/100K (12%)'); + }); + + it('keeps a single usage-shaped body paragraph instead of reducing the card to a placeholder', () => { + const card = { + elements: [[ + { tag: 'text', text: '上下文 12.3K/100K (12%) · Token ↑67.9K ↓123' }, + ]], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('上下文 12.3K/100K (12%)'); + expect(result.content).not.toBe('[卡片]'); + }); + + it('keeps a sentence that merely contains the compact usage shape', () => { + const card = { + elements: [[ + { tag: 'text', text: '请记录:上下文 12.3K/100K (12%) · Token ↑67.9K ↓123,稍后对比。' }, + ]], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('请记录:上下文'); + expect(result.content).toContain('稍后对比'); + }); + + it('keeps a usage-shaped final line when it belongs to the same body paragraph', () => { + const card = { + elements: [[ + { + tag: 'text', + text: '正文中的指标如下:\n上下文 12.3K/100K (12%) · Token ↑67.9K ↓123', + }, + ]], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文中的指标如下'); + expect(result.content).toContain('上下文 12.3K/100K (12%)'); + }); + it('round-trips a real buildMarkdownCard output without footer leakage', () => { const raw = buildMarkdownCard('帮我看下这个 bug', 'ou_owner'); const result = parseApiMessage(makeMsg('interactive', JSON.parse(raw))); diff --git a/test/tmux-backend-env.test.ts b/test/tmux-backend-env.test.ts index 38a806b45..82946bfc0 100644 --- a/test/tmux-backend-env.test.ts +++ b/test/tmux-backend-env.test.ts @@ -83,6 +83,16 @@ describe('buildBotmuxEnvAssignments()', () => { expect(buildBotmuxEnvAssignments({ BOTMUX: '1' }).some(s => s.startsWith('BOTMUX_OWNER_OPEN_ID='))).toBe(false); }); + it('forwards the reply-card usage visibility into persistent CLI panes', () => { + const out = buildBotmuxEnvAssignments({ + BOTMUX: '1', + BOTMUX_SHOW_USAGE_IN_CARD_FOOTER: 'false', + PATH: '/usr/bin', + }); + expect(out).toContain('BOTMUX_SHOW_USAGE_IN_CARD_FOOTER=false'); + expect(out).not.toContain('PATH=/usr/bin'); + }); + it('forwards CLAUDE_CODE_RESUME_TOKEN_THRESHOLD so the resume-summary bypass reaches the tmux pane (issue #62)', () => { // The worker injects this for claude-code to suppress Claude Code 2.1.x's // blocking resume-summary menu. Under the tmux backend it ONLY reaches the diff --git a/test/worker-riff-env.integration.test.ts b/test/worker-riff-env.integration.test.ts new file mode 100644 index 000000000..98b633d14 --- /dev/null +++ b/test/worker-riff-env.integration.test.ts @@ -0,0 +1,157 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer, type IncomingMessage, type Server } from 'node:http'; +import type { Socket } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { DaemonToWorker, WorkerToDaemon } from '../src/types.js'; + +async function listen(server: Server): Promise { + await new Promise((resolvePromise, rejectPromise) => { + server.once('error', rejectPromise); + server.listen(0, '127.0.0.1', () => { + server.off('error', rejectPromise); + resolvePromise(); + }); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Riff test server has no TCP address'); + return address.port; +} + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolvePromise, rejectPromise) => { + const chunks: Buffer[] = []; + req.on('data', chunk => chunks.push(Buffer.from(chunk))); + req.once('end', () => resolvePromise(Buffer.concat(chunks).toString('utf8'))); + req.once('error', rejectPromise); + }); +} + +describe('Riff worker session environment', () => { + it('forwards a disabled reply-card usage switch into the remote sandbox', async () => { + const root = mkdtempSync(join(tmpdir(), 'botmux-worker-riff-env-')); + const sockets = new Set(); + let child: ChildProcess | undefined; + let settleRequest!: (body: Record) => void; + let rejectRequest!: (error: Error) => void; + let requestSettled = false; + const taskExecuteRequest = new Promise>((resolvePromise, rejectPromise) => { + settleRequest = body => { + if (requestSettled) return; + requestSettled = true; + resolvePromise(body); + }; + rejectRequest = error => { + if (requestSettled) return; + requestSettled = true; + rejectPromise(error); + }; + }); + + const server = createServer(async (req, res) => { + if (req.url === '/api/task-execute' && req.method === 'POST') { + try { + settleRequest(JSON.parse(await readRequestBody(req))); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: { id: 'task-env-1', status: 'running' } })); + } catch (error) { + rejectRequest(error instanceof Error ? error : new Error(String(error))); + res.writeHead(400); + res.end(); + } + return; + } + if (req.url?.startsWith('/api2/task-stream')) { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.write(': keepalive\n\n'); + return; + } + if (req.url?.startsWith('/api/task-detail')) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: { task: {} } })); + return; + } + if (req.url === '/api/task-cancel') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: {} })); + return; + } + res.writeHead(404); + res.end(); + }); + server.on('connection', socket => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + + try { + const port = await listen(server); + const appId = 'app_riff_usage_hidden'; + const botsPath = join(root, 'bots.json'); + writeFileSync(botsPath, JSON.stringify([{ + larkAppId: appId, + larkAppSecret: 'secret', + cliId: 'riff', + backendType: 'riff', + riff: { baseUrl: `http://127.0.0.1:${port}` }, + showUsageInCardFooter: false, + }])); + + const logs: string[] = []; + child = spawn(process.execPath, ['--import', 'tsx', resolve('src/worker.ts')], { + cwd: resolve('.'), + env: { + ...process.env, + HOME: root, + SESSION_DATA_DIR: root, + BOTS_CONFIG: botsPath, + BOTMUX_SESSION_ID: 'sid-riff-env', + LARK_APP_ID: appId, + LARK_APP_SECRET: 'secret', + }, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + child.stdout?.on('data', chunk => logs.push(chunk.toString())); + child.stderr?.on('data', chunk => logs.push(chunk.toString())); + child.on('message', (raw) => { + const msg = raw as WorkerToDaemon; + if (msg.type === 'error') { + rejectRequest(new Error(`worker error: ${msg.message}\n${logs.join('')}`)); + } + }); + child.once('exit', (code, signal) => { + rejectRequest(new Error(`worker exited before task-execute (${code ?? signal})\n${logs.join('')}`)); + }); + + const init: DaemonToWorker = { + type: 'init', + sessionId: 'sid-riff-env', + chatId: 'oc_riff_env', + rootMessageId: 'om_riff_env', + workingDir: root, + cliId: 'riff', + backendType: 'riff', + backendConfig: { baseUrl: `http://127.0.0.1:${port}`, injectStatusLines: false }, + prompt: 'verify remote session environment', + larkAppId: appId, + larkAppSecret: 'secret', + }; + child.send(init); + + const request = await Promise.race([ + taskExecuteRequest, + new Promise((_, rejectPromise) => { + setTimeout(() => rejectPromise(new Error(`task-execute timeout\n${logs.join('')}`)), 15_000); + }), + ]); + expect(request.config?.env?.BOTMUX_SHOW_USAGE_IN_CARD_FOOTER).toBe('false'); + } finally { + if (child && child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + for (const socket of sockets) socket.destroy(); + await new Promise(resolvePromise => server.close(() => resolvePromise())); + rmSync(root, { recursive: true, force: true }); + } + }, 25_000); +}); From b8ff44f455c577d95f664a9f690e390384710f93 Mon Sep 17 00:00:00 2001 From: "zhangqi.ruster" Date: Tue, 28 Jul 2026 20:13:09 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(dashboard):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E9=A1=B5=E8=84=9A=E7=94=A8=E9=87=8F=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/assets/dashboard-card-usage-toggle.png | Bin 0 -> 58752 bytes src/core/dashboard-ipc-server.ts | 4 ++ src/dashboard/bot-payload.ts | 1 + src/dashboard/web/bot-defaults-page.tsx | 33 +++++++++-- src/dashboard/web/bot-defaults.ts | 1 + src/dashboard/web/i18n.ts | 4 ++ src/services/card-prefs-store.ts | 20 ++++++- test/dashboard-bot-defaults-cliid.test.ts | 61 +++++++++++++++++++- test/dashboard-bot-payload.test.ts | 7 +++ test/dashboard-ipc.test.ts | 52 +++++++++++++++++ 10 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 docs/assets/dashboard-card-usage-toggle.png diff --git a/docs/assets/dashboard-card-usage-toggle.png b/docs/assets/dashboard-card-usage-toggle.png new file mode 100644 index 0000000000000000000000000000000000000000..c3ca08fec63cf57b507de9e7da9f77f40acc6780 GIT binary patch literal 58752 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 Ef4y-yqyPW_ literal 0 HcmV?d00001 diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index caad26364..b6eef4995 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -2436,6 +2436,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { // disables the toggle wherever the worker would fail-close on it. readIsolationSupported: readIsolationEnforceable(cachedLarkAppId), backendType: backendTypeStore.getBotBackendType(cachedLarkAppId) ?? null, + showUsageInCardFooter: cardPrefs.showUsageInCardFooter, disableStreamingCard: cardPrefs.disableStreamingCard, silentTurnReactions: cardPrefs.silentTurnReactions, codexAppCleanInput: cardPrefs.codexAppCleanInput, @@ -2479,6 +2480,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { ipcRoute('PUT', '/api/bot-card-prefs', async (req, res) => { if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' }); let body: { + showUsageInCardFooter?: unknown; disableStreamingCard?: unknown; silentTurnReactions?: unknown; codexAppCleanInput?: unknown; writableTerminalLinkInCard?: unknown; privateCard?: unknown; botToBotSameDir?: unknown; autoStartOnGroupJoin?: unknown; autoStartOnGroupJoinPrompt?: unknown; autoStartOnNewTopic?: unknown; @@ -2489,6 +2491,7 @@ ipcRoute('PUT', '/api/bot-card-prefs', async (req, res) => { catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } const patch: { + showUsageInCardFooter?: boolean; disableStreamingCard?: boolean; silentTurnReactions?: boolean; codexAppCleanInput?: boolean; writableTerminalLinkInCard?: boolean; privateCard?: boolean; botToBotSameDir?: boolean; autoStartOnGroupJoin?: boolean; autoStartOnGroupJoinPrompt?: string; autoStartOnNewTopic?: boolean; @@ -2496,6 +2499,7 @@ ipcRoute('PUT', '/api/bot-card-prefs', async (req, res) => { docSubscribeDefaultMode?: 'mention-only' | 'all'; overloadAlert?: boolean; } = {}; + if (typeof body.showUsageInCardFooter === 'boolean') patch.showUsageInCardFooter = body.showUsageInCardFooter; if (typeof body.disableStreamingCard === 'boolean') patch.disableStreamingCard = body.disableStreamingCard; if (typeof body.botToBotSameDir === 'boolean') patch.botToBotSameDir = body.botToBotSameDir; if (typeof body.silentTurnReactions === 'boolean') patch.silentTurnReactions = body.silentTurnReactions; diff --git a/src/dashboard/bot-payload.ts b/src/dashboard/bot-payload.ts index 4afe45a55..bcbbbcf6f 100644 --- a/src/dashboard/bot-payload.ts +++ b/src/dashboard/bot-payload.ts @@ -55,6 +55,7 @@ export function botDefaultsPayload(bot: DashboardBotDescriptor, j?: any, error?: readIsolation: j?.readIsolation === true, readIsolationSupported: j?.readIsolationSupported === true, backendType: typeof j?.backendType === 'string' ? j.backendType : null, + showUsageInCardFooter: j?.showUsageInCardFooter !== false, disableStreamingCard: j?.disableStreamingCard === true, silentTurnReactions: j?.silentTurnReactions === true, codexAppCleanInput: j?.codexAppCleanInput === true, diff --git a/src/dashboard/web/bot-defaults-page.tsx b/src/dashboard/web/bot-defaults-page.tsx index 767a9ddd1..6f0fd0eee 100644 --- a/src/dashboard/web/bot-defaults-page.tsx +++ b/src/dashboard/web/bot-defaults-page.tsx @@ -255,6 +255,7 @@ function sessionCapStateLabel(cap: number | null, tr: ReturnType): function patchCardPrefsFromBody(bot: BotDefaultsRow, body: any): BotDefaultsRow { return { ...bot, + showUsageInCardFooter: body.showUsageInCardFooter, disableStreamingCard: body.disableStreamingCard, silentTurnReactions: body.silentTurnReactions, codexAppCleanInput: body.codexAppCleanInput, @@ -1904,9 +1905,10 @@ function ProfileRoles(props: { appId: string }) { ); } -function CardBehaviorSection(props: { bot: BotDefaultsRow; putCardPref(patch: CardPrefPatch): Promise }) { +export function CardBehaviorSection(props: { bot: BotDefaultsRow; putCardPref(patch: CardPrefPatch): Promise }) { const tr = useT(); const { bot, putCardPref } = props; + const [showUsage, setShowUsage] = useState(bot.showUsageInCardFooter !== false); const [disableStreaming, setDisableStreaming] = useState(bot.disableStreamingCard === true); const [silentReactions, setSilentReactions] = useState(bot.silentTurnReactions === true); const [writableLink, setWritableLink] = useState(bot.writableTerminalLinkInCard === true); @@ -1915,19 +1917,26 @@ function CardBehaviorSection(props: { bot: BotDefaultsRow; putCardPref(patch: Ca const [busy, setBusy] = useState(null); useEffect(() => { + setShowUsage(bot.showUsageInCardFooter !== false); setDisableStreaming(bot.disableStreamingCard === true); setSilentReactions(bot.silentTurnReactions === true); setWritableLink(bot.writableTerminalLinkInCard === true); setPrivateCard(bot.privateCard === true); - }, [bot.disableStreamingCard, bot.privateCard, bot.silentTurnReactions, bot.writableTerminalLinkInCard]); + }, [bot.disableStreamingCard, bot.privateCard, bot.showUsageInCardFooter, bot.silentTurnReactions, bot.writableTerminalLinkInCard]); - async function savePatch(patch: CardPrefPatch, key: string): Promise { + async function savePatch(patch: CardPrefPatch, key: string, rollback?: () => void): Promise { setBusy(key); setStatus(null); try { const res = await putCardPref(patch); - setStatus(res.ok ? { text: `✓ ${tr('botDefaults.cardPrefSaved')}`, ok: true } : { text: `✗ ${responseErrorText(res)}` }); + if (res.ok) { + setStatus({ text: `✓ ${tr('botDefaults.cardPrefSaved')}`, ok: true }); + } else { + rollback?.(); + setStatus({ text: `✗ ${responseErrorText(res)}` }); + } } catch (e: any) { + rollback?.(); setStatus({ text: `✗ ${caughtErrorText(e)}` }); } finally { setBusy(null); @@ -1938,6 +1947,22 @@ function CardBehaviorSection(props: { bot: BotDefaultsRow; putCardPref(patch: Ca

{tr('botDefaults.sectionCard')}

+ { + const previous = showUsage; + setShowUsage(checked); + void savePatch( + { showUsageInCardFooter: checked }, + 'usage', + () => setShowUsage(previous), + ); + }} + /> (larkAppId, (entry) => { + applyDefaultTrue(entry, 'showUsageInCardFooter', patch.showUsageInCardFooter); apply(entry, 'disableStreamingCard', patch.disableStreamingCard); apply(entry, 'silentTurnReactions', patch.silentTurnReactions); apply(entry, 'codexAppCleanInput', patch.codexAppCleanInput); @@ -161,6 +169,7 @@ export async function updateBotCardPrefs( return { write: true, result: { + showUsageInCardFooter: entry.showUsageInCardFooter !== false, disableStreamingCard: entry.disableStreamingCard === true, silentTurnReactions: entry.silentTurnReactions === true, codexAppCleanInput: entry.codexAppCleanInput === true, @@ -184,6 +193,10 @@ export async function updateBotCardPrefs( if (!r.ok) return { ok: false, reason: r.reason }; // Sync in-memory config so live card builders / routing react without a restart. + if (patch.showUsageInCardFooter !== undefined) { + // Default true: store false explicitly, clear (→ default on) when true. + bot.config.showUsageInCardFooter = patch.showUsageInCardFooter === false ? false : undefined; + } if (patch.disableStreamingCard !== undefined) { bot.config.disableStreamingCard = patch.disableStreamingCard || undefined; } @@ -229,7 +242,8 @@ export async function updateBotCardPrefs( bot.config.docSubscribeDefaultMode = patch.docSubscribeDefaultMode === 'all' ? 'all' : undefined; } logger.info( - `[card-prefs:${larkAppId}] disableStreamingCard=${r.result.disableStreamingCard} ` + + `[card-prefs:${larkAppId}] showUsageInCardFooter=${r.result.showUsageInCardFooter} ` + + `disableStreamingCard=${r.result.disableStreamingCard} ` + `silentTurnReactions=${r.result.silentTurnReactions} ` + `codexAppCleanInput=${r.result.codexAppCleanInput} ` + `writableTerminalLinkInCard=${r.result.writableTerminalLinkInCard} privateCard=${r.result.privateCard} ` + diff --git a/test/dashboard-bot-defaults-cliid.test.ts b/test/dashboard-bot-defaults-cliid.test.ts index 8b1766f42..aef220e91 100644 --- a/test/dashboard-bot-defaults-cliid.test.ts +++ b/test/dashboard-bot-defaults-cliid.test.ts @@ -2,7 +2,7 @@ import React from 'react'; import TestRenderer, { act } from 'react-test-renderer'; import { describe, expect, it, vi } from 'vitest'; import { displayCliId } from '../src/dashboard/web/bot-defaults.js'; -import { BotAgentSection, CodexAppDisplaySection } from '../src/dashboard/web/bot-defaults-page.js'; +import { BotAgentSection, CardBehaviorSection, CodexAppDisplaySection } from '../src/dashboard/web/bot-defaults-page.js'; import { isOnboardingSubmitDisabled } from '../src/dashboard/web/bot-onboarding.js'; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -203,3 +203,62 @@ describe('Codex App history switch', () => { .toContain('write_failed'); }); }); + +describe('reply-card usage footer switch', () => { + it('is default-on and persists explicit off/on changes', async () => { + const putCardPref = vi.fn(async (patch: Record) => ({ + ok: true, + status: 200, + body: { ok: true, ...patch }, + })); + let renderer!: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create(React.createElement(CardBehaviorSection, { + bot: { larkAppId: 'cli_usage' }, + putCardPref, + })); + }); + + const toggle = () => renderer.root.findByProps({ 'data-action': 'toggle-show-usage-in-card-footer' }); + expect(toggle().props.checked).toBe(true); + + await act(async () => { + toggle().props.onChange({ currentTarget: { checked: false } }); + await Promise.resolve(); + }); + expect(putCardPref).toHaveBeenLastCalledWith({ showUsageInCardFooter: false }); + expect(toggle().props.checked).toBe(false); + + await act(async () => { + toggle().props.onChange({ currentTarget: { checked: true } }); + await Promise.resolve(); + }); + expect(putCardPref).toHaveBeenLastCalledWith({ showUsageInCardFooter: true }); + expect(toggle().props.checked).toBe(true); + }); + + it('rolls back when persistence fails', async () => { + const putCardPref = vi.fn(async () => ({ + ok: false, + status: 500, + body: { error: 'write_failed' }, + })); + let renderer!: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create(React.createElement(CardBehaviorSection, { + bot: { larkAppId: 'cli_usage' }, + putCardPref, + })); + }); + + const toggle = () => renderer.root.findByProps({ 'data-action': 'toggle-show-usage-in-card-footer' }); + await act(async () => { + toggle().props.onChange({ currentTarget: { checked: false } }); + await Promise.resolve(); + }); + + expect(toggle().props.checked).toBe(true); + expect(renderer.root.findByProps({ 'data-card-pref-status': '' }).children.join('')) + .toContain('write_failed'); + }); +}); diff --git a/test/dashboard-bot-payload.test.ts b/test/dashboard-bot-payload.test.ts index 13b33c3ef..2cc63b90f 100644 --- a/test/dashboard-bot-payload.test.ts +++ b/test/dashboard-bot-payload.test.ts @@ -86,6 +86,13 @@ describe('dashboard bot payload helpers', () => { .toMatchObject({ codexAppCleanInput: true }); }); + it('projects reply-card usage footer visibility as an explicit default-on boolean', () => { + const daemon = { larkAppId: 'app_usage', botName: 'Usage', cliId: 'codex' }; + expect(botDefaultsPayload(daemon, {})).toMatchObject({ showUsageInCardFooter: true }); + expect(botDefaultsPayload(daemon, { showUsageInCardFooter: false })) + .toMatchObject({ showUsageInCardFooter: false }); + }); + it('projects sandboxPaths three tiers, defaulting to null when absent or malformed', () => { const daemon = { larkAppId: 'app_sbx', botName: 'Sbx', cliId: 'claude-code' }; // Absent → null (pure deny-by-default baseline, no rules to render). diff --git a/test/dashboard-ipc.test.ts b/test/dashboard-ipc.test.ts index 1f78caf61..8ac266854 100644 --- a/test/dashboard-ipc.test.ts +++ b/test/dashboard-ipc.test.ts @@ -330,6 +330,58 @@ describe('PUT /api/bot-card-prefs — Codex App clean history', () => { }); }); +describe('PUT /api/bot-card-prefs — reply-card usage footer', () => { + it('is default-on and persists explicit off/on changes immediately', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dashboard-ipc-usage-footer-')); + const configPath = join(dir, 'bots.json'); + const appId = 'test-usage-footer-app'; + const prevBotsConfig = process.env.BOTS_CONFIG; + try { + process.env.BOTS_CONFIG = configPath; + writeFileSync(configPath, JSON.stringify([{ + larkAppId: appId, + larkAppSecret: 'secret', + cliId: 'codex', + }], null, 2)); + loadBotConfigs().forEach((c: any) => registerBot(c)); + setLarkAppId(appId); + handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + const base = `http://127.0.0.1:${handle.port}`; + + const initial = await (await fetch(`${base}/api/bot-default-oncall`)).json(); + expect(initial.showUsageInCardFooter).toBe(true); + + const off = await fetch(`${base}/api/bot-card-prefs`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ showUsageInCardFooter: false }), + }); + expect(off.status).toBe(200); + expect(await off.json()).toMatchObject({ ok: true, showUsageInCardFooter: false }); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].showUsageInCardFooter).toBe(false); + expect(await (await fetch(`${base}/api/bot-default-oncall`)).json()) + .toMatchObject({ showUsageInCardFooter: false }); + + const on = await fetch(`${base}/api/bot-card-prefs`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ showUsageInCardFooter: true }), + }); + expect(on.status).toBe(200); + expect(await on.json()).toMatchObject({ ok: true, showUsageInCardFooter: true }); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].showUsageInCardFooter).toBeUndefined(); + expect(await (await fetch(`${base}/api/bot-default-oncall`)).json()) + .toMatchObject({ showUsageInCardFooter: true }); + } finally { + if (handle) await handle.close(); + handle = null; + if (prevBotsConfig === undefined) delete process.env.BOTS_CONFIG; + else process.env.BOTS_CONFIG = prevBotsConfig; + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe('POST /api/grants/chat', () => { it('requires loopback HMAC before invoking the permission service', async () => { const handler = vi.fn(); From ba9f9b5456b6233819e0ebf90a9ef101bead82c4 Mon Sep 17 00:00:00 2001 From: "zhangqi.ruster" Date: Wed, 29 Jul 2026 15:23:02 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(card):=20=E4=BF=AE=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E9=87=8F=E8=AF=BB=E5=8F=96=E4=B8=8E=E9=A1=B5=E8=84=9A=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli.ts | 33 ++- src/core/cost-calculator.ts | 8 +- src/core/worker-pool.ts | 1 + src/im/lark/md-card.ts | 90 +++++- src/im/lark/message-parser.ts | 214 ++++++++++---- src/im/lark/reply-card-footer-signature.ts | 8 + src/services/codex-transcript.ts | 130 +++++++-- src/services/jsonl-cursor.ts | 18 +- src/services/transcript-resolver.ts | 187 ++++++++++-- test/bridge-final-output-retry.test.ts | 112 ++++++- test/cost-calculator.test.ts | 65 ++++- test/md-card.test.ts | 123 +++++++- test/message-parser.test.ts | 323 ++++++++++++++++++++- test/transcript-resolver-bot-home.test.ts | 270 ++++++++++++++++- 14 files changed, 1417 insertions(+), 165 deletions(-) create mode 100644 src/im/lark/reply-card-footer-signature.ts diff --git a/src/cli.ts b/src/cli.ts index a8e41be58..fa026b5ff 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4332,6 +4332,7 @@ async function readCardUsageSnapshotForSend( sessionId: session.sessionId, cliSessionId: session.cliSessionId ?? session.adoptedFrom?.sessionId, cwd: session.workingDir ?? session.adoptedFrom?.cwd, + larkAppId, fresh: true, }); } catch { @@ -5935,26 +5936,21 @@ function argValues(args: string[], ...flags: string[]): string[] { function withCustomCardMentionFooter( card: Record, mentionOpenIds: readonly string[], - sentToLabel: string, + locale?: Locale, ): { ok: true; card: Record } | { ok: false; error: string } { if (mentionOpenIds.length === 0) return { ok: true, card }; - const cloned = JSON.parse(JSON.stringify(card)) as Record; - const body = cloned.body as { elements?: unknown } | undefined; - if (!body || !Array.isArray(body.elements)) { + const deduped = [...new Set(mentionOpenIds.filter(Boolean))]; + const cloned = appendReplyCardFooterToV2Card(card, { + brand: '', + recipientOpenIds: deduped, + locale, + }); + if (!cloned) { return { ok: false, - error: '自定义卡片带 --mention/--mention-back 时必须是 schema 2.0 且包含 body.elements;或改用 --no-mention 并在卡片 JSON 内自行处理展示', + error: '自定义卡片带 --mention/--mention-back 时必须是 schema 2.0、包含 body.elements,且未占用 botmux_reply_footer 元素 ID;或改用 --no-mention 并在卡片 JSON 内自行处理展示', }; } - const deduped = [...new Set(mentionOpenIds.filter(Boolean))]; - body.elements.push( - { tag: 'hr' }, - { - tag: 'markdown', - text_size: 'notation_small_v2', - content: `${sentToLabel}${deduped.map(id => ``).join(' ')}`, - }, - ); return { ok: true, card: cloned }; } @@ -5962,6 +5958,7 @@ function withCustomCardMentionFooter( // daemon's bridge fallback path can produce identical cards. cmdSend // keeps using `buildImageCardElements` from there. import { + appendReplyCardFooterToV2Card, buildImageCardElements, buildReplyCardFooter, prepareCardMarkdown, @@ -7281,7 +7278,7 @@ async function cmdSend(rest: string[]): Promise { const withFooter = withCustomCardMentionFooter( customCard, mentionFooter, - t('card.sent_to', undefined, localeForBot(appId)), + localeForBot(appId), ); if (!withFooter.ok) { console.error(`botmux send: ${withFooter.error}`); process.exit(2); } customCard = withFooter.card; @@ -7416,7 +7413,11 @@ async function cmdSend(rest: string[]): Promise { columns: [ { tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center', - elements: [{ tag: 'markdown', text_size: 'notation_small_v2', content: footerContent || ' ' }], + elements: [footer?.element ?? { + tag: 'markdown', + text_size: 'notation_small_v2', + content: ' ', + }], }, { tag: 'column', width: 'auto', vertical_align: 'center', diff --git a/src/core/cost-calculator.ts b/src/core/cost-calculator.ts index aa8d51ba0..a1134a446 100644 --- a/src/core/cost-calculator.ts +++ b/src/core/cost-calculator.ts @@ -271,11 +271,12 @@ function foldClaudeLine(agg: TokenUsageAggregate, seenMessageIds: Set, e * the latest snapshot counts. The active model rides on turn_context / * session_meta payloads (latest wins — sessions can switch models). */ function foldCodexLine(agg: TokenUsageAggregate, entry: any): void { + const contextUsage = extractCodexContextUsage(entry); + if (contextUsage) agg.latestContextUsage = contextUsage; + const codexUsage = extractCodexTokenCountUsage(entry); if (codexUsage) { agg.latestCodexUsage = codexUsage; - const contextUsage = extractCodexContextUsage(entry); - if (contextUsage) agg.latestContextUsage = contextUsage; return; } const m = entry?.payload?.model ?? entry?.payload?.collaboration_mode?.settings?.model; @@ -303,6 +304,9 @@ function foldCocoLine(agg: TokenUsageAggregate, entry: any): void { /** Cursor / TraeX / Antigravity: transcripts whose exact dialect is not yet * pinned down — keep the tolerant multi-shape extraction for them. */ function foldGenericLine(agg: TokenUsageAggregate, seenMessageIds: Set, entry: any): void { + const contextUsage = extractCodexContextUsage(entry); + if (contextUsage) agg.latestContextUsage = contextUsage; + const codexUsage = extractCodexTokenCountUsage(entry); if (codexUsage) { agg.latestCodexUsage = codexUsage; diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 0e85514a0..fa1d2f0ed 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -99,6 +99,7 @@ export function getDaemonSessionUsageSnapshot( ?? ds.session.workingDir ?? ds.session.adoptedFrom?.cwd ?? ds.adoptedFrom?.cwd, + larkAppId: ds.larkAppId, fresh: true, }); } catch (error) { diff --git a/src/im/lark/md-card.ts b/src/im/lark/md-card.ts index 23c97b0db..003262947 100644 --- a/src/im/lark/md-card.ts +++ b/src/im/lark/md-card.ts @@ -26,6 +26,12 @@ import { resolve } from 'node:path'; import MarkdownIt from 'markdown-it'; import type Token from 'markdown-it/lib/token.mjs'; import { t, type Locale } from '../../i18n/index.js'; +import { + REPLY_CARD_FOOTER_ELEMENT_ID, + REPLY_CARD_FOOTER_MARKER, +} from './reply-card-footer-signature.js'; + +export { REPLY_CARD_FOOTER_MARKER } from './reply-card-footer-signature.js'; const md = new MarkdownIt({ html: false, linkify: false, breaks: false }); const MAX_LOCAL_HOME_LINK_REPAIRS = 256; @@ -53,6 +59,7 @@ export interface ReplyCardFooter { /** Standalone footer element used by ordinary reply cards. */ element: { tag: 'markdown'; + element_id: typeof REPLY_CARD_FOOTER_ELEMENT_ID; text_size: 'notation_small_v2'; content: string; }; @@ -253,24 +260,21 @@ export function normalizeLocalHomeLinks( /** Default footer brand when a bot has no custom `brandLabel` configured. */ export const DEFAULT_BRAND_LABEL = '[botmux](https://github.com/deepcoldy/botmux)'; -/** A visually empty link that survives Lark's simplified card representation. - * It lets message-parser identify our footer even when brandLabel is disabled - * or custom, without exposing implementation text in the rendered card. */ -export const REPLY_CARD_FOOTER_MARKER = - '[\u200B](https://github.com/deepcoldy/bot%6Dux#reply-card-footer)'; - /** * Resolve the brand segment to render in a card footer from a bot's configured * `brandLabel` (see {@link resolveBrandLabel}): * • `undefined` (unset) → the default botmux link * • `''` / whitespace → `null` (brand suppressed) - * • any other string → returned verbatim (markdown allowed) + * • any other string → one trimmed line (markdown allowed) * Returning `null` lets callers drop the brand — and, when there's also no * recipient, the whole footer (HR included) — so an empty brand reads clean. */ export function brandFooterSegment(brand: string | undefined): string | null { if (brand === undefined) return DEFAULT_BRAND_LABEL; - return brand.trim() ? brand : null; + const normalized = brand + .trim() + .replace(/[ \t]*(?:\r\n?|\n|\u2028|\u2029)+[ \t]*/g, ' '); + return normalized || null; } function compactTokenCount(value: number): string { @@ -279,9 +283,16 @@ function compactTokenCount(value: number): string { { threshold: 1_000_000, suffix: 'M' }, { threshold: 1_000, suffix: 'K' }, ] as const; - const unit = units.find(candidate => value >= candidate.threshold); - if (!unit) return Math.round(value).toString(); - return `${(value / unit.threshold).toFixed(1).replace(/\.0$/, '')}${unit.suffix}`; + let unitIndex = units.findIndex(candidate => value >= candidate.threshold); + if (unitIndex < 0) return Math.round(value).toString(); + let unit = units[unitIndex]; + let scaled = value / unit.threshold; + // Avoid boundary artifacts such as 1000K/1000M after one-decimal rounding. + if (unitIndex > 0 && Number(scaled.toFixed(1)) >= 1_000) { + unit = units[--unitIndex]; + scaled = value / unit.threshold; + } + return `${scaled.toFixed(1).replace(/\.0$/, '')}${unit.suffix}`; } function isNonNegativeFinite(value: unknown): value is number { @@ -342,18 +353,71 @@ export function buildReplyCardFooter(opts: { } if (parts.length === 0) return null; - const content = - `${parts.join(' · ')}${REPLY_CARD_FOOTER_MARKER}`; + // The first ordinary separator doubles as a visible, versioned marker. Lark + // preserves non-empty links in its simplified Format A, so custom/disabled + // brands remain identifiable without changing the rendered multi-part text. + // A one-part footer also gets the marker. The canonical repository URL alone + // is legitimate body content, so it must never double as proof of ownership. + const signedContent = parts.length > 1 + ? `${parts[0]} ${REPLY_CARD_FOOTER_MARKER} ${parts.slice(1).join(' · ')}` + : `${parts[0]} ${REPLY_CARD_FOOTER_MARKER}`; + const content = `${signedContent}`; return { content, element: { tag: 'markdown', + element_id: REPLY_CARD_FOOTER_ELEMENT_ID, text_size: 'notation_small_v2', content, }, }; } +/** Clone a caller-supplied schema-2 card and append the canonical reply + * footer. Returns null for cards without a v2 `body.elements` array or when a + * caller-owned element already occupies the globally unique footer id. */ +export function appendReplyCardFooterToV2Card( + card: Record, + opts: Parameters[0], +): Record | null { + const cloned = JSON.parse(JSON.stringify(card)) as Record; + if (cloned.schema !== '2.0') return null; + const body = cloned.body as { elements?: unknown } | undefined; + if (!body || !Array.isArray(body.elements)) return null; + const header = cloned.header as { + text_tag_list?: unknown; + i18n_text_tag_list?: unknown; + } | undefined; + const i18nHeaderTagLists = header?.i18n_text_tag_list + && typeof header.i18n_text_tag_list === 'object' + ? Object.values(header.i18n_text_tag_list as Record) + : []; + if ( + containsReplyCardFooterId(body.elements) + || containsReplyCardFooterId(header?.text_tag_list) + || containsReplyCardFooterId(i18nHeaderTagLists) + ) { + return null; + } + const footer = buildReplyCardFooter(opts); + if (footer) body.elements.push({ tag: 'hr' }, footer.element); + return cloned; +} + +function containsReplyCardFooterId(value: unknown): boolean { + if (Array.isArray(value)) return value.some(containsReplyCardFooterId); + if (!value || typeof value !== 'object') return false; + const record = value as Record; + if (record.element_id === REPLY_CARD_FOOTER_ELEMENT_ID) return true; + // Follow only documented component-child slots. Callback `value`, behavior + // payloads, and other arbitrary business JSON are deliberately outside the + // card component tree even when they contain tag/element_id-shaped fields. + return containsReplyCardFooterId(record.elements) + || containsReplyCardFooterId(record.columns) + || containsReplyCardFooterId(record.actions) + || containsReplyCardFooterId(record.extra); +} + /** Build a Feishu native `table` element from a `table_open … table_close` token slice. */ function buildTableFromTokens(tokens: Token[]): any | null { const headerCells: string[] = []; diff --git a/src/im/lark/message-parser.ts b/src/im/lark/message-parser.ts index 96ccb01c3..40c991610 100644 --- a/src/im/lark/message-parser.ts +++ b/src/im/lark/message-parser.ts @@ -1,6 +1,9 @@ import type { LarkMessage, LarkMention } from '../../types.js'; import { getMessageDetail } from './client.js'; import { logger } from '../../utils/logger.js'; +import { + REPLY_CARD_FOOTER_ELEMENT_ID, +} from './reply-card-footer-signature.js'; // Event data structure from WSClient im.message.receive_v1 // sender is at data top-level, NOT inside data.message @@ -613,39 +616,107 @@ function extractTextContent(msgType: string, rawContent: string, mentions?: RawE } /** - * botmux-generated card footer signature. Every card `botmux send` / - * buildMarkdownCard emits ends with a small grey note linking back to the repo - * (`[botmux](https://github.com/deepcoldy/botmux)`, optionally `· 发送给:@owner`). - * That footer is human-facing chrome — when another bot receives the card it - * must NOT leak into the receiving bot's prompt (it surfaces as a stray - * `botmux` block and duplicates mention info). Both - * Lark render formats carry the canonical repo URL on that footer line (Format B - * as the markdown link target, Format A as the ``), so the URL is a - * reliable, format-agnostic marker. Anchored on the full repo URL so genuine - * body text merely mentioning "botmux" survives. Known, accepted trade-off: a - * card whose own body puts this exact repo URL on a line would lose that line - * too — vanishingly rare versus the value of a simple format-agnostic anchor. + * botmux-generated reply-card footer signature. New cards carry both a visible + * versioned link marker and an exact element id. The canonical repository URL + * is NOT a signature: it is valid body content. For pre-signature cards we + * recognize only the exact old default-brand + recipient chrome shape; a + * default-brand-only old card is intentionally preserved because it is + * indistinguishable from an ordinary repository link. */ -const BOTMUX_FOOTER_MARKER = 'github.com/deepcoldy/botmux'; -const BOTMUX_HIDDEN_FOOTER_MARKER = /github\.com\/deepcoldy\/bot%6dux#reply-card-footer/i; - -// Defense in depth for Lark clients that discard the footer's zero-width link -// while producing Format A. This intentionally matches only a complete -// usage footer line carrying Bot addressing chrome; the caller additionally -// restricts it to the final non-empty paragraph after real body content. -// A pure usage-shaped line is ambiguous without the stable marker, so it is -// deliberately preserved rather than risking silent body-data loss. -const BOTMUX_CONTEXT_USAGE_PATTERN = - String.raw`(?:上下文|Context)\s+(?:不可用|unavailable|\d+(?:\.\d+)?[KMB]?(?:\/\d+(?:\.\d+)?[KMB]?(?:\s+\(\d+%\))?)?)`; -const BOTMUX_TOKEN_USAGE_PATTERN = - String.raw`(?:Token|Tokens)\s+(?:不可用|unavailable|↑\d+(?:\.\d+)?[KMB]?\s+↓\d+(?:\.\d+)?[KMB]?)`; -const BOTMUX_USAGE_FOOTER_PATTERN = new RegExp( - String.raw`^(?:${BOTMUX_CONTEXT_USAGE_PATTERN}(?:\s*·\s*${BOTMUX_TOKEN_USAGE_PATTERN})?|${BOTMUX_TOKEN_USAGE_PATTERN})\s*·\s*(?:发送给:|Sent to:\s*).+$`, -); - -function hasBotmuxFooterMarker(line: string): boolean { - return line.includes(BOTMUX_FOOTER_MARKER) - || BOTMUX_HIDDEN_FOOTER_MARKER.test(line); +const LEGACY_DEFAULT_FOOTER_URL = 'https://github.com/deepcoldy/botmux'; +const FOOTER_MARKER_HASHES = new Set(['#reply-card-footer', '#reply-card-footer-v1']); + +function isBotmuxFooterMarkerUrl(value: unknown): boolean { + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return url.protocol === 'https:' + && url.hostname === 'github.com' + && url.port === '' + && url.username === '' + && url.password === '' + && url.search === '' + && decodeURIComponent(url.pathname) === '/deepcoldy/botmux' + && FOOTER_MARKER_HASHES.has(url.hash); + } catch { + return false; + } +} + +function isBotmuxFooterMarkerText(value: unknown): boolean { + return value === '·' || value === '\u200B'; +} + +function isBotmuxFooterMarkerAnchor(href: unknown, text: unknown): boolean { + return isBotmuxFooterMarkerText(text) && isBotmuxFooterMarkerUrl(href); +} + +function greyFontInner(line: string): string | null { + const font = /^\s*]*)>([\s\S]*?)<\/font>\s*$/i.exec(line); + if (!font || !/\bcolor\s*=\s*(?:"grey"|'grey'|grey)(?:\s|$)/i.test(font[1])) return null; + return font[2]; +} + +function hasExactMarkerMarkdown(value: string): boolean { + // Match only the protocol's reserved anchor texts. A generic Markdown-link + // regex can start at an unmatched `[` inside a custom brand and swallow the + // later marker before it gets a chance to match. + for (const match of value.matchAll(/\[(·|\u200B)\]\(([^)\s]+)\)/gu)) { + if (isBotmuxFooterMarkerAnchor(match[2], match[1])) return true; + } + return false; +} + +function isSignedFormatBFooterLine(line: string): boolean { + const inner = greyFontInner(line); + return inner !== null && hasExactMarkerMarkdown(inner); +} + +/** Strict compatibility recognizer for Format A cards emitted before the + * versioned marker existed. Recipient chrome is required; without it, the old + * default-brand-only footer is indistinguishable from a real repository link. */ +function isMeaningfulFormatANode(node: unknown): boolean { + if (!node || typeof node !== 'object') return false; + const value = node as any; + if (value.tag === 'text') return typeof value.text === 'string' && value.text.trim().length > 0; + return true; +} + +function isLegacyFormatAFooterParagraph(paragraph: unknown[]): boolean { + const nodes = paragraph.filter(isMeaningfulFormatANode) as any[]; + const first = nodes[0]; + if ( + first?.tag !== 'a' + || first.href !== LEGACY_DEFAULT_FOOTER_URL + || typeof first.text !== 'string' + || first.text.trim().toLowerCase() !== 'botmux' + ) { + return false; + } + + let label = ''; + let sawAt = false; + for (const node of nodes.slice(1)) { + if (node.tag === 'at') { + sawAt = true; + continue; + } + if (node.tag !== 'text' || typeof node.text !== 'string') return false; + if (sawAt && node.text.trim() !== '') return false; + label += node.text; + } + return sawAt && /^\s*·\s*(?:发送给:|Sent to:)\s*$/i.test(label); +} + +/** Strict compatibility recognizer for an old original-format footer line. + * Grey styling plus exact default brand, locale label, and one or more native + * mentions are all required; styling or the canonical URL alone proves + * nothing. */ +function isLegacyFormatBFooterLine(line: string): boolean { + const inner = greyFontInner(line); + if (inner === null) return false; + return /^\s*\[botmux\]\(https:\/\/github\.com\/deepcoldy\/botmux\)\s*·\s*(?:发送给:|Sent to:)\s*(?:]+)\s*><\/at>\s*)+$/i + .test(inner); } /** @@ -693,13 +764,32 @@ export function extractCardContent(rawContent: string, numberer?: ImgNumberer): if (isApiFormat) { // Format A: [[{tag:"text",text:"..."}, {tag:"img",...}, {tag:"button",...}], ...] - for (const paragraph of rootElements) { + let lastMeaningfulParagraphIndex = -1; + for (let i = rootElements.length - 1; i >= 0; i--) { + const candidate = rootElements[i]; + if (Array.isArray(candidate) && candidate.some(isMeaningfulFormatANode)) { + lastMeaningfulParagraphIndex = i; + break; + } + } + for (let paragraphIndex = 0; paragraphIndex < rootElements.length; paragraphIndex++) { + const paragraph = rootElements[paragraphIndex]; if (!Array.isArray(paragraph)) continue; + if ( + paragraphIndex === lastMeaningfulParagraphIndex + && isLegacyFormatAFooterParagraph(paragraph) + ) { + continue; + } const textNodes: string[] = []; const buttons: string[] = []; + let hasFooterProof = false; for (const node of paragraph) { if (node.tag === 'text') { if (node.text) textNodes.push(node.text); } else if (node.tag === 'a') { + if (isBotmuxFooterMarkerAnchor(node.href, node.text)) { + hasFooterProof = true; + } // Keep the href so links survive — Format A separates text/href, // and dropping href loses real content (规则配置/详情/Trace 链接). // Bare long URLs (e.g. Argos Kibana 处理建议) come back with @@ -741,7 +831,7 @@ export function extractCardContent(rawContent: string, numberer?: ImgNumberer): } } const line = textNodes.join('').trim(); - if (line) parts.push(line); + if (line && !hasFooterProof) parts.push(line); if (buttons.length) parts.push(buttons.join(' ')); } } else { @@ -754,27 +844,28 @@ export function extractCardContent(rawContent: string, numberer?: ImgNumberer): // Drop the botmux footer chrome so a receiving bot's prompt isn't polluted // by the grey `botmux` badge / `发送给:@owner` line. Line-level (not // part-level) so a footer never takes adjacent real content with it. - // Stable markers may occur inside a multiline element, so remove only - // their exact line first. Keep the remaining element as one paragraph: - // splitting it into global lines would make a body paragraph ending in a - // usage-shaped line indistinguishable from a standalone footer paragraph. - const visibleParts = parts + // Stable legacy markers may occur inside a multiline element, so remove + // only their exact line. Marker-less footer-shaped text is deliberately + // preserved: an arbitrary custom brand is indistinguishable from user data. + let visibleParts = parts .map(part => part .split('\n') - .filter(line => !hasBotmuxFooterMarker(line)) + .filter(line => !isSignedFormatBFooterLine(line)) .join('\n')) .filter(part => !!part.trim()); - const lastPartIndex = visibleParts.length - 1; - // The marker-less fallback is permitted only for a standalone final - // paragraph with recipient chrome after a substantive body paragraph (a - // title alone does not count). In ambiguous cases, preserve user data. - const hasBodyBeforeUsageCandidate = visibleParts - .slice(0, Math.max(0, lastPartIndex)) - .some(part => !part.trim().startsWith('[卡片:')); - if (lastPartIndex >= 0 - && hasBodyBeforeUsageCandidate - && BOTMUX_USAGE_FOOTER_PATTERN.test(visibleParts[lastPartIndex].trim())) { - visibleParts.pop(); + // Marker-less legacy compatibility is intentionally tail-only. Old + // botmux footers were always last; applying this heuristic in the middle + // of a foreign card would turn a footer-shaped body note into data loss. + if (visibleParts.length > 0) { + const lastIndex = visibleParts.length - 1; + const lines = visibleParts[lastIndex].split('\n'); + let lastLine = lines.length - 1; + while (lastLine >= 0 && !lines[lastLine].trim()) lastLine--; + if (lastLine >= 0 && isLegacyFormatBFooterLine(lines[lastLine])) { + lines.splice(lastLine, 1); + visibleParts[lastIndex] = lines.join('\n'); + visibleParts = visibleParts.filter(part => !!part.trim()); + } } const cleaned = visibleParts.join('\n'); return cleaned || '[卡片]'; @@ -995,16 +1086,17 @@ function extractElementText(el: any, parts: string[], imgLabel: (key: string) => const tag = el.tag; - // botmux card footer: the only element rendered as a small grey notation - // (text_size 'notation_small_v2' + a grey wrapper). Drop it - // structurally — brand-agnostic, so a peer bot's *custom* brandLabel footer - // is stripped from cross-bot / quote / history prompts without us needing to - // know its label (the receiving bot can't see the sender's config). The - // repo-URL line filter below still covers the default brand in the simplified - // Format A representation, which carries no text_size. - if ((tag === 'markdown' || tag === 'div' || tag === 'plain_text') && el.text_size === 'notation_small_v2') { - const c = el.text?.content ?? el.content ?? ''; - if (/color=['"]grey['"]/i.test(c)) return; + // The public element id alone is not ownership proof: third-party cards may + // collide with it. New botmux footers carry all four invariants together. + const elementText = el.text?.content ?? el.content; + if ( + el.element_id === REPLY_CARD_FOOTER_ELEMENT_ID + && tag === 'markdown' + && el.text_size === 'notation_small_v2' + && typeof elementText === 'string' + && isSignedFormatBFooterLine(elementText) + ) { + return; } // div / markdown / plain_text blocks diff --git a/src/im/lark/reply-card-footer-signature.ts b/src/im/lark/reply-card-footer-signature.ts new file mode 100644 index 000000000..24e11f5d9 --- /dev/null +++ b/src/im/lark/reply-card-footer-signature.ts @@ -0,0 +1,8 @@ +/** Versioned identity shared by reply-card producers and the card parser. + * The marker uses visible link text because Lark may discard zero-width links + * while simplifying a card into Format A. */ +export const REPLY_CARD_FOOTER_ELEMENT_ID = 'botmux_reply_footer'; +export const REPLY_CARD_FOOTER_MARKER_URL = + 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1'; +export const REPLY_CARD_FOOTER_MARKER = + `[·](${REPLY_CARD_FOOTER_MARKER_URL})`; diff --git a/src/services/codex-transcript.ts b/src/services/codex-transcript.ts index 8547740f4..2e9b289b4 100644 --- a/src/services/codex-transcript.ts +++ b/src/services/codex-transcript.ts @@ -24,13 +24,28 @@ * * Pure I/O. Attribution belongs in CodexBridgeQueue. */ -import { existsSync, statSync, openSync, readSync, closeSync, readdirSync, readlinkSync } from 'node:fs'; +import { + closeSync, + constants, + existsSync, + fstatSync, + lstatSync, + openSync, + opendirSync, + readdirSync, + readlinkSync, + readSync, + statSync, + type Dirent, +} from 'node:fs'; import { execSync } from 'node:child_process'; import { platform } from 'node:os'; import { join } from 'node:path'; import { codexHistoryPath, codexSessionsRoot } from './codex-paths.js'; const IS_LINUX = platform() === 'linux'; +const UNTRUSTED_SESSION_SCAN_MAX_DEPTH = 3; +const UNTRUSTED_SESSION_SCAN_MAX_ENTRIES = 50_000; /** Extract the cliSessionId encoded in a rollout filename. Codex's session * id is UUID-shaped (8-4-4-4-12 hex), which lets us anchor the regex on @@ -189,24 +204,72 @@ export interface CodexDrainResult { * `rollout--.jsonl`, so a suffix match is unambiguous. The * directory tree is small (year/month/day) — a one-shot recursive scan * is cheap enough that we don't bother caching. */ -export function findCodexRolloutBySessionId(cliSessionId: string): string | undefined { - const sessionsRoot = codexSessionsRoot(); - if (!cliSessionId || !existsSync(sessionsRoot)) return undefined; +export function findCodexRolloutBySessionId( + cliSessionId: string, + opts?: { codexHome?: string; noFollow?: boolean }, +): string | undefined { + const sessionsRoot = opts?.codexHome + ? join(opts.codexHome, 'sessions') + : codexSessionsRoot(); + if (!cliSessionId) return undefined; + try { + // BOT_HOME is untrusted and requires no-follow roots. A normal CODEX_HOME + // remains compatible with legitimate user-managed symlinks. + if (opts?.noFollow && opts.codexHome && !lstatSync(opts.codexHome).isDirectory()) return undefined; + const rootStat = opts?.noFollow ? lstatSync(sessionsRoot) : statSync(sessionsRoot); + if (!rootStat.isDirectory()) return undefined; + } catch { + return undefined; + } const suffix = `-${cliSessionId}.jsonl`; - const stack: string[] = [sessionsRoot]; + const stack: Array<{ dir: string; depth: number }> = [{ dir: sessionsRoot, depth: 0 }]; + let visitedEntries = 0; while (stack.length > 0) { - const dir = stack.pop()!; - let entries: string[]; - try { entries = readdirSync(dir); } catch { continue; } - for (const name of entries) { - const full = join(dir, name); - let st: ReturnType; - try { st = statSync(full); } catch { continue; } - if (st.isDirectory()) { - stack.push(full); - } else if (st.isFile() && name.endsWith(suffix)) { - return full; + const { dir, depth } = stack.pop()!; + try { + const dirStat = opts?.noFollow ? lstatSync(dir) : statSync(dir); + if (!dirStat.isDirectory()) continue; + } catch { + continue; + } + let directory: ReturnType; + try { directory = opendirSync(dir); } catch { continue; } + try { + let entry: Dirent | null; + while ((entry = directory.readSync()) !== null) { + visitedEntries++; + if (opts?.noFollow && visitedEntries > UNTRUSTED_SESSION_SCAN_MAX_ENTRIES) { + return undefined; + } + const full = join(dir, entry.name); + let isDirectory = entry.isDirectory(); + let isFile = entry.isFile(); + // Some filesystems report DT_UNKNOWN. Resolve those with the same trust + // policy, but never stat through a known symlink in untrusted BOT_HOME. + if (!isDirectory && !isFile && !entry.isSymbolicLink()) { + try { + const stat = opts?.noFollow ? lstatSync(full) : statSync(full); + isDirectory = stat.isDirectory(); + isFile = stat.isFile(); + } catch { + continue; + } + } + if (isDirectory) { + if (!opts?.noFollow || depth < UNTRUSTED_SESSION_SCAN_MAX_DEPTH) { + stack.push({ dir: full, depth: depth + 1 }); + } + } else if (isFile && entry.name.endsWith(suffix)) { + try { + const fileStat = opts?.noFollow ? lstatSync(full) : statSync(full); + if (fileStat.isFile()) return full; + } catch { + continue; + } + } } + } finally { + try { directory.closeSync(); } catch { /* already closed */ } } } return undefined; @@ -228,23 +291,34 @@ const HISTORY_TAIL_BYTES = 4 * 1024 * 1024; * between the two. Only the trailing `maxTailBytes` of the file is scanned. */ export function findCodexSessionIdByBotmuxSessionId( botmuxSessionId: string, - opts?: { maxTailBytes?: number }, + opts?: { maxTailBytes?: number; codexHome?: string; noFollow?: boolean }, ): string | undefined { if (!botmuxSessionId) return undefined; - const historyPath = codexHistoryPath(); - if (!existsSync(historyPath)) return undefined; + const historyPath = opts?.codexHome + ? join(opts.codexHome, 'history.jsonl') + : codexHistoryPath(); + let fd: number | undefined; try { - const size = statSync(historyPath).size; + // O_NOFOLLOW rejects a symlink swapped in after lstat; O_NONBLOCK keeps a + // malicious FIFO from blocking the daemon. fstat then accepts only regular + // files. (O_NOFOLLOW is available on the supported Linux/macOS targets.) + if (opts?.noFollow && opts.codexHome && !lstatSync(opts.codexHome).isDirectory()) return undefined; + const pathStat = opts?.noFollow ? lstatSync(historyPath) : statSync(historyPath); + if (!pathStat.isFile()) return undefined; + fd = openSync( + historyPath, + constants.O_RDONLY + | (opts?.noFollow ? (constants.O_NOFOLLOW ?? 0) : 0) + | (constants.O_NONBLOCK ?? 0), + ); + const opened = fstatSync(fd); + if (!opened.isFile()) return undefined; + const size = opened.size; const maxTailBytes = Math.max(1, opts?.maxTailBytes ?? HISTORY_TAIL_BYTES); const start = Math.max(0, size - maxTailBytes); const length = size - start; - const fd = openSync(historyPath, 'r'); const buf = Buffer.alloc(length); - try { - readSync(fd, buf, 0, length, start); - } finally { - closeSync(fd); - } + readSync(fd, buf, 0, length, start); let text = buf.toString('utf8'); if (start > 0) { // The window almost certainly opens mid-line — drop the partial line. @@ -267,6 +341,10 @@ export function findCodexSessionIdByBotmuxSessionId( } } catch { return undefined; + } finally { + if (fd !== undefined) { + try { closeSync(fd); } catch { /* already closed / invalid fd */ } + } } return undefined; } diff --git a/src/services/jsonl-cursor.ts b/src/services/jsonl-cursor.ts index c64a4da78..b25b9f393 100644 --- a/src/services/jsonl-cursor.ts +++ b/src/services/jsonl-cursor.ts @@ -1,4 +1,12 @@ -import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs'; +import { + closeSync, + constants, + existsSync, + fstatSync, + openSync, + readSync, + statSync, +} from 'node:fs'; import { StringDecoder } from 'node:string_decoder'; export interface JsonlCursor { @@ -32,7 +40,13 @@ export function scanJsonlFromOffset(path: string, fromOffset: number, opts: Json const buf = Buffer.alloc(chunkSize); try { - fd = openSync(path, 'r'); + // O_NONBLOCK prevents an untrusted transcript path swapped to a FIFO from + // hanging the daemon. Validate the opened fd (rather than only the path) + // so directories/devices and the stat→open replacement window fail closed. + fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0)); + if (!fstatSync(fd).isFile()) { + throw new Error(`JSONL source is not a regular file: ${path}`); + } while (true) { const remaining = endOffset === undefined ? chunkSize : endOffset - nextReadOffset; if (remaining <= 0) break; diff --git a/src/services/transcript-resolver.ts b/src/services/transcript-resolver.ts index 4b748bd41..6f433bca8 100644 --- a/src/services/transcript-resolver.ts +++ b/src/services/transcript-resolver.ts @@ -1,11 +1,12 @@ -import { existsSync, realpathSync, statSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; +import { existsSync, lstatSync, realpathSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { homedir } from 'node:os'; import type { CliId } from '../adapters/cli/types.js'; import { botHomePath } from '../adapters/cli/read-isolation.js'; import { createCliAdapterSync } from '../adapters/cli/registry.js'; import { expandHome } from '../core/working-dir.js'; import { findCodexRolloutBySessionId, findCodexSessionIdByBotmuxSessionId } from './codex-transcript.js'; +import { codexHome as configuredCodexHome } from './codex-paths.js'; import { cocoEventsPathForSession } from './coco-transcript.js'; import { findCursorTranscriptByChatId } from './cursor-transcript.js'; import { findTraexRolloutBySessionId } from './traex-transcript.js'; @@ -78,7 +79,12 @@ function realCwd(cwd: string): string { try { return realpathSync(expanded); } catch { return resolve(expanded); } } -export function getClaudeSessionJsonlPath(sessionId: string, cwd: string, dataDir: string): string | null { +export function getClaudeSessionJsonlPath( + sessionId: string, + cwd: string, + dataDir: string, + opts?: { noFollow?: boolean }, +): string | null { // Claude stores sessions at ~/.claude/projects//.jsonl // where project-key = the REALPATH of cwd with non [A-Za-z0-9-] chars → '-'. // Resolve symlinks (realCwd), NOT just resolve(): under a symlinked cwd a @@ -86,8 +92,16 @@ export function getClaudeSessionJsonlPath(sessionId: string, cwd: string, dataDi // found and the usage ledger silently writes no delta (claude-code.ts's // realpathCwd already does this for the idle bridge — this path was the laggard). const projectKey = realCwd(cwd).replace(/[^A-Za-z0-9-]/g, '-'); - const jsonlPath = join(dataDir, 'projects', projectKey, `${sessionId}.jsonl`); - return existsSync(jsonlPath) ? jsonlPath : null; + const projectsDir = join(dataDir, 'projects'); + const projectDir = join(projectsDir, projectKey); + const jsonlPath = join(projectDir, `${sessionId}.jsonl`); + if (!opts?.noFollow) return existsSync(jsonlPath) ? jsonlPath : null; + return isDirectoryNoFollow(dataDir) + && isDirectoryNoFollow(projectsDir) + && isDirectoryNoFollow(projectDir) + && regularFileMtime(jsonlPath) !== null + ? jsonlPath + : null; } /** Resolve a Claude-family fork's (seed / relay) data root EXACTLY as the worker @@ -101,22 +115,26 @@ function claudeForkDataDir(cliId: 'seed' | 'relay'): string { return dir; } -/** The redirected Claude data dir of a sandboxed bot: `/bots//claude`. - * Sandboxed bots with CLI-data redirect get CLAUDE_CONFIG_DIR pointed here by the - * worker, so their transcripts never appear under the global data dir and every - * daemon-side reader (dashboard token column, usage ledger, insight) must fall - * back to this dir on a global miss. botmuxHome is derived exactly like the - * worker derives BOT_HOME (`dirname(SESSION_DATA_DIR)`); no SESSION_DATA_DIR - * means no redirect ever happened, so no fallback. Deliberately existence-driven - * rather than re-deriving the redirect decision (sandbox × adapter capability × - * wrapper): probing global-then-BOT_HOME is correct in every combination and - * can't drift from worker.ts. */ -function botHomeClaudeDataDir(larkAppId: string | undefined): string | null { +/** Resolve one CLI data root redirected beneath a sandboxed bot's BOT_HOME. + * botmuxHome is derived exactly like worker.ts (`dirname(SESSION_DATA_DIR)`); + * no SESSION_DATA_DIR means no redirect ever happened, so there is no fallback. + * Deliberately probe by existence rather than re-deriving the redirect decision + * (sandbox × adapter capability × wrapper), which could drift from the worker. */ +function botHomeCliDataDir( + larkAppId: string | undefined, + cliDirName: 'claude' | 'codex', +): string | null { if (!larkAppId) return null; const sessionDataDir = process.env.SESSION_DATA_DIR; if (!sessionDataDir) return null; try { - return join(botHomePath(dirname(sessionDataDir), larkAppId), 'claude'); + const botHome = botHomePath(dirname(sessionDataDir), larkAppId); + const cliDataDir = join(botHome, cliDirName); + // BOT_HOME is an agent-writable mount. Reject a replaced app root or CLI + // root before any descendant lookup; checking only the final transcript + // component would still follow these intermediate symlinks. + if (!isDirectoryNoFollow(botHome) || !isDirectoryNoFollow(cliDataDir)) return null; + return cliDataDir; } catch { return null; // unsafe app id — never build a path from it } @@ -125,24 +143,118 @@ function botHomeClaudeDataDir(larkAppId: string | undefined): string | null { function claudeJsonlWithBotHomeFallback(sid: string, q: TranscriptPathQuery, primaryDataDir: string): string | null { if (!q.cwd) return null; const globalPath = getClaudeSessionJsonlPath(sid, q.cwd, primaryDataDir); - const botHomeDir = botHomeClaudeDataDir(q.larkAppId); - const botHomeJsonl = botHomeDir ? getClaudeSessionJsonlPath(sid, q.cwd, botHomeDir) : null; + const botHomeDir = botHomeCliDataDir(q.larkAppId, 'claude'); + const botHomeJsonl = botHomeDir + ? getClaudeSessionJsonlPath(sid, q.cwd, botHomeDir, { noFollow: true }) + : null; // Both exist when a persistent session straddles a sandbox flip (the CLI kept // its session id but moved data dirs — either direction). The stale copy stops // growing while the live one keeps its mtime fresh, so newest-wins tracks the // file the CLI is actually writing; a fixed preference would freeze usage at // the flip point forever. - if (globalPath && botHomeJsonl) return newerFile(globalPath, botHomeJsonl); - return globalPath ?? botHomeJsonl; + return newerFile( + globalPath, + botHomeJsonl, + regularFileMtime, + path => botHomeDir ? noFollowRegularFileMtime(botHomeDir, path) : null, + ); +} + +/** Return a no-follow regular file's mtime, rejecting symlinks, directories, + * FIFOs, and stale cache entries. BOT_HOME is writable by the sandboxed agent, + * so transcript readers must never follow an agent-created link out of it. */ +function regularFileMtime(path: string | null): number | null { + if (!path) return null; + try { + const stat = lstatSync(path); + return stat.isFile() ? stat.mtimeMs : null; + } catch { + return null; + } } -/** Ties (e.g. a byte-identical copy) keep `a` — the global/stock path. */ -function newerFile(a: string, b: string): string { +function isDirectoryNoFollow(path: string): boolean { try { - return statSync(b).mtimeMs > statSync(a).mtimeMs ? b : a; + return lstatSync(path).isDirectory(); } catch { - return a; + return false; + } +} + +/** Validate every component beneath an untrusted root with lstat. This closes + * the positive-cache hole where an agent replaces a previously valid parent + * directory with a symlink while keeping the same lexical rollout path. */ +function noFollowRegularFileMtime(root: string, candidate: string | null): number | null { + if (!candidate) return null; + const resolvedRoot = resolve(root); + const resolvedCandidate = resolve(candidate); + const suffix = relative(resolvedRoot, resolvedCandidate); + if (!suffix || suffix === '..' || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) return null; + if (!isDirectoryNoFollow(resolvedRoot)) return null; + + const components = suffix.split(sep).filter(Boolean); + let current = resolvedRoot; + for (let i = 0; i < components.length; i++) { + current = join(current, components[i]); + try { + const stat = lstatSync(current); + if (i === components.length - 1) return stat.isFile() ? stat.mtimeMs : null; + if (!stat.isDirectory()) return null; + } catch { + return null; + } + } + return null; +} + +/** Pick the newest surviving candidate. Ties keep `a` (the global/stock path); + * each side is statted independently so one deleted candidate cannot mask the + * other. */ +function newerFile( + a: string | null, + b: string | null, + aMtimeReader = regularFileMtime, + bMtimeReader = regularFileMtime, +): string | null { + const aMtime = aMtimeReader(a); + const bMtime = bMtimeReader(b); + if (aMtime === null) return bMtime === null ? null : b; + if (bMtime === null) return a; + return bMtime > aMtime ? b : a; +} + +function codexRolloutInHome( + q: TranscriptPathQuery, + codexHome: string, + noFollow: boolean, +): string | null { + const key = `codex:${noFollow ? 'untrusted' : 'global'}:${codexHome}:${q.sessionId}:${q.cliSessionId ?? ''}`; + const finderOpts = noFollow ? { codexHome, noFollow: true } : { codexHome }; + const candidateMtime = (path: string | null) => noFollow + ? noFollowRegularFileMtime(codexHome, path) + : regularFileMtime(path); + const lookup = () => { + // cliSessionId comes from the live/adopted CLI and is authoritative. Avoid + // both the bounded history scan and any chance that an unrelated history + // row changes the selected rollout. + const mappedSid = q.cliSessionId + ? undefined + : findCodexSessionIdByBotmuxSessionId(q.sessionId, finderOpts); + const codexSid = q.cliSessionId || mappedSid || q.sessionId; + return findCodexRolloutBySessionId(codexSid, finderOpts) ?? null; + }; + let path = cachedTranscriptPathLookup( + key, + null, + lookup, + { retryMiss: q.fresh }, + ); + // Positive entries were historically cached forever. Refresh immediately if + // the file vanished or was replaced by a symlink/non-regular node. + if (path && candidateMtime(path) === null) { + path = cachedTranscriptPathLookup(key, null, lookup, { refreshHit: true, retryMiss: true }); } + return candidateMtime(path) === null ? null : path; } export function resolveSessionTranscriptPath(q: TranscriptPathQuery): ResolvedTranscriptPath | null { @@ -162,10 +274,27 @@ export function resolveSessionTranscriptPath(q: TranscriptPathQuery): ResolvedTr return path ? { path, kind: 'claude' } : null; } case 'codex': { - const path = cachedTranscriptPathLookup(`codex:${q.sessionId}:${q.cliSessionId ?? ''}`, null, () => { - const codexSid = q.cliSessionId || findCodexSessionIdByBotmuxSessionId(q.sessionId) || q.sessionId; - return findCodexRolloutBySessionId(codexSid) ?? null; - }, { retryMiss: q.fresh }); + // Resolve on every call: CODEX_HOME is intentionally dynamic, and the + // absolute path is part of the cache key so changing it cannot reuse a + // rollout discovered under a previous root. + const globalCodexHome = resolve(configuredCodexHome()); + const globalPath = codexRolloutInHome(q, globalCodexHome, false); + const botHomeDir = botHomeCliDataDir(q.larkAppId, 'codex'); + const resolvedBotHomeDir = botHomeDir ? resolve(botHomeDir) : null; + const botHomeRollout = resolvedBotHomeDir + ? codexRolloutInHome(q, resolvedBotHomeDir, true) + : null; + // A persistent session can straddle a sandbox flip while retaining its + // CLI session id. Compare the two cached path candidates on every read + // so a stale positive hit never pins usage to the pre-flip transcript. + const path = newerFile( + globalPath, + botHomeRollout, + regularFileMtime, + candidate => resolvedBotHomeDir + ? noFollowRegularFileMtime(resolvedBotHomeDir, candidate) + : null, + ); return path ? { path, kind: 'codex' } : null; } case 'coco': { diff --git a/test/bridge-final-output-retry.test.ts b/test/bridge-final-output-retry.test.ts index 574850115..99edab35f 100644 --- a/test/bridge-final-output-retry.test.ts +++ b/test/bridge-final-output-retry.test.ts @@ -82,7 +82,11 @@ vi.mock('@larksuiteoapi/node-sdk', () => ({ LoggerLevel: { info: 2 }, })); -import { initWorkerPool, __testOnly_setupWorkerHandlers } from '../src/core/worker-pool.js'; +import { + getDaemonReplyCardUsageSnapshot, + initWorkerPool, + __testOnly_setupWorkerHandlers, +} from '../src/core/worker-pool.js'; import { MessageWithdrawnError } from '../src/im/lark/client.js'; import type { DaemonSession } from '../src/core/types.js'; import type { WorkerToDaemon } from '../src/types.js'; @@ -1294,6 +1298,7 @@ describe('Bridge final_output delivery (P2 retry)', () => { sessionId: 'sid-final-out', cliSessionId: 'claude-session-xyz', cwd: '/tmp', + larkAppId: 'app_test', fresh: true, }); const cards = sessionReply.mock.calls.map(call => call[1] as string); @@ -1302,6 +1307,111 @@ describe('Bridge final_output delivery (P2 retry)', () => { expect(cards[0]).toContain('Token ↑67.9K ↓123'); }); + it('reads a sandboxed Claude transcript through the daemon reply-card boundary', async () => { + const actualCostCalculator = + await vi.importActual( + '../src/core/cost-calculator.js', + ); + vi.mocked(getSessionUsageSnapshot) + .mockImplementationOnce(actualCostCalculator.getSessionUsageSnapshot); + + const sandboxRoot = mkdtempSync(join(tmpdir(), 'botmux-card-usage-sandbox-')); + const previousSessionDataDir = process.env.SESSION_DATA_DIR; + try { + process.env.SESSION_DATA_DIR = join(sandboxRoot, 'data'); + const transcriptDir = join( + sandboxRoot, + 'bots', + 'app_test', + 'claude', + 'projects', + '-tmp', + ); + mkdirSync(transcriptDir, { recursive: true }); + writeFileSync( + join(transcriptDir, 'claude-session-xyz.jsonl'), + JSON.stringify({ + type: 'assistant', + message: { + model: 'claude-sonnet-test', + usage: { + input_tokens: 100, + output_tokens: 20, + cache_read_input_tokens: 10, + cache_creation_input_tokens: 5, + }, + }, + }) + '\n', + ); + + expect(getDaemonReplyCardUsageSnapshot(makeDs())).toMatchObject({ + context: { usedTokens: 115 }, + tokens: { in: 115, out: 20 }, + }); + } finally { + if (previousSessionDataDir === undefined) delete process.env.SESSION_DATA_DIR; + else process.env.SESSION_DATA_DIR = previousSessionDataDir; + rmSync(sandboxRoot, { recursive: true, force: true }); + } + }); + + it('reads a sandboxed Codex rollout through the daemon reply-card boundary', async () => { + const actualCostCalculator = + await vi.importActual( + '../src/core/cost-calculator.js', + ); + vi.mocked(getSessionUsageSnapshot) + .mockImplementationOnce(actualCostCalculator.getSessionUsageSnapshot); + + const sandboxRoot = mkdtempSync(join(tmpdir(), 'botmux-card-usage-codex-')); + const previousSessionDataDir = process.env.SESSION_DATA_DIR; + const codexSid = '019dd80d-d922-7a11-8339-0208d8c5b4ef'; + try { + process.env.SESSION_DATA_DIR = join(sandboxRoot, 'data'); + const rolloutDir = join( + sandboxRoot, + 'bots', + 'app_test', + 'codex', + 'sessions', + '2026', + '07', + '29', + ); + mkdirSync(rolloutDir, { recursive: true }); + writeFileSync( + join(rolloutDir, `rollout-2026-07-29T12-00-00-${codexSid}.jsonl`), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 1_000, + cached_input_tokens: 400, + output_tokens: 50, + }, + last_token_usage: { total_tokens: 250 }, + model_context_window: 2_000, + }, + }, + }) + '\n', + ); + const ds = makeDs(); + ds.session.cliId = 'codex'; + ds.session.cliSessionId = codexSid; + + expect(getDaemonReplyCardUsageSnapshot(ds)).toMatchObject({ + context: { usedTokens: 250, windowTokens: 2_000, percentUsed: 13 }, + tokens: { in: 1_000, out: 50 }, + }); + } finally { + if (previousSessionDataDir === undefined) delete process.env.SESSION_DATA_DIR; + else process.env.SESSION_DATA_DIR = previousSessionDataDir; + rmSync(sandboxRoot, { recursive: true, force: true }); + } + }); + it('does not read or render usage when this bot disables the card-footer switch', async () => { const sessionReply = vi.fn(async () => 'om_reply'); initWorkerPool({ diff --git a/test/cost-calculator.test.ts b/test/cost-calculator.test.ts index 7d068c5c8..c5bab064e 100644 --- a/test/cost-calculator.test.ts +++ b/test/cost-calculator.test.ts @@ -20,6 +20,10 @@ vi.mock('node:fs', async (importOriginal) => { return { ...original, existsSync: vi.fn(() => false), + lstatSync: vi.fn(() => ({ + isFile: () => true, + mtimeMs: 0, + })), readFileSync: vi.fn(() => ''), }; }); @@ -528,8 +532,14 @@ describe('getSessionTokenUsage', () => { turns: 0, model: '', }); - expect(findCodexSessionIdByBotmuxSessionId).toHaveBeenCalledWith('botmux-sid'); - expect(findCodexRolloutBySessionId).toHaveBeenCalledWith('codex-sid'); + expect(findCodexSessionIdByBotmuxSessionId).toHaveBeenCalledWith( + 'botmux-sid', + { codexHome: expect.any(String) }, + ); + expect(findCodexRolloutBySessionId).toHaveBeenCalledWith( + 'codex-sid', + { codexHome: expect.any(String) }, + ); }); it('keeps Codex latest context usage separate from cumulative Session tokens', () => { @@ -591,6 +601,36 @@ describe('getSessionTokenUsage', () => { }); }); + it('reports Codex context when token_count omits cumulative totals', () => { + vi.mocked(findCodexSessionIdByBotmuxSessionId).mockReturnValue('codex-sid'); + vi.mocked(findCodexRolloutBySessionId).mockReturnValue('/home/testuser/.codex/sessions/rollout-codex-sid.jsonl'); + setupJsonl(JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + last_token_usage: { + input_tokens: 49_978, + cached_input_tokens: 49_536, + output_tokens: 274, + reasoning_output_tokens: 38, + total_tokens: 50_252, + }, + model_context_window: 258_400, + }, + }, + })); + + expect(getSessionUsageSnapshot({ + cliId: 'codex', + sessionId: 'botmux-sid', + fresh: true, + })).toEqual({ + context: { usedTokens: 50_252, windowTokens: 258_400, percentUsed: 19 }, + tokens: null, + }); + }); + it('keeps the last valid Codex context when a later cumulative snapshot omits last usage', () => { vi.mocked(findCodexSessionIdByBotmuxSessionId).mockReturnValue('codex-sid'); vi.mocked(findCodexRolloutBySessionId).mockReturnValue('/home/testuser/.codex/sessions/rollout-codex-sid.jsonl'); @@ -949,6 +989,27 @@ describe('getSessionTokenUsage', () => { expect(findCodexRolloutBySessionId).toHaveBeenCalledTimes(1); }); + it('does not scan Codex history when cliSessionId is already authoritative', () => { + vi.mocked(findCodexRolloutBySessionId).mockReturnValue( + '/home/testuser/.codex/sessions/rollout-codex-explicit.jsonl', + ); + setupJsonl(JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { total_token_usage: { input_tokens: 1, output_tokens: 1 } }, + }, + })); + + getSessionTokenUsage({ + cliId: 'codex', + sessionId: 'botmux-sid', + cliSessionId: 'codex-explicit', + }); + + expect(findCodexSessionIdByBotmuxSessionId).not.toHaveBeenCalled(); + }); + it('retries a missed codex path lookup only after the retry window', () => { vi.mocked(findCodexSessionIdByBotmuxSessionId).mockReturnValue(undefined); vi.mocked(findCodexRolloutBySessionId).mockReturnValue(undefined); diff --git a/test/md-card.test.ts b/test/md-card.test.ts index 041a824e3..8d9355aad 100644 --- a/test/md-card.test.ts +++ b/test/md-card.test.ts @@ -11,6 +11,7 @@ import { homedir, tmpdir } from 'node:os'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { + appendReplyCardFooterToV2Card, buildCardBodyElements, buildImageCardElements, buildMarkdownCard, @@ -712,6 +713,18 @@ describe('buildMarkdownCard', () => { expect(footer).not.toContain('不可用'); }); + it('promotes rounded compact values at unit boundaries', () => { + const json = buildMarkdownCard('hello', undefined, '', 'en', undefined, 'filesystem', { + context: { usedTokens: 999_950, windowTokens: 999_950_000 }, + tokens: { in: 999_950_000, out: 999_950 }, + }); + const footer = JSON.parse(json).body.elements.at(-1).content; + + expect(footer).toContain('Context 1M/1B'); + expect(footer).toContain('Tokens ↑1B ↓1M'); + expect(footer).not.toMatch(/1000[KM]/); + }); + it('omits the usage footer when the Agent CLI reports neither metric', () => { const json = buildMarkdownCard('hello', undefined, '', 'zh', undefined, 'filesystem', { context: null, @@ -777,16 +790,119 @@ describe('buildReplyCardFooter', () => { }); expect(footer?.content).toContain( - "Acme · 上下文 12.3K · Token ↑67.9K ↓123 · " + 'Acme [·](https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1) ' + + '上下文 12.3K · Token ↑67.9K ↓123 · ' + '发送给: ', ); - expect(footer?.content).toContain('https://github.com/deepcoldy/bot%6Dux#reply-card-footer'); + expect(footer?.content).not.toContain('\u200B'); expect(footer?.element).toMatchObject({ tag: 'markdown', + element_id: 'botmux_reply_footer', text_size: 'notation_small_v2', content: footer?.content, }); }); + + it('appends the canonical signed footer to caller-supplied v2 cards', () => { + const original = { + schema: '2.0', + body: { elements: [{ tag: 'markdown', content: 'body' }] }, + }; + const card = appendReplyCardFooterToV2Card(original, { + brand: '', + recipientOpenIds: ['ou_owner', 'ou_owner'], + locale: 'en', + }) as any; + + expect(card).not.toBe(original); + expect(original.body.elements).toHaveLength(1); + expect(card.body.elements.at(-1)).toMatchObject({ + tag: 'markdown', + element_id: 'botmux_reply_footer', + content: expect.stringContaining('Sent to: '), + }); + expect(card.body.elements.at(-1).content).toContain( + '[·](https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1)', + ); + }); + + it('rejects caller-supplied cards without schema-2 body elements', () => { + expect(appendReplyCardFooterToV2Card( + { elements: [] }, + { brand: '', recipientOpenIds: ['ou_owner'] }, + )).toBeNull(); + expect(appendReplyCardFooterToV2Card( + { schema: '1.0', body: { elements: [] } }, + { brand: '', recipientOpenIds: ['ou_owner'] }, + )).toBeNull(); + }); + + it('rejects a custom card that already occupies the canonical footer id', () => { + expect(appendReplyCardFooterToV2Card( + { + schema: '2.0', + body: { + elements: [{ + tag: 'column_set', + columns: [{ + tag: 'column', + elements: [{ tag: 'markdown', element_id: 'botmux_reply_footer', content: 'x' }], + }], + }], + }, + }, + { brand: '', recipientOpenIds: ['ou_owner'] }, + )).toBeNull(); + }); + + it('rejects footer-id collisions in localized header tag components', () => { + expect(appendReplyCardFooterToV2Card( + { + schema: '2.0', + header: { + i18n_text_tag_list: { + zh_cn: [{ + tag: 'text_tag', + element_id: 'botmux_reply_footer', + text: { tag: 'plain_text', content: '状态' }, + }], + }, + }, + body: { elements: [{ tag: 'markdown', content: 'body' }] }, + }, + { brand: '', recipientOpenIds: ['ou_owner'] }, + )).toBeNull(); + }); + + it('does not treat callback payload fields as card element-id collisions', () => { + const card = appendReplyCardFooterToV2Card( + { + schema: '2.0', + body: { + elements: [{ + tag: 'button', + text: { tag: 'plain_text', content: '提交' }, + behaviors: [{ + type: 'callback', + value: { tag: 'deploy', element_id: 'botmux_reply_footer' }, + }], + }], + }, + }, + { brand: '', recipientOpenIds: ['ou_owner'] }, + ) as any; + + expect(card).not.toBeNull(); + expect(card.body.elements.at(-1).element_id).toBe('botmux_reply_footer'); + }); + + it('signs a default-brand-only footer with the versioned marker', () => { + const footer = buildReplyCardFooter({}); + expect(footer?.content).toContain(DEFAULT_BRAND_LABEL); + expect(footer?.content).toContain( + '[·](https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1)', + ); + }); }); describe('hasMarkdown', () => { @@ -811,6 +927,9 @@ describe('brandFooterSegment', () => { it('custom string → verbatim (markdown allowed)', () => { expect(brandFooterSegment('[Acme](https://acme.test)')).toBe('[Acme](https://acme.test)'); }); + it('normalizes custom brands to the footer single-line invariant', () => { + expect(brandFooterSegment(' Acme\n Team\r\nBot ')).toBe('Acme Team Bot'); + }); }); describe('buildMarkdownCard footer brand', () => { diff --git a/test/message-parser.test.ts b/test/message-parser.test.ts index 1e9060c45..8ae8ef21c 100644 --- a/test/message-parser.test.ts +++ b/test/message-parser.test.ts @@ -8,7 +8,7 @@ */ import { describe, it, expect } from 'vitest'; import { parseApiMessage, extractResources, parseEventMessage, stripLeadingMentions, createImgNumberer, cardContentHasUpgradeFallback, isPureCardUpgradeFallback, mergeCardText, wrapResolvedCardText, mentionOpenId, CARD_EMBEDDED_PLACEHOLDER } from '../src/im/lark/message-parser.js'; -import { buildMarkdownCard } from '../src/im/lark/md-card.js'; +import { buildMarkdownCard, buildReplyCardFooter } from '../src/im/lark/md-card.js'; // ─── Helpers ────────────────────────────────────────────────────────────── @@ -303,13 +303,65 @@ describe('Interactive card parsing: botmux footer is stripped from prompt', () = expect(result.content).not.toContain('botmux'); }); + it('preserves an ambiguous legacy default-brand-only line', () => { + const formatA = { + elements: [[ + { tag: 'a', text: 'botmux', href: 'https://github.com/deepcoldy/botmux' }, + ]], + }; + const formatB = { + body: { elements: [{ + tag: 'markdown', + text_size: 'notation_small_v2', + content: "[botmux](https://github.com/deepcoldy/botmux)", + }] }, + }; + + expect(parseApiMessage(makeMsg('interactive', formatA)).content) + .toContain('botmux(https://github.com/deepcoldy/botmux)'); + expect(parseApiMessage(makeMsg('interactive', formatB)).content) + .toContain('[botmux](https://github.com/deepcoldy/botmux)'); + }); + + it('only applies marker-less legacy footer compatibility at the card tail', () => { + const formatA = { + elements: [ + [ + { tag: 'a', text: 'botmux', href: 'https://github.com/deepcoldy/botmux' }, + { tag: 'text', text: ' · 发送给:' }, + { tag: 'at', user_name: 'Owner' }, + ], + [{ tag: 'text', text: '后续正文' }], + ], + }; + const formatB = { + body: { elements: [ + { + tag: 'markdown', + text_size: 'notation_small_v2', + content: "[botmux](https://github.com/deepcoldy/botmux) · 发送给:", + }, + { tag: 'markdown', content: '后续正文' }, + ] }, + }; + + expect(parseApiMessage(makeMsg('interactive', formatA)).content).toContain('botmux'); + expect(parseApiMessage(makeMsg('interactive', formatB)).content).toContain('[botmux]'); + }); + it('drops a Format A usage-only footer when the bot brand is disabled', () => { const card = { elements: [ [{ tag: 'text', text: '正文内容' }], [ { tag: 'text', text: '上下文 159.9K/258.4K (62%)' }, - { tag: 'text', text: ' · Token ↑3.7M ↓23.3K · 发送给:' }, + { tag: 'text', text: ' · Token ↑3.7M ↓23.3K ' }, + { + tag: 'a', + text: '·', + href: 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1', + }, + { tag: 'text', text: ' 发送给:' }, { tag: 'at', user_name: 'Owner' }, ], ], @@ -326,7 +378,13 @@ describe('Interactive card parsing: botmux footer is stripped from prompt', () = elements: [ [{ tag: 'text', text: '正文内容' }], [ - { tag: 'text', text: 'Token ↑3.7M ↓23.3K · 发送给:' }, + { tag: 'text', text: 'Token ↑3.7M ↓23.3K ' }, + { + tag: 'a', + text: '·', + href: 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1', + }, + { tag: 'text', text: ' 发送给:' }, { tag: 'at', user_name: 'Owner' }, ], ], @@ -342,7 +400,13 @@ describe('Interactive card parsing: botmux footer is stripped from prompt', () = elements: [ [{ tag: 'text', text: '正文内容' }], [ - { tag: 'text', text: '上下文 159.9K/258.4K (62%) · 发送给:' }, + { tag: 'text', text: '上下文 159.9K/258.4K (62%) ' }, + { + tag: 'a', + text: '·', + href: 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1', + }, + { tag: 'text', text: ' 发送给:' }, { tag: 'at', user_name: 'Owner' }, ], ], @@ -374,6 +438,129 @@ describe('Interactive card parsing: botmux footer is stripped from prompt', () = expect(result.content).not.toContain('发送给'); }); + it('accepts a semantically equivalent decoded marker URL from Lark', () => { + const card = { + elements: [ + [{ tag: 'text', text: '正文内容' }], + [ + { tag: 'text', text: 'Acme ' }, + { + tag: 'a', + text: '·', + href: 'https://github.com/deepcoldy/botmux#reply-card-footer-v1', + }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文内容'); + expect(result.content).not.toContain('Acme'); + }); + + it('preserves marker-prefix URLs and exact marker URLs with ordinary link text', () => { + const formatA = { + elements: [ + [{ + tag: 'a', + text: '·', + href: 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1-guide', + }], + [{ + tag: 'a', + text: '协议文档', + href: 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1', + }], + ], + }; + const formatB = { + body: { elements: [ + { + tag: 'markdown', + content: "" + + '[·](https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1-guide)' + + '', + }, + { + tag: 'markdown', + content: '[协议文档](https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1)', + }, + ] }, + }; + + const textA = parseApiMessage(makeMsg('interactive', formatA)).content; + expect(textA).toContain('reply-card-footer-v1-guide'); + expect(textA).toContain('协议文档'); + const textB = parseApiMessage(makeMsg('interactive', formatB)).content; + expect(textB).toContain('reply-card-footer-v1-guide'); + expect(textB).toContain('协议文档'); + }); + + it('drops an English custom-brand footer through its visible separator marker', () => { + const card = { + elements: [ + [{ tag: 'text', text: 'body' }], + [ + { tag: 'text', text: 'Acme ' }, + { + tag: 'a', + text: '·', + href: 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1', + }, + { + tag: 'text', + text: ' Context 50.3K/258.4K (19%) · Tokens ↑1M ↓2K · Sent to: ', + }, + { tag: 'at', user_name: 'Owner' }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('body'); + expect(result.content).not.toContain('Acme'); + expect(result.content).not.toContain('Context 50.3K'); + expect(result.content).not.toContain('Tokens ↑1M'); + expect(result.content).not.toContain('Sent to'); + }); + + it('keeps a marker-less English usage-and-recipient paragraph as user data', () => { + const card = { + elements: [ + [{ tag: 'text', text: 'body' }], + [ + { + tag: 'text', + text: 'Context 50.3K/258.4K (19%) · Tokens ↑1M ↓2K · Sent to: ', + }, + { tag: 'at', user_name: 'Owner' }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('body'); + expect(result.content).toContain('Context 50.3K/258.4K (19%)'); + expect(result.content).toContain('Tokens ↑1M ↓2K'); + expect(result.content).toContain('Sent to: @Owner'); + }); + + it('keeps a marker-less custom-brand footer-shaped paragraph as user data', () => { + const card = { + elements: [ + [{ tag: 'text', text: 'body' }], + [ + { + tag: 'text', + text: 'Acme · Context 50.3K/258.4K (19%) · Tokens ↑1M ↓2K · Sent to: ', + }, + { tag: 'at', user_name: 'Owner' }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('Acme · Context 50.3K/258.4K (19%)'); + expect(result.content).toContain('Tokens ↑1M ↓2K'); + expect(result.content).toContain('Sent to: @Owner'); + }); + it('keeps ordinary prose that mentions context and tokens', () => { const card = { elements: [[ @@ -452,6 +639,14 @@ describe('Interactive card parsing: botmux footer is stripped from prompt', () = expect(result.content).not.toContain('发送给'); }); + it('round-trips a footer whose custom brand contains an unmatched bracket', () => { + const raw = buildMarkdownCard('正文内容', 'ou_owner', 'Acme [beta'); + const result = parseApiMessage(makeMsg('interactive', JSON.parse(raw))); + expect(result.content).toContain('正文内容'); + expect(result.content).not.toContain('Acme [beta'); + expect(result.content).not.toContain('发送给'); + }); + it('keeps body text that merely mentions botmux without the repo link', () => { // The filter anchors on the canonical repo URL, so genuine prose about // botmux survives — only the footer chrome is removed. @@ -463,21 +658,81 @@ describe('Interactive card parsing: botmux footer is stripped from prompt', () = const result = parseApiMessage(makeMsg('interactive', card)); expect(result.content).toContain('botmux 这个项目挺好用的'); }); + + it('keeps a real canonical Botmux repository link in Format A body text', () => { + const card = { + elements: [[ + { tag: 'text', text: '项目地址:' }, + { + tag: 'a', + text: 'botmux', + href: 'https://github.com/deepcoldy/botmux', + }, + { tag: 'text', text: ',请查看 README。' }, + ]], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain( + '项目地址:botmux(https://github.com/deepcoldy/botmux),请查看 README。', + ); + }); + + it('keeps a real canonical Botmux repository link in Format B body text', () => { + const card = { + body: { elements: [{ + tag: 'markdown', + content: '项目地址:[botmux](https://github.com/deepcoldy/botmux),请查看 README。', + }] }, + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain( + '项目地址:[botmux](https://github.com/deepcoldy/botmux),请查看 README。', + ); + }); }); // ─── Structural footer strip (brand-agnostic, for per-bot custom brands) ── describe('Interactive card parsing: footer stripped structurally (custom brand)', () => { - it('drops a custom-brand grey notation_small_v2 footer element (no botmux URL to anchor on)', () => { - // A peer bot configured brandLabel='Acme' — the receiving bot can't know - // that label, so the URL anchor can't catch it. The text_size+grey - // structural signal does. + it('drops a footer carrying the complete Botmux structural signature', () => { + const footer = buildReplyCardFooter({ + brand: 'Acme', + recipientOpenIds: ['ou_owner'], + })!; + const card = { + body: { elements: [ + { tag: 'markdown', content: '正文内容' }, + { tag: 'hr' }, + footer.element, + ] }, + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文内容'); + expect(result.content).not.toContain('Acme'); + expect(result.content).not.toContain('发送给'); + }); + + it('keeps third-party body content that only collides with the public element id', () => { + const card = { + body: { elements: [{ + tag: 'markdown', + element_id: 'botmux_reply_footer', + content: '这是第三方卡片正文', + }] }, + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('这是第三方卡片正文'); + }); + + it('drops a custom-brand grey footer carrying the stable separator marker', () => { const card = { body: { elements: [ { tag: 'markdown', content: '正文内容' }, { tag: 'hr' }, { tag: 'markdown', text_size: 'notation_small_v2', - content: "[Acme](https://acme.test) · 发送给:" }, + content: "[Acme](https://acme.test) " + + '[·](https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1) ' + + '发送给:' }, ] }, }; const result = parseApiMessage(makeMsg('interactive', card)); @@ -486,6 +741,56 @@ describe('Interactive card parsing: footer stripped structurally (custom brand)' expect(result.content).not.toContain('发送给'); }); + it('keeps a third-party grey notation element without a Botmux id or marker', () => { + const card = { + body: { elements: [ + { tag: 'markdown', content: '正文内容' }, + { tag: 'hr' }, + { + tag: 'markdown', + text_size: 'notation_small_v2', + content: "报警来源:第三方监控系统", + }, + ] }, + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文内容'); + expect(result.content).toContain('报警来源:第三方监控系统'); + }); + + it('drops the canonical footer nested beside a voice button', () => { + const footer = buildReplyCardFooter({ + brand: 'Acme', + recipientOpenIds: ['ou_owner'], + locale: 'en', + })!; + const card = { + body: { elements: [ + { tag: 'markdown', content: 'body' }, + { tag: 'hr' }, + { + tag: 'column_set', + columns: [ + { tag: 'column', elements: [footer.element] }, + { + tag: 'column', + elements: [{ + tag: 'button', + text: { tag: 'plain_text', content: 'Voice summary' }, + }], + }, + ], + }, + ] }, + }; + + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('body'); + expect(result.content).toContain('[Voice summary]'); + expect(result.content).not.toContain('Acme'); + expect(result.content).not.toContain('Sent to'); + }); + it('keeps a small-text element that is NOT a grey footer (foreign card content survives)', () => { // notation_small_v2 alone is not enough — the botmux footer is always grey. // A foreign card's small note without grey font must not be dropped. diff --git a/test/transcript-resolver-bot-home.test.ts b/test/transcript-resolver-bot-home.test.ts index fa95c6253..a7fc9cae5 100644 --- a/test/transcript-resolver-bot-home.test.ts +++ b/test/transcript-resolver-bot-home.test.ts @@ -1,5 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync, utimesSync } from 'node:fs'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, + realpathSync, + symlinkSync, + unlinkSync, + utimesSync, +} from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -18,7 +27,10 @@ vi.mock('node:os', async (importOriginal) => ({ homedir: () => fake.home, })); -import { resolveSessionTranscriptPath } from '../src/services/transcript-resolver.js'; +import { + __resetTranscriptResolverCacheForTest, + resolveSessionTranscriptPath, +} from '../src/services/transcript-resolver.js'; const APP_ID = 'cli_testbot0001'; @@ -31,9 +43,13 @@ describe('resolveSessionTranscriptPath — sandboxed-bot BOT_HOME fallback', () let base: string; let cwd: string; let savedSessionDataDir: string | undefined; + let savedCodexHome: string | undefined; beforeEach(() => { savedSessionDataDir = process.env.SESSION_DATA_DIR; + savedCodexHome = process.env.CODEX_HOME; + delete process.env.CODEX_HOME; + __resetTranscriptResolverCacheForTest(); base = mkdtempSync(join(tmpdir(), 'botmux-bot-home-')); trash.push(base); fake.home = join(base, 'home'); @@ -46,6 +62,8 @@ describe('resolveSessionTranscriptPath — sandboxed-bot BOT_HOME fallback', () afterEach(() => { if (savedSessionDataDir === undefined) delete process.env.SESSION_DATA_DIR; else process.env.SESSION_DATA_DIR = savedSessionDataDir; + if (savedCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = savedCodexHome; for (const d of trash.splice(0)) rmSync(d, { recursive: true, force: true }); }); @@ -65,6 +83,22 @@ describe('resolveSessionTranscriptPath — sandboxed-bot BOT_HOME fallback', () return p; } + function writeCodexRollout(codexHome: string, sid: string): string { + const dir = join(codexHome, 'sessions', '2026', '07', '29'); + mkdirSync(dir, { recursive: true }); + const p = join(dir, `rollout-2026-07-29T12-00-00-${sid}.jsonl`); + writeFileSync(p, '{}'); + return p; + } + + function writeCodexHistory(codexHome: string, botmuxSid: string, codexSid: string): void { + mkdirSync(codexHome, { recursive: true }); + writeFileSync(join(codexHome, 'history.jsonl'), JSON.stringify({ + session_id: codexSid, + text: `resume ${botmuxSid}`, + }) + '\n'); + } + it('falls back to /bots//claude when the global dir misses', () => { const expected = writeBotHomeTranscript('sb-1'); const resolved = resolveSessionTranscriptPath({ @@ -143,4 +177,236 @@ describe('resolveSessionTranscriptPath — sandboxed-bot BOT_HOME fallback', () }); expect(resolved).toEqual({ path: expected, kind: 'claude' }); }); + + it('resolves a Codex rollout under the sandboxed bot CODEX_HOME', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4ec'; + const expected = writeCodexRollout( + join(base, '.botmux', 'bots', APP_ID, 'codex'), + sid, + ); + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: 'botmux-codex-1', + cliSessionId: sid, + larkAppId: APP_ID, + fresh: true, + })).toEqual({ path: expected, kind: 'codex' }); + }); + + it('maps a botmux session through sandboxed Codex history when cliSessionId is absent', () => { + const botmuxSid = 'botmux-codex-2'; + const codexSid = '019dd80d-d922-7a11-8339-0208d8c5b4ed'; + const codexHome = join(base, '.botmux', 'bots', APP_ID, 'codex'); + writeCodexHistory(codexHome, botmuxSid, codexSid); + const expected = writeCodexRollout(codexHome, codexSid); + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: botmuxSid, + larkAppId: APP_ID, + fresh: true, + })).toEqual({ path: expected, kind: 'codex' }); + }); + + it('picks the newer Codex rollout when a session straddles a sandbox flip', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4ee'; + const global = writeCodexRollout(join(fake.home, '.codex'), sid); + const botHome = writeCodexRollout( + join(base, '.botmux', 'bots', APP_ID, 'codex'), + sid, + ); + utimesSync(global, new Date('2026-01-01'), new Date('2026-01-01')); + utimesSync(botHome, new Date('2026-01-02'), new Date('2026-01-02')); + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: 'botmux-codex-flip', + cliSessionId: sid, + larkAppId: APP_ID, + fresh: true, + })?.path).toBe(botHome); + }); + + it('keys the Codex path cache by the effective CODEX_HOME', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4f0'; + const firstHome = join(base, 'codex-global-first'); + const secondHome = join(base, 'codex-global-second'); + const first = writeCodexRollout(firstHome, sid); + const second = writeCodexRollout(secondHome, sid); + process.env.CODEX_HOME = firstHome; + + const query = { + cliId: 'codex' as const, + sessionId: 'botmux-codex-dynamic-home', + cliSessionId: sid, + fresh: true, + }; + expect(resolveSessionTranscriptPath(query)?.path).toBe(first); + + process.env.CODEX_HOME = secondHome; + expect(resolveSessionTranscriptPath(query)?.path).toBe(second); + }); + + it('keeps normal CODEX_HOME root symlinks compatible', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4f6'; + const actualHome = join(base, 'codex-global-symlink-target'); + writeCodexRollout(actualHome, sid); + const linkedHome = join(base, 'codex-global-symlink'); + symlinkSync(actualHome, linkedHome, 'dir'); + process.env.CODEX_HOME = linkedHome; + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: 'botmux-codex-global-symlink', + cliSessionId: sid, + fresh: true, + })?.path).toBe(join( + linkedHome, + 'sessions', + '2026', + '07', + '29', + `rollout-2026-07-29T12-00-00-${sid}.jsonl`, + )); + }); + + it('falls back to BOT_HOME when a cached global Codex rollout disappears', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4f1'; + const globalHome = join(base, 'codex-global-stale'); + process.env.CODEX_HOME = globalHome; + const global = writeCodexRollout(globalHome, sid); + const botHome = writeCodexRollout( + join(base, '.botmux', 'bots', APP_ID, 'codex'), + sid, + ); + utimesSync(global, new Date('2026-01-02'), new Date('2026-01-02')); + utimesSync(botHome, new Date('2026-01-01'), new Date('2026-01-01')); + const query = { + cliId: 'codex' as const, + sessionId: 'botmux-codex-stale-global', + cliSessionId: sid, + larkAppId: APP_ID, + fresh: true, + }; + expect(resolveSessionTranscriptPath(query)?.path).toBe(global); + + unlinkSync(global); + expect(resolveSessionTranscriptPath(query)?.path).toBe(botHome); + }); + + it('does not follow a sandboxed Codex sessions symlink outside BOT_HOME', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4f2'; + const siblingHome = join(base, '.botmux', 'bots', 'cli_sibling', 'codex'); + writeCodexRollout(siblingHome, sid); + const botCodexHome = join(base, '.botmux', 'bots', APP_ID, 'codex'); + mkdirSync(botCodexHome, { recursive: true }); + symlinkSync(join(siblingHome, 'sessions'), join(botCodexHome, 'sessions'), 'dir'); + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: 'botmux-codex-symlink', + cliSessionId: sid, + larkAppId: APP_ID, + fresh: true, + })).toBeNull(); + }); + + it('does not follow a sandboxed Codex root symlink outside BOT_HOME', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4f5'; + const siblingHome = join(base, '.botmux', 'bots', 'cli_sibling_root', 'codex'); + writeCodexRollout(siblingHome, sid); + const botHome = join(base, '.botmux', 'bots', APP_ID); + mkdirSync(botHome, { recursive: true }); + symlinkSync(siblingHome, join(botHome, 'codex'), 'dir'); + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: 'botmux-codex-root-symlink', + cliSessionId: sid, + larkAppId: APP_ID, + fresh: true, + })).toBeNull(); + }); + + it('does not follow a nested directory symlink while scanning Codex sessions', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4f4'; + const siblingHome = join(base, '.botmux', 'bots', 'cli_sibling_nested', 'codex'); + writeCodexRollout(siblingHome, sid); + const botCodexHome = join(base, '.botmux', 'bots', APP_ID, 'codex'); + mkdirSync(join(botCodexHome, 'sessions'), { recursive: true }); + symlinkSync( + join(siblingHome, 'sessions', '2026'), + join(botCodexHome, 'sessions', '2026'), + 'dir', + ); + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: 'botmux-codex-nested-symlink', + cliSessionId: sid, + larkAppId: APP_ID, + fresh: true, + })).toBeNull(); + }); + + it('invalidates a cached Codex rollout after a parent directory becomes a symlink', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4f7'; + const botCodexHome = join(base, '.botmux', 'bots', APP_ID, 'codex'); + writeCodexRollout(botCodexHome, sid); + const query = { + cliId: 'codex' as const, + sessionId: 'botmux-codex-cached-parent-symlink', + cliSessionId: sid, + larkAppId: APP_ID, + fresh: true, + }; + expect(resolveSessionTranscriptPath(query)).not.toBeNull(); + + const siblingHome = join(base, '.botmux', 'bots', 'cli_sibling_cached', 'codex'); + writeCodexRollout(siblingHome, sid); + rmSync(join(botCodexHome, 'sessions'), { recursive: true }); + symlinkSync(join(siblingHome, 'sessions'), join(botCodexHome, 'sessions'), 'dir'); + + expect(resolveSessionTranscriptPath(query)).toBeNull(); + }); + + it('does not follow a sandboxed Claude projects symlink outside BOT_HOME', () => { + const sid = 'claude-projects-symlink'; + const siblingClaude = join(base, '.botmux', 'bots', 'cli_sibling_claude', 'claude'); + const siblingProject = join(siblingClaude, 'projects', projectKey(cwd)); + mkdirSync(siblingProject, { recursive: true }); + writeFileSync(join(siblingProject, `${sid}.jsonl`), '{}'); + const botClaude = join(base, '.botmux', 'bots', APP_ID, 'claude'); + mkdirSync(botClaude, { recursive: true }); + symlinkSync(join(siblingClaude, 'projects'), join(botClaude, 'projects'), 'dir'); + + expect(resolveSessionTranscriptPath({ + cliId: 'claude-code', + sessionId: sid, + cwd, + larkAppId: APP_ID, + })).toBeNull(); + }); + + it('does not follow a sandboxed Codex history symlink', () => { + const botmuxSid = 'botmux-codex-history-symlink'; + const codexSid = '019dd80d-d922-7a11-8339-0208d8c5b4f3'; + const siblingHome = join(base, '.botmux', 'bots', 'cli_sibling', 'codex'); + writeCodexHistory(siblingHome, botmuxSid, codexSid); + const botCodexHome = join(base, '.botmux', 'bots', APP_ID, 'codex'); + writeCodexRollout(botCodexHome, codexSid); + symlinkSync( + join(siblingHome, 'history.jsonl'), + join(botCodexHome, 'history.jsonl'), + 'file', + ); + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: botmuxSid, + larkAppId: APP_ID, + fresh: true, + })).toBeNull(); + }); });