From 874774c9585c2c2ee49a1e81adcfc6cb36f0fb05 Mon Sep 17 00:00:00 2001 From: Tiago Santos Date: Fri, 5 Jun 2026 20:59:38 +0100 Subject: [PATCH 1/3] feat: add devin provider --- README.md | 1 + assets/providers/devin.png | Bin 0 -> 68535 bytes docs/providers/README.md | 1 + docs/providers/devin.md | 175 ++++++++ mac/Sources/CodeBurnMenubar/AppStore.swift | 2 + .../CodeBurnMenubar/CurrencyState.swift | 71 ++++ .../CodeBurnMenubar/Views/AgentTabStrip.swift | 1 + .../CodeBurnMenubar/Views/SettingsView.swift | 80 +++- .../CLIDevinConfigTests.swift | 97 +++++ package.json | 2 + src/config.ts | 3 + src/parser.ts | 7 +- src/providers/devin.ts | 395 ++++++++++++++++++ src/providers/index.ts | 3 +- src/session-cache.ts | 1 - src/sqlite.ts | 4 + tests/provider-registry.test.ts | 2 +- tests/providers/devin.test.ts | 336 +++++++++++++++ tsconfig.json | 3 +- 19 files changed, 1178 insertions(+), 6 deletions(-) create mode 100644 assets/providers/devin.png create mode 100644 docs/providers/devin.md create mode 100644 mac/Tests/CodeBurnMenubarTests/CLIDevinConfigTests.swift create mode 100644 src/providers/devin.ts create mode 100644 tests/providers/devin.test.ts diff --git a/README.md b/README.md index b3e312ef..6366bf31 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr | | Codex (OpenAI) | Yes | [codex.md](docs/providers/codex.md) | | | Cursor | Yes | [cursor.md](docs/providers/cursor.md) | | | cursor-agent | Yes | [cursor-agent.md](docs/providers/cursor-agent.md) | +| | Devin | Yes | [devin.md](docs/providers/devin.md) | | | Forge | Yes | [forge.md](docs/providers/forge.md) | | | Gemini CLI | Yes | [gemini.md](docs/providers/gemini.md) | | | Mistral Vibe | Yes | [mistral-vibe.md](docs/providers/mistral-vibe.md) | diff --git a/assets/providers/devin.png b/assets/providers/devin.png new file mode 100644 index 0000000000000000000000000000000000000000..c9a68730ad8eba459b90038e29ce286e30100c28 GIT binary patch literal 68535 zcmeFZbySqy7d8yzAf+5Q_wY10t!Ov7=z^e zoKEm-vP=~f=);aYzo|rz{nHc?dvh9o$x;$FPA8+*kaUD&k?L)QRpB6&CrmowpNaJw zcMP23-tzqTh)5Ugu1l>I8z#TnJ>;CW}{I!DrKUzWb zb5v3{>Ld4`Ou2Yy{qF|pWEoE3Mwb#V9QGefw?-Y(yHav@c+ElV->iBut1c?Jv|^i- zWk1Yz-6)`xU1g;l{fY^o&CBtFKI{dnkaJjv(nd&kdD`syRdr2TSWVH^Js~kXOj6DeYcp# zZ#~}@llOA9-ID&;LA^UzWr5J7FKJq|28EIWv!EJnR(M=W{N6v~9VabYE@Tx2OPwTc z1oIh2ldaWOTgpV7>@4$jf3F{r$iTTPkER*RxBWYxE7#Fnw z#=mj&IO!Ag98J=kYeLh3*ZJ~$ac#X^>l$Z=_(^-Jco7JjEv-n>B-J;Z%`Lm8B>hkZ zJG(o)*h(&Jn#a1Th~A|~%ETwK;{###O^ke|#0gNx;FtWgJ3rWE9a?#Eh!?6EgItVmZS*PAa_{-k-Fr!RGXcGA#kYD4YV zI1>};L{ZytX5E%JrXwV=tDkmu8t?Y-`r`Kzo1{G}IUj|+x4F2HexM?fByo%^kY4NO z(&vms>G3fVb!yz%#pgBo7au)jSWF(IP7{aqZvBC56-t3Uc)2n07p zsh+$Ku_a!Wx=*8v=~j74gr+$cMHX%; z7Owo!&o`$rfyS-Xp$WvzM&}UBO#6HPdjCs0)4_kRqiCbiGF*nadmQ54A2kpLVl38` zJV;Wo*Dx+@(hwDWh`OOr<)fsANcxyxdoOU=vJ%Hrh(qh%0H?;tuWC!$S2eUR@F&qK z&f2@GwwDEF<>~0@@9(b~&?SW7;3H~PmoAFD^s!ng4{`E(Ygo(ig5EJ6nkwz%{Znga zQ(7XEd0a}|OMGnhi()xWk+d;84hXX5x1AJeLNS^r*rd#$&L**xU-dGuW~(X3iad*e zE|6pGV}aSFb{a}<3e2aNR*e}17jQFptSr*MF3!hg_o7?{NdkBP zbc97SA)1`IkL%}N^uH>Xw&VpB-t35~kd4(2Q9({;? zRKRnIEQV=Sy(0Gt&rQp)Gp6b!P1jY1gvh6InY1T0ip9It=9)?hh&K{5NAnZaF>Jw< zS~UYTt_s}(q9hqI2t~_fVOz&2#2=gR?#W6v(mXdw6&!t#+WMD*3bR!9QRva1opsgGVe$ zH#6C;`)_43aayhkKj}*Gmpt`Lpo5-bky2GB&Re$4%9c1b89E7QmFoj3VdCrvG4w=m z@PbT=25nYy^`y9?C=6H(ZBW;9f6=t#&q(%(U;{ESy?&S^q)+CE1y}6GLY1o3uhw~0 z>9Io;9*%>RKfAfyACG08B&vrdWH1yd8YJl#Lq^RMmZ@f}U>4t4kn?4jC26I5MXfyA7fojZ2Ck&!x4N`#*^!l>^`3XI0e`zE0PG0 z5Nsu=^HyodcB`xddJCa86s-IPmtFiqnv1yi5kau2+(#TFZ2hVWHs$T@U3tudH?w@s zM@kbB8N0Z3XGhn z?piFDcaq9pzgM*?A2rrL{C3A4T_|)vfFU^u+SfG)S<+O|H9!$JGB5EEkHJ*XIlp~| ze)MAboW+}ok;@}IYpQ;~0R*2r@<%Ox(g2pFzm6&yQG)2A$wOk>BDb+ zraz`q)MBPWG8JbtAi=pX*;L{t#nJUO5xwD`gDEe%^HmJ+!j)L2n?}#;i+L__1bEkP z5)YId22FQ+&Q6jzf4%CzrV@4CH})y2kINmGn`ly#+|PgPu-(x=>(l}2izHtWA*rtr z`uVza;;6@*s#SH++mDiTXj-`PiX>jAOr5`){FzZ7%&1L0O@i0@ELaQuu-k_N0VzS7 z{i&R*if9fBtQsRy#=Eks#f9zyNwe7bF@b#*&rHM#Ge*Y&@NV$nXwL}h)HZiL8dram zWH7j+Q*uG1(;lG#k`W60gRrK&(HpXUI~!)+x&9T_!{I19IMHBACAS%<2!reDftSMg zw0{s!K^Fc(+Pol$a$AA!+qA23a*MJjAhIeHp|4QpIsf1SyLVS&Qqxz=S*4Er4=EpRAlD&^)7uBnA@as%Y zO?j@V=G|j&(k5uiS9L(d48Knj;Y(0ReAYFARlEPW*`s=DU(pWwJ-sSynm^t63DP|G z!Rl7oYf0dm#B_9BRtrFstJ6*lY7p*iW#fJ=psO;Y+neBL(QhGd86!zY+}&~X=mNH` z&WFQeDzE#t9;@;Q4O%92W2&z5{X`k=U)T449Ix|BpupGqt)z$ON0WkD+FYNYv?w0$ z=~*le4d%~ag-P8d&UeB39Ae)S4Iw+N{tGs@TOf=pxU_VxLdoHzvliN)nb#!P$CEz) zesU)X+De&pQF&(SA|V6`Rz%|MMU4??Q=5Cft0?+0)!LX$AH>HhxaUeWN#d{p*pd2_ z1SYl*Ah|E}d(IqqeJ-)k+<`|tqrNTAL>dTDJBOs>m>9_UX(nO_lcVk6hCRcNUfad6 z>ab-VYc7Se^Eos2nYz($zVKi#b8I2fW_D4a@XPdp1QbH^Q41q|J445R8C`d+QkyF? zgdL+@Yk1hh$)RXAYpsw!O5!;g%m5axjp0OaxOJMy#Oy{(Vkas-p6qZ&o5v&tDbK3c zjnci)AxN&v6u7ZT>M{lVMmuhyOWJ3^pW8n~;)e2VbCh+I~dCcS*F&Tj|Uik6{k z(#H-s)f2#00+tAD<))=95dj1s44%>FK@n#X1UPl=l`VS~edxU^+|}!mO!3L}YFnE{ zJ{9F95>=;LFA`PhlR1i9%18ec(b-5a9#!I*-SmvQu+&s~Ov{cP2G18^;2NlOA4dmk znjLSWAYz3Y(0`2>Dl4M1=w;e+ZoySejlOA`Gn=^sgVQj`)Vj}2`l)omerXZKs3`(A zC-sV)XhKG`0x50A1HSOHM|3Z?KKh0r3|;@0!nio4!SKY7w+WRN_ghX>?zbsmW>l$!C|;XD`h-SsiiQ z&vWCW(6JP7j^VK>k4nj3yH8^2Uqme_?SD$}C=pHqJc8Y+Y96I+(ody?tutH?89zQ# z*J)1L-FMm_Xk#ohbu?i&gc$C<+hz{HO)199F7$Qy@3qwo((np$@4tOXozv)qQ;3o&@V^3P+l zzuW0xg?}8w3iH79oQz;4#9y;Q%R4hw-PruLFH{UvHM)E9i#LhR{h2W^g|3++90XHf z;9m*QMW8Tku+V_!xg>K30#%m+{3!1{qX+@HyG#%>T|Rz>s+bj6@<&=SqOPk0bugY? zqas9Qk|rZVALp77g&lV&a`|-w08ez1TerwyAs*9J7BHm!WTO^daoG{#A+JjUXMxGK zQjGnmo0HOcH-R@Av^tqa_*R2bo)F(m;0GPEMVv!i#fKFoNj*`x+3eEdc)45|*Ukg{bp{_tk}f0Q{C8Y|JScR~KP66BIuQzp#}WqdO}PV;B$`T6Y-rrSoBVr{f7SJ` zP5uR*zv%jZ??ObGu2;-sZvNK;eKG;G)5&U_81{Bj32VE)bNH{X5(hqNw&X$n|En7+ z1HQIYD=&!P@$G2``n~RM+Lh`H5ScdQ(Mll>qQWln0Ph{UbS%X~){%J@#(BhG2)H}c z1Vjbn&XVJofxb=E=yHqFUgFB#=l`zt|6nObi0yK|f}y_uEL9*Fh(h)^f`-VwUCHHj zkrZ+=H1cQbVc5Qu-d!Y>|7U;@)yDGf*rHGL+ZuLBvM#@4!pIa&b>amiPL=3RLUbgJoceA?G`SS69=|&tNFXmsy z5d=CY?ttbAl`l@|B(Zn=$6GPrDC2Hb6N4~jp6Feh^Gt~^{yR=8RF$|ic9=+}1n_A> z@y#20tBG;{^BkDZUvSKa_?$S}Ft^n3$1gro578G}&g`XzC+v4L{C z#Q34M`q_{%f60)YJi~h+Bh_|>4`xLB2$$tJ+|J(Z1&Me$3$?S|p1hWb^e@{UuSY~i zrWN<7F%QZt4hJycdIafGRTQ)suI8 z2g+VuOI{Q9~M=Z)`GzkfKv565}vC5=o-|i;9nc6%rXKVH5Oo+>RC-gE|fCOb9oMD}`14hsN=5oNSq zz-OCL%bgEQzkQ0?Xx8MqwQuV8V{4u*AR{=M$!mq}bURD0Pi!O}(4zIk7 z8*@*T$4mvx?YW6xZYx_;6*);U3F6rLdH6gkIl1&D&@gd*v@0l|nf(0nn|(vo@4b&_ zl`wf|+I8|Oo$z&Oc{C$62=iXwdZzfSZtwTFM@6zyoKjY7zT%qd!ed;vJjl=pPJ`Ho z>X`|OzU$tUsv8hf{AI*~qBls=UcC9s?OP#o_6v4y7-V2sN>823$~fl1h^DX78LUKx zCOd!?bV#B%!nv;1;U=<%f6u$h<=C-zf|NKiPfPCc9Nq@1f%INTOP>T0OnoT@NS-$= zV84x(iiu7>Fy~M;fzq*T5;aEQ%v&AiH$)fwK-fkGK+^AEtw4uAuJeWho1!SDeTMC3 zIl@CO4h)IA1J=u5gf1}uB42xS~%ui_d7&}!$mQHD~H>s(JOsM=(- z=}rEV_qJ~P8oK7bghdoNHA6g<0u-$EHdmN;?UVCk_V9ExbO~}{TmUPF>TL0fXTTAT z+ytXXDf)ukI$E6?ly{8ZC|NR{OnF2}?GELs_^OAjSs1rEK5)}f7(fYxL%{F*F(@$t z%e^n59a9pu^FfvJ&1S3GbQJbtK-VmvD+5;ms%M~pBgUsTeSlRZxuq=^_YNvQIGs>5 zo4FPsV(4s+P`D_(w`=&@aM?y@XM^TKzYi}*y+vI@|!y36B`^9W-*y3mt*ldDk8CF;U? zsezsXo^M48oZOG0LQ`_;$#o>%vVs{TkgK` zt-js0_$4Hh40ZWKB0VNec<=vwWUaqpF~kdG#WyDZh-i*iTC zB>!Vsn~Sv1|9TJ*sQOa)8mfjhE*3~VIu+!O6I)AqZQ{?bxG78q=>>FItgD`&e;ZN^ z)g2Etb*`P$!Y9jYF(9iyQvl8|EbFH4!3W*9`?yN?#%z#Gk)spL5Sl3ikr@djLY1I6 zDp2a2J>U`gEk4-Wh2t*1UOBm~-C~3gN#RplQ_vh|n@q|(tb;iS<(d$oQ*lJw#|6Hy zRdpM(eozo~A)rfSPme#^l^TfpEXeS-GIZ*KZGyyguu_(^@~EUyO`CgLjVa zH%T3|o_rXni^oqXeB?u6&H;WXr8WUN2(|`q`e_$y*=}u5T1JC(FeqL9{K6gq7Ca6Z zN95EmVX%)|e9^2gpq3CtU4Du0;RlI8yYu?tP|G#cill`_8dG)-;9iFrcgijN2k6RE zL9O(rC|IuJbW|nK|Hw~|0RsWVgC?5|;{~yV`_xZ3Gh;zk`Nd>)G?KHN%VW#&#Nwdh z*4EwO30{m>Lfl&y>MoFdE&XuX5%2&h?Xk6~M3102y^Ic>4|KzMIO*Y~Yo~4B0se>i z0>-`Cmhyv|A^Qn*CTR@LS!d7p3Uz=Kw<0JZeJ|)O`clf?)Ol~>f+Sp!52)L_M;W7K zsY|8f@88>ClUScs%gplHxPT(YgIz6+KvWuFzQ>Q+b$cVFg1G$+IW|Vb@_DdO<XEHqVRuQK*_jQ;J2CknDm{tuYqN|q9ZDfhz0 zmk|YdkYhjHY_~R%R$JT79$~ox&J+}b6M}9;-YiMo9|$#3!>+d8i0nM-?Tz+ln zMbc$*eW3!ng;)mseU^(62k}gOnkSA{+5zvTHD#Z**a$Lk@qFHa_O~uK-(c1Q3OsKW zo7}sSz&d!rk;IKK-xpYL?F*awzCRRn#zY%LD44NWbMOy>Zle=>dId3IufK88emBeV;5KWWI0S=#1SOjj?RK|qBCtu0 zkCn1Onb(|!WefBg+!jbzlF6B7d<0UU=qpqu>PLbQVEMXw_?jf>CfR8ftq$W`HvKW` zfm5{;?KJt`UbE1m1OMLc8*{PlaXE_)@*TdEIeW6hf2GrSfCf1%kYcEmG1Hz69v>=w zZt;Wy;Teo>lh384?_WFz z0VjaM$qm&LW!W-{hpV&)KfG+V`5F(W+6C<_SpKH@I#7}G`xTk+v!CtkIG`*>0_pji zv@eI#fsC^;K*kiYe79im+hWnIPd_XS+R=-bBU@AIrjO|;2Xygk%||7aNl>Z$T5}y1 zm7@m9P0R*3K#KG&)6`C`4{Nj(k1W5yD8>YS9y>G%cE}nJ2gUyXkPl{Falw;{bgMf} zud{ic-5iQ7l(2!yJ^r#EK8uCvg@>(4)Wr`AUxH9T6^zW~@@rpfrztAeg)wVC&uI#o zcuZYbe0Mu@zjbqmH&PF3nGt}hQOnF!;5dsQQAkMyHFwhobuC_7m8SQWFV~@o;%guy zR>l{VXK%^8eFY9&JQC=ujQX*pP9k^enUXy4)%&mu2R6-dCd#-V-S{vlc>|NVZDYp+ zywAzySM4(qg{MwYH}QzUESQfD^&yWQ+Q=O^C3RP41!VdQX+P&-ExwiD7X!c%(JNy;1|B$pme2QP=aVS^t8*s*eoH#h%GTLh0bF84`OSBY%`&$obs z=_R&(qUT6YOHalHcKDPYd!bt!V!)oCVA)i z)r|K!y|+XkZ}+`aaY6t`^-GY!DdiX`5t4eOSOI9=@K>+jbo>U|o#7 z>-$mk2Q|tJH3AB7rxH}IgGnmmC{ly2$pLK4&3`)xF>L1ukaOX-E?)pgL|FtzaZ2f z1%;d|Je!wznQl9Qroz4Lk6fL6l5RgQ!d@Zmi@NtGxhR>;7EXeZ3^eQM1z9xWov9fr zYU`)}k)07^O`fw@paH*wn4}HbTmUoFem}DBa4P5_X>yHHRm^uNSE79rQeTL)XLg+1 z$5os>LK_P(o{4%Oep-+Xgoqdz_D`uK)(N0J zFOb5TKpl(Esi0dZVt&<_1d8!vSEmcx?dApx>{iy=rTxxh;INs8En_`Z!+m1@8^xZ~ zzq#QscoCa3Z2-Qf27|&^!bVR|wJTFCz#7B>Dnok~e~iK!EXC<8DI6W|x|Dhr1ZHsx zVILgpE?v0pdlil)@z$#_R(|kCJ|oa|d?YQ%{cojQKQxenSe(76#yAX`Z@VtIyu&k$ zjw!c=F^OsoOe3rTmu>SZx^hU&<(V=R;#~*UfAfqiK=2}(bU(nGbfg=^EFGn0gQ)6**i|=M{4R{)XR~(c_BKItY#G zykCXKgX7gp51iG<=k_2TZz0So#8w>$g&P275x{%~v*ZPEjJC*qyE7@Y0Y`hL*2I`C zy7fpcE0H;+e6T+m7gXNAeJXjZx6k0U4WkEh97LurZminS=HUrud+t`{B5YK&=;8T$ ztWXb2k793+Dn=9t%KdY?J45!ndOSb3t~9Ir_`VF^hm>7YzXEzFmNq>cQ@i{#ZVDIf zjZL-6sC`#j^shbE&~myIK*OG1>U5NGRkbk#+89I!J!kV+RWM30K*@mtEMVV$QHRt& z5Os=zSx42*3Az8CsXWGJwBO?tlA2X(NuYWvf3ZW;cOsXN?ZV!`Lz=XJ?pe>P+<+C=3LRhqIF2`MfBED-p?F$i4+J zH?)xRuvfjKxZP{g_ zJ&AAIjcLRtncw(DXyFFYvw(l&U_(ttGEGy`{p5pO zZDPa07vTjv2hZGU_x63bQM!V88*$25>V?^ zKA<$-TH;wu2=N+XXN?_wVe5>l1dL$Yx-fLCuK0jlUrn*agX=WZqo%3)3n4V01Jx1{ z462{oEX9Wb<);2wb_q5C>{{fS(!~pa&j`&qDSwkx3(ul0wRlTGfU<)ir~N${r0ne0 zF1&`?NCIl@q!AM@E@N!b%an-~{ri(k@+y=!K!G{o6bNmhUI(-?#S5<5nMW3dP|I-8 z!5hRbHpD4>U!#*$ZTpPrR)h2F6MC1p1RFqfh5~6a&{15sNtBv+C{Pg|`+VTc@1W`Z zw4EMN3h?CKwD?8Ld1Jo9nqRfs2_&6+-#$|uWVkE3;96-oc`-Bs#RUIwVUF(L=bxa} zSnE@n6r+GPU`aAQ5=_}Of&J?asW)gL!Gn+>i4a%VuiY&MdXfK8Eota#amU*=6A|PVg zJF726P4R=@NPdGC@GR zJwI&Zo&wy9qe?GdXkKxi${CyC)utnqMq+4_8eJGcANRKL%G!m@4-K!s`-5nncYlN? zs-7Q4>*BCzF~fyYx<6bFpe-J>+qs`S2VCQFPJMs*XxX_sQ{OMdWV`M$^fJ%fUdPnh zTwIlBxe$|E8=B51<>NQ|w$7u#2l~QUgY%?+AM4Ksu&r4yvU^ygVuJD01<=-!yTB95 zHF2g4LJ#QdfUt-ef}|ZymIRR3ZSIx{?F0c704Xc9jCv*BriH5EuK)>$0n}pTIZp+D zBMcB?{YK11yJ3A|HxI@bw#O4$saoZ^k;~$N`xc2!!Li=Hx8sBxxQ&ReLY)m!Ms_Xj zJr(e@V`9iI2sGK6OEeKWCC-G_Rq#`Y4b>!GLAb>$KJUuz*jtz9<$cC3*F9m#C=L7+ z6BlhjnptR% zPu{d_V`H_(!)r^ka;Gbt49!l>^Ki?5A2;kHWgL?MVDtj|xI|Km0i3E!6Tz(0Ie3iN z6olHQh`dnuJFfu*wZDUn?>qt-I=w4*sBZJ45R_;y$E_+2vEWyrHw|?Fq>1mMVnm*z zI6-?UeZR{c&r6(T9|^#8j3()kQkIEhH1Dz*Ufo)=5dP)V2rKUQFJ&J18@`SlB>ic(f@tEU5l z-*Jz5Z*a!?Xr3^6DYFFYBZ%nYKrZ@w2hS;>tX23gCJ}Yl2`HAf{hoip1 zMpxNAp#*RkUHX^Ra*#&3useeU=hJ0mDY|FICzrj-m={5YhFa;1rKL`y`)I)(rlT~6 zpd-utl%KjS4?jGg%cimfICF7T={9I@S{`oXQMfL)=X7)TF3RZ8zVM@fY7lZ+ z@~_D*Yyckc^+u6#`CD4hKEtWfVXsij z0hfU_0eZG46YBlYa7{x}X3pA3xW={>%C`q^b_U>ft>-W3x&N>cVK#{E4$oiZM4zVc zeq~!Hh27Nx`3-$^N%m|1*#aYvz1cSjw|kw%#wBvwu8it>zSVsy5dM&+*7K=bQXN z0povl&1viH@L=soe0dSW<*|UyF-6+T5PSjz!GZyM#Ig(tVZ6MqQvS=Mv$i6Y$ z40S7WpWgu%abdXYJ=cd`hf%JoD+eC*Ho->eDRx#Z1_$1ZP3?`bJYOA5XW>LlaN_Rq zsRBcU)@BLN<^hu>v5*Y#)qMJ&0YDeJj_-sqrpnB=R(Ai~E%kxg_Iox`xd%K#FR=E_ zQ#?-HZbWb<+m_!eHgqh-uLJCnk)}lbXlyi|-~nUGK_SbMS8RU@2Ws6*=eY2|*T{#Ar-1zl0v!!9XasWXADfOzme zR<f+NA*-E6I)u%A|QEojGm4mt&?^XBo%Av%Ni^8UG z<513(-5U{)MFcM`9COAi7&MUqbm!${C?g+!*6f#o{c%~Br?bgC1G>1RMAFN$j<#iN z)2>}e?W6>W68gIb2|Er1U`&&JKbswjHG$MBG)H(M({_TZJR`;AKflYRxJ2>3;9Ae{ zolAPZ1x!IEvinZ4!uUm}VWWGAq<-({s6fu_?F#ni#38O*{#aVe7@KL5W2#Nuh|==0 ze;lm2pd<6B1D?R8wTnsZ)SBq!ZRB13yaMkW4nZ3(6V}Jn^UE7G`F+#FTkR(Jz)Cpq8Vw)w`a{kmmq$vwaO@p!Ky@B@>_4Kk_oqxV2WY$Sofe$6DKj=O#)e zmLGI+6L#?e=(CYMJ3%=mO%Mu_vmYPcW-{l}~8h{Sb{Hl4ojJ??6Lc1(SC{nk2kd4qcx`RGUP5Z5}-e1iY$Ws3}* zSJz1!Z=#+?8zqJlND3!+S}F zuu=g2Q1|xOFDj@~1sIy?$SrU`$s_fJf|^mV^lTIsYL@0#V^pLY_bf^|m9^qMdfvuF zY;$qCJCUi7*hNGRbBCIy9d73lj_#Lm^bkx@Fmpm7cCLiWq01lEEyt?~=b;zu8#%Y>$rm z3nln4D{(ofC<8}E`ORq4*(t-~7RMyPbAiQVLycC7zinCY3$uIyE1iG`?2nek6iu{=X^zLi~xE7h5QdaJXca!f?5gB^Yh1U=QoqYZu-o7LN7()CwsH517-(kfMp-YSIF!_yI>NHNjcRPFw6b)@wdy_%( zu|P%d_l5XsMQgG>fv{9?)#B#xl#9Ar93JI0k1-wt0h_XXc7(eab+;3cNa))0E zYD;!*Z3DO9UI7$f$3FKzUc2-}R$DB|lcV_?5@e@kIak@U@WCJ+mEu7sy1Tvd0RoNs zNW~#!D#h3PUes+(7F$e}z-mDG(2JXp1&&R3kKy??y|sgz9yNIX@s=}D5@2^0AnULf z^JJ4~7EvH(_nueUyWA*kL@n^uhSv@hy@{(;fGb=Sq`!KT;Q!fae5C?bF^^k{ct}s+ zcPI#UcslAK;iY;?^pA!#UuMDx_+{IsgJV0@>G%!*#7G~k&UA-{?T@PlKY+3_sQF%g z%}dx>HI#4d-(0CX!~hG~ulkq; z@^7?-x-}2zJv~frS^GYvSiVMvR^FRvH`)AV{Yp?`EJ^yyon?bxpMM7u?0`~zEXww*~#!C=G4a>%P67?Sdfkv`C1jzZBWl&Cm-sux9taIJV zUXcAlxZ;8rHu!!6NH?P+H}O-W$|skK38I*?!TKPhyCBbIQyH7ow5j(ZfzSUgjV4wlKgg+fFDIJkb0n=oh}q@*5p@Js)0r(PI-gG$=wOq48f znA_l)bFPCI2{(-JAJ(VCZy0LY3yc$Yy#O|ScR1n8zb(YTmC-Q!(1*F%uBTEG&tk*? z>`LFLn#5$m%2E7q@9iu6zn}3Co^iGVk7#ttLWw)#veB8ezs_{-m&}9{zp6mo8a#9Q zz@Kkh0P;X%X&Qy!_DIV;B}&yDYI1H63?W^Sg`RmyI~^eBdXI^KxR;<^-TMju@2-Bp zYJ@yeBO`3d@RN7=gNZf@-W~dnvB{_)#L{qCSruM{i)IX7*MOp!(IfwRoaFf9JoNHJ z_KE8brwQ}nZw9lt{TFpxnWk$@9n3pU5G5#q{22e<3r7#Q}YE< zSdHNL^A#lGnW%23vl?-lStqCC?6PlM7f{TPGQac7z*cKYI9Q=7k><@_bkow8-+kHv z)_c=84#;^UfWLs%M$4IoBU#q2#2jrn>RBbYR~-G2Ox&x&>5UAbz_ z0GR$YiXTLl~$&9_DybiVySWaxYw)O&~?l zR70h$;mwbik?{sAYYU%QwP|vKyG@bGm2`TpdF$z?@-t}Kn6x+m5@<_mmlk!km}I>phWi=Fn;B_vr{ z!hx)g6mpQhp8?@qhI+S&`E+5)@sP{Hd|D>9?TX;QqF>4Wvf439lfCEidpT{lmp@W- zl*dweq@Ht}fD;0?Nyxe_0*Xo2%`Uc_*>0EZ_*G(a%;3;ZW2%~~dwR$Bza`khJW{ov zbz0(OfzgL~V?6BN^J);yIsR?ET59K%{=BRA&p^=4=9iD0wWjWOPZ`QuFxL4=zyKqf zA80v^yRIY+Ha@94uV_kB^LEy?(scD%7%0=&(C;%)8qG#|&tWE4Xq;%ENy)pT36ov` z0DHptP7Yqrfiyp|95v79GQV>z?sL|dohxTf$MN6u+b#iJxjE65{#{8UDsuTKbHCdT zpy_o$A&H3NUsdaQcUN7cg@}faae?JzP1i)%$#Agh9B12esRt#k)4_sqk24YTM<~K- zR9m!gUnAr58;E4Hj^A#tv%I-Ka(LtxdB6R}%e$@hLj`!aZp`tIe0$nb&efdz~ z2=h(7)a*&d&&nY#K0mlh4<+tBZ{M6^3{q=m{L~QMEIP;)-a&|l1M5w|nL1f{k@R>v z0o6SvNnf+zCXvydKjE)ktC54L-u#AZ7(7dNh5Kqimut2Csz>QVnMJUWN;0?8;73>#Bq5dwSrT3KTWoa2C*eUdcMq zxE^we885v+lnLn3yxXGw1!|^*K5{;f9{br)8mpA$Sm=7b_)UlJ0N46xABB#~k0(Ga zg1hramuwXBNOwx;fLi?bI53Py7u)7Cs$#OL+L+!yLxHwbMRVVcWqKC|&}d%sY}7zo zuGX$A{Eb(o_;cz*Lmmz;|Qyl(EutYe5~M)U5hRorgnHX!%&+}A(ir-L<&aCnm+V}f4Nb`o$Ry+zAOFu|5v#*9DX<5TV z8gX-6bm3XW$$fWhs1EI3r z9ESKR0JPba$9Kow9%kQI{D*2p5~?pZWdY=BDIBuc_T4XeSnY8JuR9FI_ER~3Fgp|I zy2Qk}VwX{;deEO8e0_yaxAmU9_5HgV0bQ%T&Rf>! zhjx?}iab8!woN06g3aW?P|iNHJWNuU_;l3D{I`#wVc@F5x@`&SDp^5Kk#XlG{<9M! zQOMPrDLc^JdfJ3UhPDbpYK&`RMU=Fed`MBtJ14MVmtD<5Lv?ByZVV{RL{$P+J#HZ) zICR~MNd3A1;VtEyBi+^tte1yfJ#reD{L!&ld8e&ccVQa9%%e8A7GJmkISejFCG;I1 z(2#el(-aY8?JMi}O+1+d;JW$-N}2F?Wt_8_EoTsh0pToskG=nA36rRI;sGA zanG_`S@L`pEq6!nTexRuWaos$9yNm znGHaxi3)r?+jCJvk`wbyyNZl12OUR-6w zN}IZ^5g?jGB(-0G4{`rE@+Yxk04s2O5qoa%6l)E-praVSGnM)vmJE79@4o+3g zD05log~i^84zybt3yh#Zka=0M$B0O3K*=epB%wlv3ojhySbMIVk&+k9e4O2H^d^#$f2D6Y9QLS+0CldSeMYrJ}xKSgFz^dXysa(F{=dEk&eqS5H7vB7M9+t%k&pa8eWRX*DYufr-Sc9TF z&7ex*G7>4$nfep4#&2YBf>onfNF^n%1=I$gPMWK$9rj7Uu3HMWIOxLQd_v0s{PIcP zs2HXn0QSpfXcP(O6K1%HZH@;veuBa4=MpMLU+bOMhV7|FHctR|g2n#?oG#Q|aMPAY z#dWrI&8KG@pXk;J>h>2XHn5D?U!N^G`%CrbNZx+`P`CFFKK z;2M6vTPql27<8Q;98}49OXs_FhwVOrn&(fN^l)DV;26Xc>TzxBX>O0rveVwlCV&R^ z@A;*BmnckQEps&zJI%gtURiMy3o_{95hi^pNi&Rc@A>-d@L5i9_(#=*1k?Pv*3F@@)$z6ajAwpXVZ8d`)RMHc4lC$8nkPUy&`5go(M1$RUpSs(*ed@ zuUk)Nzy$}bOuP)^Be)E0H%4-p7K03S^dBeBL)8syGou8yQjkKuBhyq}VR=ug4U8&(`r2fnIk(F3PYD({sv zX%gR*G)c5v(bEh?d$=#jINo*-c5vQo&eQ?TNV4zz;*qE)Rzi;|6xxoq@uD#PFpu~E zFReF9S@);!W}TzV_RS?+0YS zQYxwEcb7+`6WwXw(5)n`$qrtQn+@M(O~(4AHa1B~r>V1Q1{68;pHveow}K3m zxYjGZrAOG%nGl0{_MDL)%}%?!s1r~ZCpuLSMgx{8c^$F0JeRJP}48qk1IlxaYRIQFBbw8nlHPgWl7=v8LzjXd{}6X8|wzCQpj9kjE|7 zHOWB^{W7=}Qd(}lLHx=&+n7L&&BPMp8@##>-lu)*YUI%UF z)kmnRuqn0Qwx(ywH)PGLjxr8TY`#{ub;3Uxx^K&wkUZH$1Cps3D^R;yDXF z!4_rEKpn>DhXx8}dJ1H`IBqCEINMRvO#E@$70lRa}J5nLmj@-=coOd5hFJ-+K2@J)PtNL3MkvFJJ_Kv46X?k2w z198|0@FLy(z|3=Ke1A7{g)!u(r#$dT(`M-#fr}!dJ**YpZ_MQ;n$_N_`{7f<@jKq* zlT8!FCk|rQPM7J3SMQ7^U~*X&tGYp|fvATd*gjtErOe;KgVw=qwNEhIaCd!?!Pg{W z2opK-gPyy8x8K6wwIjy&l6D#)=y|7Z5K@Z9TK)Ji^ z#$O(xPQgukhC{3wZ~4HnXKtAwaTC%yeX-wdU*ZkKPdnG+BxFpGgIJ|7%^%*CdPa`^ z2r^UWK3EVxT0*8=T4}wfLwYx7w+C`|Cm~3Byprz#NL9nLv zPXCrGRnBsN3zZ&vUl@OYKx$hll*_m%AG-T#JJf1_IMo4tZ6~W(eixOb%1Ao$O=6{_ z^*@b9*U%4-HcUnZ?0MDYlWWW8ziGGQ^!C*6e^6@IZtY8;3zmGaJgOCIPfIl>o-$#!Fr`%zaqhPABrk z*h;I#i$v(6rKSmD{^QnJv;5&frL5=9Ps6gf1K!1@h-#S(Y!UVT83k|##Rf7p?&1?K z4}sYIKbhZR=B>51;m-Ub-N8ed@e|dPCqYA#zrE|bp$Vm|f*zHXwdaqJ1j&z6V9zwR z;v)L1BHE!kdI|KML!}x*XMkoMHSgJ9Z9k4pE3?tEK3i0;hdpUE7fi{u^u+fzt&Xn3 z-O<In-R#z;wb=cUOW>whvfUb#gs$md*LRUUi4Qu6MP|hM?V&*G4MZXDoApxeN~oU(^mn#Q{L5D(Sn&+rdaM7q``< z*&Gn7GaVGx6RMxDRGeb)>Hp#!%VqaGU z5I3|m6iy2(fJR&^u&@~}hnfDlH_??Hg&jc}BNt$M!r_TXg{_6fV7l2cpQfRQCx6vU zo%Y;MTpFW9wc!El7SMDW8LglZ}de} zcCVQ>>L92&uBJ|Kaqiv*y$NgW7dX9Z#buK}=Hd?#QwHM>H+ffGneAoV&&voJfY}&8 z5fXNpjvIx+LCOW-W=!2QFQDWk&vHJhn{vLd0MZ|9eoH@+#V6{;1SJnQ zGn{kegX*XW!dkkU)qBZ5Fd|hQsHIOZ1Urk0J zRWjJq)93IBY0hy(a4*I91o0j|<(;QpIwRLI3|TIj;#<1MXR`&hfW#BB?uIY7-*vx?(Hkq;z@ z(3L~==nj(Z;LJTgKkckBh`idL0@uD0GoY`Y>}188k7$|hU{h86hUrKCUjtpsWU=K{g?qlak8b*(wC@5^|_|-zGwOT zH9G{8G0JHh@_6b(n5M+R`AU5$6`v`FQ@^BhJ)7L#el}*;+oApBRV}(zam-hLyTWyA z`M-Gb$l@xc5}loG1p%9ZnDquG2<5A2;ggTyx^zdGf!_Q6&#|3VBN3{i&QY4jQN66> zl0Qm7t2cqODzJ0G+A2?_DHG?B-I>_%CQrBd zsosvVLzNQirEN3qPY*gOz1&TMB`mK`te1(Jx@-&bJl7po8t0+ zrHH=)wBf6~du1ov9B3KPyHr%t`%6+8NpdgV=P0~tN6}~>QPxXzNL#rYWY;Zq;4Vow zEM*X(sC5?G1osvU804rjW4hSOJ-<<)G~dxnzFU7^xjDM8b_rxC)#eWP((*^s+{9Bv z0#|K*^ox!3)mErZiRpcI<5Dl=u$($3&pYRR%STnPW6UA?b1gJB7sI!2)D?LK=kqKk zbUO{=Y{P3R(JAq$tpYaJMPH)IyB!iKlQ*`QSP9z&sN;_^aPP+)>0?iISy9|sJ#jzz zBzKLH<@}rUW{dsmH=lr>P=h!=XOjsUB3V#2%hHYlb1CdSzlKCf8T)s@RdTnvcje^x zD|jr;i+j+&X-w*{U@8QlMNx&V0o@*n(Yn+q;QM}gcig_Gp^yTalk%YlIIg_-1ZeCx zh@;X!9QN^^cN;mDkPu=LlS|*R^SKEtT#)W#0vBD_Q1KJM%@6)3mCSx><@(|BQVZsx z9vzR~ zHH$++pyFp(BlU>o$>5%#4m!P$>%;n9wKg6WLUm`2&Zw|x!-9$ zN|vzX2seCwd96V~xeeel4$2I(w>zJMd8~Rw;OR0q-<0d==*V+;jNXvqVq6z@dh@ut zR{f-3>Y~ zh$%tvK&w>8fJ{2@=vrUG_FGEhcX22^YuOf*9Li9jHUgx!bMX_9Pl|O(sS-qk&tOHD zYrUe;8616lr_2aRhQ(o%a+=rHP@cCb?@@a?ynyuKxMq1)YL;wX zXo|(4Eu|?1HUz9^n+!NrK!+#tf&j&7T0TAY*G^ScoO4n^}Onk__lQlkw@wY0N6h3K&K0DE65y!gur#ZJeWb!A{ zHJ3YMe^(tSEkG}qnz9o?u)`(y8*$fS*&zE`eDw(E?W#4z)k>%zXOePmyN*kk$`DVb z8m)kYMufyHvN$C+x&9}s07rD(R7pc+UbV8#g43{ro`1K5&5J z(VU4c!oyUeu6KGgqTiGfFNOIC~^^d%!KzjAF+*Fzq4~>qHwzXJcI0ep+3N>!SA%D9{J_~pD-mi zagC%w6+Q2e@*`D29nor#1}nI7Z+hu$ncE4>(nh282_i6TB3t#bvpBraaRdV*Dq)6b z^gD=lOAm?W2o$^Zp6GAjcGt(rB=rTJRItNpULPmu^MBvlrQ2@s{}uTQ;{mM|2ZhnYbe)1 zqoc^9ggNFA#qmp{vZN%E+)4Pb-TbdiXvFw3RRRFsZ?u5!#JnS^CI=~-EX_cQbjGgB zZXKn(Qm(24*C#17^LlQW`un9WYf?Oe?S_O?0`dawii1TGmboP{)AtU4igCZLme6+b z)Sa!hE`<$yvTOTPsNU>%%qTm++~R`CxUkyR*t>c%;kxv(3oEB7U4J7jfB}SL-JZ0E zT4_+&*m%ZYCdj79!G>EeF(C?T9lt4-cH7_L1dSwWLGtKlozH0BFa_)g0SG8dh@YIV zfOU54dWC=`8(uwkaO39EO>Yh>-fE$ z)uaM{Y=v&S+{Q1zhv$!BDyb1hsKG`k3iNP+&Ms*o~=j+&(-z> z62a2T%)7j=T`*~~X%gDitC!4bQjVt6rL`&YGhs{*stSC+A(h%_YQ~6NC6tGz`_%2U z%-PDWj09hVYeYcYsw3m3&pyl%Bmy;f_WJq*vILkdJS{cejbS3wN3|uq>xGDp!s1RovcEw~K&aM7x9A&|CTr{!ZKv z6Em7=Jh#`!0>hW2`wfxR#g@%D_5L_nj)-J@&2f+Z+jJ4<6Y-L>!|?c&GuH8YVq;)$ow77yLc}bz=b@ zPaZhS52{XqKEOF6XkY-wYta;$4(q@XpL6;2!wkAPI~J9U^Q86&tpM)<0Xj$kR^NM* zMy@tY7;R(@fm+L?Sd-Be&&m@kLb&YAHl^kv9H504G2x8w(!qjpvw=gm6kJ#^`4U8; ztG*wrNiE>zGS}Ou85Nltg(5w{b3Neh>O8eg#hi=&uS@>NzyQmu82+w6G-0%cAzk z2BU7%3gUtq)GkKnAZ#@px302~A6FPOFUqtrDOWE&TqpCTJCqXC2IpfvOKrZ5?KGmd zhth2`n#a#|AwCXyS|$`9bTzK&z~7P2@qUw2lDa^&R%9z>uFrI;E~B=Z2u6czEwm~P zo{;1x6pt*6*M|OD?wuHan+?2LD8YM^L10iJj&= z$T%U4CNbophvPCNETgToD%Z$1VpD{2*y5Xvq#7Xx#;lNrnXD(5cYnF_9Hln_Y26NoJSa|$XgoU4-bMi{t;a3%<5UmA? zr>bbZIda5`$dZUg_bY)sK}LVK1jm3Dk)(kt2A<#P9_5r=i|GFL#Q~9u1k%(2HGzwh zR_EtW2hzj3Tj)TtXx*A+5Prw)wUVGq>g ze}TN9d{gUnf@r1?tW3^tuI>W0O#L#o;Pxz@=EAqBIyL@FDDAPo~R2BHj!Fy)Eldw`zKp$V=8Y2%(;ci6YjC`ohkH6w)AhS*FI& zXQMNRLh?PBYX_5KmVVecpdRJ|HK)bhh-(NKC#~>7%=Hc=UH%7Im*|c1jwq#T&;mJx zFZuvR8bD3Gg!|(j{sgv53F8f=3{PqM2*kU9%M`D`2A<^f`_t|OGOWg+Td||7VZRGu zWl%(hM7ibor{Qi3X9uoPF(6+pjVj7QWCs>mg5dldO?GJrkn0lNQ62*8uo zT{sytY|ANQklbff& zA>i$jYsbGL@~a+Z?$1YhBEkajtAm0LWs`^lpwl~P%?mXAa~=;F0Z=-Le`jckn1jP7jf%% zJa*yA_JE_GH2d!2FdA?SbI)z=^_3thd6VD=+R}5y6#pOsaRjk^`LqqLmV*Bx+@~-u zd@-&KdqcX8BhCUJmo%c$aeEIcmk_i4l|xK1bSoC#X6~(_yl@&aoi!vVfx$z7A z`-p#n@BjV3;2U-!yTBrf$oBvDf2&I2f6ltC?SdWuz9R18Tv)iiip$Oafbgn zusru?9@+o6xgH3?Zd~$LQuf4m7KF88@+b4(Pe)+NL7x9uQ^8^nP5w`Uaqm|IDd*qt zWn%*OkQ2uNnCYl^5+d(v|zV7IkT(({20O?0DGC!SlWlnAk;~4Kvdw>o zpN-dTcoW(m?fw(yAb3Q2`@S&#xrBgNR)fI@f-HxpfxE+7bdRQ)?d3YJ;*8D)Kr{|~YV zGY+3ee&G9|f7YXeX5h+?p%HA@TOZ(qH!xr@_i0Lo*>ezyNX<=;=d^l>4E9unrex6y}dwyhXYe>V|qkz|0^ zgFRGgS|});0EJmv;SPN5%3{{6qVxiEVZUaljY{jl|WCpO%M4X7lg3 zECvldJT^{$!cL-kb9~Uhrte_Z`qupwTQ2|Zg~tlE3{a3i`o!ecI6zMaq92{R zNP{u_z}FYaetUkEX1kJ^QKquD+WX>d1;{RJY?*ShnJL2#w8Qon>P<49guel1^}+BN z+1G-4=B(FGX=-E35^C0T*DnryxkM9OOItrq-~$3LPE1h3MsBq7D|JMBvX3fc-2^ao zbr!zk^@X8v4K*fpLPEI-ez^lLMhfG$zm^Qx)K)tn5ZY*@tD^k{1=3Yfd-S`w&ga~4 zXO*80#<7M?f~yw*ro)yT|8jWb8HC}|L-)8boh0DOe%CKt6Kq^e5HO<7 zpe7&oFh51p@SM(v{ zgpK}4fzqWC^c(3YIN0&JO?PWwY=raMRdXRIzaLx`5VNWBuN+YDaO+wgFBgnF3s$)0 zHYf|GT!gET0Z;;v;|&{edSbYI##doBaxMA(Gnc=g0dd;)T+IpzDy<>4qeM)%fi!4>C% zO`PX0<54V^Jk}^m;Ab|F!zu3JB3J`SAo)o-uXbOnh||?i5e1;A4FwddQa%(M?yBD? zh}SPaacgwl(5FETC1V7os8BY+Sl|-5>p1PhW8!_QqsHBixH*d!b zFh_9wPiHEnUZ7N*EI4Vz$gPjb==!Bl|6V-ChSD_Qbrqzewio*fO7ovNmn$T6Gp9>f zvL>@`HZRy4BdT8(uyT$kGY~?Fsw{&3C84d0a>d-47O8yAO04^AUhEVX@xGov+>3@! zet;7uCCVx>^ex}7$*EvLb(#Mj>P`wIH}0q_pRXK9-cDNu`nYetG8bTTiXMS#KHn0H zIQhcFs$y4-pY*bXzdAB6%kATN`V+REi+Yt=Fy-Y*aG3VDyiM%*>N{MGm7`up81DWX?rzB(uK675tv3f_KD{nDYb>%wSn(sH ztY3y{>#VWB=z7X_+Uy61DQPfl97PT!p!KImd)LQDryCE_PJ|pFQ3WVaOK%XP_FcvA zNxs5BA9!~~!qe|oKs?0p7q_C;$Tj z%>75kbT~M!{jtxL*sk(TOVKHv`GSQbKdoQ5&>z6q?6#r&}_GaL#X6u$FbOv;PdZ zfP)S$(D>@I#jDF&q)v0MA|)W3tCxHc*Px0PX{X&^!h)PjB@CNB9cRSL7amodH|MtP zhyW&u>_~pWs+kBr#lZazT|$XP5I%3~c&Zv8&k?Kc@gu@i+*OQOw%p@W$*JTHd%LTE z1;;?F*)fI~Fh@!k+gpz=-;dpXu9su%vKY)F&f>lfL_Ul9Pc5V&*?$)p|IOc_Cwxm@ zU|;rIIZ^jL-G-!1pXI8FbKYsjHbAmoW=-xN54H`z0_#K@AWeO|=u42SC*Wka{V~S3 zyzk&C8u8RpqSGck{ay5hef0YX+nsWAr-)@G8K{Ok!7PYHRo|+O464%hkgt^GH7~b4 zV(*w#09ByZ5)(p-{YbLw5cG2HrVZv8F`JX00s*_}mrDQ8ZL$C34X^gG^4udD17%Y< ztgOQghQ0u^((mp??XP2F!X}YU0{~b+lK73w7t5rH!`hMH_OJYxom;mPb>Z2Q_G(7}h%dLLRHurwJhgDz@p*SZY$1pb_V-r~Wk#I-^fZ+iP8Zig z;~o3?-(dpf?c8u2eK>$CuR()>Dmo{pXv($19}i>h9COH<3~|z$#erLa99+E;JM+7`*hzT|<=9HTSftnFpEyA(w3zyBecomtOi17%!B zkR&ok`(q3(LKw(PFjSY0bM=iho!9q%)m%6y}pH zG))l<$pdN~L};qIF75{2b@(#r zAsG@A=htw`?jXr88sV>(5&OWMhu-d}oEh<6)0ua;UBVJierX|do_hz|BC(c|hTFjQ ztBJ&}?=*Dv0REzN^cn!9uYK01WsUDN6Usd0^tf!bY~r$ZLfTbCjXD7jJT5H`bHm7g zdpH)Iyz)c-B_%(xG_l!1T3AL5HYxtFs;@JQ4?`Np0HvgivCysjJl{E=+-$MD(b(#8 zR>=J;3Tyzr*goQl!@bCZDaH5NRH+hH2{=m!{vi<{Odg6uNyN6r2Y7A9%<68Q3gu#n7Is03187l^o7 zszmjoX-jYyo|vjPb6SpSuI93ehWrok2jzg#s3iM|3-~)>Xk3ja-K8hZL`3X6`HZ?% z_r|F0^wRz^oR;Mvzjbm&O3p)6JEXo{89J(D#rPqQ3rz+oqA{3Y7=|mD<<`|?dMzIb zmg3;t;#+2`!U%D$vk!==imWT^r{>EHrrn-gKEPl-_F$(!%n9#-5HK-wmsPulI3<>L9Cr}p_|1_tQ2@E(IxtN$yedvxeq0MpbjK8THVoNiF0g;veAZr`zW zeE%gvh|C=t__P`x$}CSVjb|9Lrx#Ud*Mi|HDb_k1A$=WU5GBw&L2D^cu{T9LKemx; z)SJVvOxaY%Y?bHv?jW9%${0foZ4AmXR6}8kK6_i?|lBm#jUai&jikI zdUtO&3}rp8Qi7he%1Hr@%$l3Tz^~tf#CWDG1d>qiLo9Pi?A>_(mZ{h;QEyLYFx6e>n3)X#9?97a`>50 z3{<9I^WQm|j7R~F7xAyX$!Ak44@Gb}JoF~Mj|O(@LcG^XMmw5)lmM^(zgR4f`0RpA z%6R#k<4wIkY>RTbb3zi?r2I{6#C!@@?8Mqpi_5Zfutf*eWk|udya8Ku^NkN6mPb(? zBUp`kXlQw(zIgp{*L6%$D2~clczcKnsrw$!j44J2EJEk_mP7i^W$GcX7bX=6CR`h# z>=~zNWQ?EH-C^l#nKKRFmc8H0K?TESDkYqZZT7AzL&mpymAG#7m+j|~07GI5 zl~a7zA-4vmU&A+(=a6$AmHpxBU%G$;-ck}+mM-7S;}Z9XEII1}?}+$OF!*sxY1nk9 zcDnCm%{hc=UlvKeSr!}sfx3hmpygDH*q2&47j8uniWX-6tAxKrh*K#77AyRkMB53( z$bb`~imWK#L31bDJH5#mA#rHXgqt-TL^!TeE+9Ig?>^HVSv%@06vyQlYO@ziDv~57 zYPy#$mlwT2LOQsKD1l@+wlbm%TF2siD}-Ra#$&X&|BVKS_c5A#Vd~4F@vM{#`-YVdXaeh+Hga?pW7VN6Mmw zj-U5V3=LCiE7h%4Umd)QY5#6tN%IN3NSn;q?M+0e>q4^Qw{tXbOWOy;dP~M9Iycx7 zX1gvhy@cf&u!AmpyA}(0pSuL7X{im#=~FA)ahnCbj|}20`iMF7m!=VaZA^s2h@4&( z4u9u>yC2#f*)Jjp$^j&K4nUHCQT-1H0|4|N&7rs~RIY4&dpz&ADf1aORJRiK@YaIA zS7qIh!+)iU3eNNRlrc>C0bk(-HfaXPTgTLno%#(rvxR$L+N*q&s(Zocj79XLUt|D10|hK)eHiW%{(n5cMbC1 z;Mx=(%w(4agqGkqv`WfHXoW{R5SLM>VUudfFXc;coi6kI7MfKeBqg{#^ymOcBHRf; z|K9ENj$p1wIbxrXI3T(3$%K9D6z0zXuozAD)gj&UmX-2%qx0PH=pQ$LBMU}%C;e95 z##irPFPKOlMT)Tu8-UZqloKCG;sE$xh_5-VP>si2+Q3jy7Rflr6uP~N3jjdtkU{2V zb4}crLtRr!kX|{+=%v)BXqny}^#mCQ1h1hrcmd=;SHcokMsGO|AFh`D1sZNU2u_Kk zciO8MR36hD5R3*JZb4RRW45g413jgZNYQU)By|n?Y6;#3Om1&U*}|EYBX9$!tR!~g z0tF=S%V;*l%2*bAeEIUE$_4Q}%HPEIX-)+TDCak;U%k76eTrX4*;#Vsoz=@F-`b(y z+*xmRKP=7?kN{fVQsm$b+K{M5OzLw$wP;DmL#9Rrm3pum7vhxwE|T@tC}5Eb>`j&( z&gJgrQ00yZ&IG$bgJf55H067s12TUPA!%ush7-Zb^C4c#x$c_8*w{;!9R%9@F-hbf zM+ckz6)ef?e@KG=(R01DGt-Iwp#8y*)w^dnnP5+=Bp<-doUR40iB7t?7&OCGe1eT< zTDZ~g=^=A+NO|D&N-3-j5bnQTItxr+)14fZMt9=wmgxA$#p^9;ICph-7DS=W!}Bc{ zF+{#n$E)kcxn7Ngn=sL#>?xlpeATPy7}-w4VJXQf!!)CZB_pG8pN~8;4A`|N49Wpf zERoI_h`2CHNaE|;J$3-%92K0gkLD~qd@A%r3q<{8%(h`QE|rVm6+_p$M|(!!H0Pw( z?0(47;AHbECZr|~7p7TKXeczmaf4uEYMDX|Dp8OKOL>(p{y0!u@IL{y!~`%8j>Qra zb4)5oBmF@&F9cMNJ1XlaX`U_5w#xZ+`9+my|3P~W;$VKg`7{)!%jseV$J z$08Z8C9=TAsb+toz=NKW|0TfSa``PK4Q9S%L!83ehgl~D{nBc(l`z{Za3~*)2O$U< zXI369Ndshy0GQp&ctFbEHOIS{1Q1bP?KJDA?}}a8)j|NlQqeFzL4@wdy`M0qJ;&^Q zfH#D6+V2aFol#_i`)F|h`%HmM)d&`8R4=%em{8I3o%a6QqTc8L&S%#Vm;Xu6Q=NxR zIFuGLR)cdg7s>emz5%K!1(C{(il;CRp=q}Ko(bCzHFM--QP7nphH;?q+1XreT-BRu z4`$v{ngv66D(;kmY@ z*~7duEvGStHtjS&^Evff^|MX)@JuoZEB8xGa1{|cYQZNX9aK{7N4!wU@&=^fmZ_lm z&taNKX=xM^Qa5RYtr+Z}&MkOB|Iv#a8l_AVq|^>wm3K50G84phce$37w&tF#u6}!M zPC^w)J3{*FBy)xVb(rdU#PpcPu%2IkQ=1Rx%QvNLq(gmR5|`TU>hty&+c!GU8KNRBPINvLd)i$~Z!tbaO`a zcQSii$Da{+g2~n^S+e2j>wwOzU0HgIl6krM3M=4;I5)M_5ZK>An3Hx7u=5R2sCO`` z*SGG>)eKqZ=SSXIf)<>m;M_afUhQ8>PU35Y4L2EX+ht<91j(+mZq_R-|{C~%a-k_i9b4)WyJkInQ?hFO9zide(+<& zfeZf)Ib#Z_k2gZLqVUAzxtplMUCZku5R_qZ|KS<*;60QH7rUu5^+tq9>6RJP82m(+ z-Wh*Z?@h?YGlif<2#ijsW5MvgKYL0OB3dhi?~21(9K~_$_#^XFE&omxRsubeU;9fH z^)+Kx%)CbAcOc)MpteIUGh0eK$9FClN5+7OCV3R^RoZ^%#-l4ht`3^B4!p6LV?-Ci4;6a=9ITCtJp zNtD+oKIi4D@&*+s&YuanmE9zKDh3<2jFl&MDoqb?Swb%B0^pe<8CM`q#__IOP{_aWyM&#=S&C5&3RI-iZ-Elq5}z(Rjl>_vtn z8=Jd;cknf!>urE1VHxnqcjc?0F*)%FN@Ifj(Qyc+!_f_2mx7rGD*3EBuF4oVVA#0w zhfKnkrNP_GCBO=Z53sD9miV%mFq=|;KtQj2MggTleVwp%ih*i2A$+{~4mlQbB}8YN zTutp>f^E9p{o+h>{fMg0R`7A3n$D$YToA!!yFmCY%B!wJV*gRVi;{t6a!rc~MYj=3 zu2e?HKU{;(N&UnIWxI22&5QTb$>{CwMGcz1U7ah1=C?M^imx5s5cNtnIMs`aHXg9s zIabs9%t)x%+fgE}U}vJ*P^qv>yQzN4lr@brwaZ^)b{!$2;is6hiHhcUVoS`2xK<>Y z=dQuOT`x@wpOVP#enku?x94CiWuZEf^7lFZPNhm}+MC)SjxZ?5KV^QW{ikw!=&OlP z0`;wk*Ib9f7oanFtLcr_)-7C343gZ{klsea{$@5Io z)YbZho~EC08cYtPwFxnAJxEB7j8AUf%xk_Mr@n_lsPYACC6PQVhQApy#tG3IsTM2# zW@KHhLG7jEuFtj?f9F)avDKPZwr2Y<*<2=RsU2}k4{xy4;3(O2+yhu0&XYCILpxeH zl{Boo^*9!F{1UD9bj4UsXUg>T>WF%1r?Mui-z716lJL4~!=`d{ zScZ8#0xRls8Oy)r^MZ2g^5RAEc|yV?8T>h&*ktAzLItZaLuUQcI?a;r2AU3;;Mr4% z`5;A5_%xHrba#7sQJ-XDZTd?*@2%bwS?tMQknV_1(T(U*z+&Dp=kxHUlYXg-SoUq? z3v1qtj7JcT$WGJ(*q&~ng%~eONCF9g#&g>BM`3R>MP{H2eIWZ_DVHj&^oeM8U`Ta25nI^`bcKtOxT_>su9I43*Q{=5Z7CJ4L7X zr(2h6KA0@w=aPc+38{Za<>$3~n2Lt$gUjcepr*0G8{NqlLGv5HfSL8J5H-;nO$3&t z2iA;C?NM0-z>q}YikCi6mDCA2NRZ4wzO3Y1ds zQf1EZ71uZ|k>eCGUwzJOE`RV$@sq3jt}ubvIP(pa?D zNGGNuEh66@!G*z7ZCydL)b|SQ@lb&u1@#9w4{sZCCWD3ROaN`zn6nesDVfkVo^_?; z03%guW?Y&zCqJE0(ypWbr_Q*0m_rFO=-kwD8;=i}u>^aD+FRV3+WNW)U3VOh%UQVe z)MC}I#$C+AA>F`N{$Gfc0_jAA*?^T>&6#W9rsntwq4L*sD?-^>Y>VS^#=ihzq4XDF z!Oa1yxA<~$4=clMhWTl-l}=>V!3ziU3sWR64MiM1c2C;9ooU99D-a;dwrb&C=53M-)o!T*x z3QF_kV=~ALUdW*641R4e<731N5
  • s4`b!f0B`)B;b)s4 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 E0FAJ>5dZ)H literal 0 HcmV?d00001 diff --git a/docs/providers/README.md b/docs/providers/README.md index 109c47a4..ec1e0510 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -14,6 +14,7 @@ For the architectural picture, see `../architecture.md`. | [Cline](cline.md) | JSON | `src/providers/cline.ts` | `tests/providers/cline.test.ts` | | [Codex](codex.md) | JSONL | `src/providers/codex.ts` | `tests/providers/codex.test.ts` | | [Copilot](copilot.md) | JSONL | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` | +| [Devin](devin.md) | JSON + SQLite enrichment | `src/providers/devin.ts` | `tests/providers/devin.test.ts` | | [Droid](droid.md) | JSONL | `src/providers/droid.ts` | `tests/providers/droid.test.ts` | | [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none | | [IBM Bob](ibm-bob.md) | JSON | `src/providers/ibm-bob.ts` | `tests/providers/ibm-bob.test.ts` | diff --git a/docs/providers/devin.md b/docs/providers/devin.md new file mode 100644 index 00000000..04c1cb43 --- /dev/null +++ b/docs/providers/devin.md @@ -0,0 +1,175 @@ +# Devin + +Cognition Devin CLI local usage tracking. + +- **Source:** `src/providers/devin.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/devin.test.ts` (336 lines) + +## Where it reads from + +Devin CLI data lives under: + +```text +~/.local/share/devin/cli/ +``` + +The MVP usage source is transcript JSON: + +```text +~/.local/share/devin/cli/transcripts/*.json +``` + +The provider also reads: + +```text +~/.local/share/devin/cli/sessions.db +``` + +`sessions.db` is enrichment only. It supplies project path/name, model fallback, +timestamp fallback, and hidden-session filtering. It is not the source of usage +or billing. + +## Configuration + +Devin reports spend in ACUs. CodeBurn reports provider cost through `costUSD`, +so Devin stays disabled until a positive finite ACU-to-USD rate is configured: + +```json +{ + "devin": { + "acuUsdRate": 2.25 + } +} +``` + +The config file is: + +```text +~/.config/codeburn/config.json +``` + +The macOS Settings window writes this value from the Devin tab. There is no +environment-variable override and no default rate. Do not hardcode a universal +ACU price; Devin ACU pricing is account/contract dependent. + +When the rate is missing or invalid, `discoverSessions()` returns `[]` and the +parser yields no calls. Devin remains registered as a provider, but it does not +appear in CLI/UI results until configured. + +## Storage format + +Transcript root is a JSON object following the [ATIF-v1.4 trajectory schema][atif], +with Devin-specific additions such as per-step `metadata`. The parser does not +validate `schema_version`; it only requires a parseable object with `steps[]`. + +Core fields include `session_id`, `agent.model_name`, and `steps[]`. + +Each counted step can provide: + +- `step_id` +- `metadata.committed_acu_cost` +- `metadata.metrics.input_tokens` +- `metadata.metrics.output_tokens` +- `metadata.metrics.cache_creation_tokens` +- `metadata.metrics.cache_read_tokens` +- `metadata.created_at` +- `metadata.generation_model` +- `metadata.request_id` +- `tool_calls[].function_name` + +User-input steps (`metadata.is_user_input === true`) are skipped. Non-user +steps are included only if they have positive ACU usage or positive token usage. + +## Pricing + +`metadata.committed_acu_cost` is per step, not cumulative. The provider converts +each step with: + +```text +costUSD = committed_acu_cost * devin.acuUsdRate +``` + +Token-only steps are still included when they have positive token metrics, but +their `costUSD` is `0` if `committed_acu_cost` is absent. + +`src/parser.ts` preserves Devin's provider-supplied `costUSD` instead of +re-pricing it through LiteLLM. + +## sessions.db enrichment + +The provider currently reads these columns from `sessions`: + +| Column | Use | +| ------------------- | ----------------------------------------------------------------------------------------------------------- | +| `id` | join key with transcript `session_id` during parsing; discovery uses the transcript filename before `.json` | +| `working_directory` | `projectPath` and derived project name | +| `model` | model fallback | +| `title` | project name fallback | +| `created_at` | timestamp fallback | +| `last_activity_at` | preferred session timestamp fallback | +| `hidden` | skip hidden sessions | + +`message_nodes`, `prompt_history`, and `tool_call_state` are not parsed yet. + +## Timestamps + +Step timestamps come from `metadata.created_at`, falling back to +`sessions.last_activity_at`, then `sessions.created_at`. + +Transcript step timestamps are passed through as ATIF string timestamps. +Numeric normalization is only applied to `sessions.db` timestamps: + +- less than `10_000_000_000`: seconds +- otherwise: milliseconds + +## Model Resolution + +Model names resolve in this order: + +1. `step.metadata.generation_model` +2. `step.model_name` +3. `transcript.agent.model_name` +4. `sessions.model` +5. `devin` + +## Caching + +No provider-level cache. + +The normal session cache stores parsed provider calls, but Devin is always +reparsed by `src/parser.ts` because `sessions.db` can change without the +transcript JSON fingerprint changing. + +## Deduplication + +`devin::` + +The provider name is part of the key via the `devin:` prefix. + +## Quirks + +- The transcript directory has usage; `sessions.db` is enrichment only. +- `committed_acu_cost` is per-generation/per-step ACU usage. Never treat it as cumulative. +- There is no default ACU-to-USD rate. Missing config intentionally hides Devin. +- Hidden sessions from `sessions.db` are skipped in discovery and parsing. +- Tool names come directly from `tool_calls[].function_name`; the provider assumes valid ATIF tool-call records. +- If SQLite is unavailable or `sessions.db` cannot be opened, the provider still parses transcripts without enrichment. + +## When fixing a bug here + +1. First check whether `~/.config/codeburn/config.json` contains a valid + `devin.acuUsdRate`. Without it, no Devin sessions should appear. +2. For usage total bugs, compare against: + + ```bash + jq '[.steps[] | select(.metadata.committed_acu_cost != null) | .metadata.committed_acu_cost] | add' ~/.local/share/devin/cli/transcripts/.json + ``` + +3. If project/model/timestamp metadata is wrong, inspect `sessions.db`, not the transcript. +4. If a hidden session appears, check the `hidden` column. Discovery can only + hide sessions whose transcript filename matches `sessions.id`; parsing uses + the transcript `session_id` when present. +5. Run `tests/providers/devin.test.ts` after parser changes. It covers ACU conversion, disabled-until-configured behavior, timestamp parsing, deduplication, hidden sessions, and `sessions.db` enrichment. + +[atif]: https://github.com/harbor-framework/harbor/blob/main/rfcs/0001-trajectory-format.md diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index e6cf9aa2..aab72636 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -1025,6 +1025,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case cursor = "Cursor" case cursorAgent = "Cursor Agent" case copilot = "Copilot" + case devin = "Devin" case droid = "Droid" case gemini = "Gemini" case ibmBob = "IBM Bob" @@ -1067,6 +1068,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .cursor: "cursor" case .cursorAgent: "cursor-agent" case .copilot: "copilot" + case .devin: "devin" case .droid: "droid" case .gemini: "gemini" case .ibmBob: "ibm-bob" diff --git a/mac/Sources/CodeBurnMenubar/CurrencyState.swift b/mac/Sources/CodeBurnMenubar/CurrencyState.swift index c27acc18..5fc907a6 100644 --- a/mac/Sources/CodeBurnMenubar/CurrencyState.swift +++ b/mac/Sources/CodeBurnMenubar/CurrencyState.swift @@ -207,3 +207,74 @@ enum CLICurrencyConfig { } } } + +struct CodeburnCLIConfigStore { + let homeDirectory: String + + init(homeDirectory: String = NSHomeDirectory()) { + self.homeDirectory = homeDirectory + } + + private var configDir: String { + (homeDirectory as NSString).appendingPathComponent(".config/codeburn") + } + private var configPath: String { + (configDir as NSString).appendingPathComponent("config.json") + } + private var lockPath: String { + (configDir as NSString).appendingPathComponent(".config.lock") + } + + func loadDevinAcuUsdRate() -> Double? { + guard + let data = try? SafeFile.read(from: configPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let devin = json["devin"] as? [String: Any], + let rate = devin["acuUsdRate"] as? Double, + rate.isFinite, + rate > 0 + else { + return nil + } + return rate + } + + func persistDevinAcuUsdRate(_ rate: Double) { + guard rate.isFinite, rate > 0 else { return } + do { + try SafeFile.withExclusiveLock(at: lockPath) { + var existing: [String: Any] = [:] + if let data = try? SafeFile.read(from: configPath), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + existing = parsed + } + + var devin = existing["devin"] as? [String: Any] ?? [:] + devin["acuUsdRate"] = rate + existing["devin"] = devin + + guard let data = try? JSONSerialization.data( + withJSONObject: existing, + options: [.prettyPrinted, .sortedKeys] + ) else { + return + } + try SafeFile.write(data, to: configPath, mode: 0o600) + } + } catch { + NSLog("CodeBurn: failed to persist Devin ACU config: \(error)") + } + } +} + +enum CLIDevinConfig { + private static let store = CodeburnCLIConfigStore() + + static func loadAcuUsdRate() -> Double? { + store.loadDevinAcuUsdRate() + } + + static func persistAcuUsdRate(_ rate: Double) { + store.persistDevinAcuUsdRate(rate) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index d86cf36d..0c0a81f7 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -490,6 +490,7 @@ extension ProviderFilter { case .cursor: return Theme.categoricalCursor case .cursorAgent: return Color(red: 0x4E/255.0, green: 0xC9/255.0, blue: 0xB0/255.0) case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0) + case .devin: return Color(red: 0x25/255.0, green: 0xA0/255.0, blue: 0x8D/255.0) case .droid: return Color(red: 0x7C/255.0, green: 0x3A/255.0, blue: 0xED/255.0) case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0) case .ibmBob: return Color(red: 0x0F/255.0, green: 0x62/255.0, blue: 0xFE/255.0) diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index 73a16be9..53c1755e 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -18,10 +18,13 @@ struct SettingsView: View { CodexSettingsTab() .tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") } + DevinSettingsTab() + .tabItem { Label("Devin", systemImage: "flame.fill") } + AboutSettingsTab() .tabItem { Label("About", systemImage: "info.circle") } } - .frame(width: 520, height: 400) + .frame(width: 520, height: 430) } } @@ -468,6 +471,81 @@ private struct CodexConnectionRow: View { } } +// MARK: - Devin + +private struct DevinSettingsTab: View { + @State private var rateText: String = "" + @State private var statusText: String = "" + + private var parsedRate: Double? { + let trimmed = rateText.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Double(trimmed), value.isFinite, value > 0 else { return nil } + return value + } + + var body: some View { + Form { + Section("ACU Conversion") { + HStack(alignment: .center, spacing: 10) { + Text("USD per ACU") + Spacer() + TextField("", text: $rateText) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + .frame(width: 96) + .accessibilityLabel("USD per ACU") + Text("USD") + .foregroundStyle(.secondary) + .frame(width: 36, alignment: .leading) + } + + Button("Save") { + saveRate() + } + .buttonStyle(.borderedProminent) + .disabled(parsedRate == nil) + + if !statusText.isEmpty { + Text(statusText) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + + Section { + Text("CodeBurn reads Devin ACU usage from local transcripts only after this rate is configured, then multiplies each step by the rate before reporting cost.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } header: { + Text("How it works") + } + } + .formStyle(.grouped) + .padding() + .onAppear { + if let rate = CLIDevinConfig.loadAcuUsdRate() { + rateText = Self.format(rate) + } + } + } + + private func saveRate() { + guard let rate = parsedRate else { return } + CLIDevinConfig.persistAcuUsdRate(rate) + rateText = Self.format(rate) + statusText = "Saved. Refresh CodeBurn to recalculate Devin cost." + } + + private static func format(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 6 + return formatter.string(from: NSNumber(value: value)) ?? String(value) + } +} + // MARK: - About private struct AboutSettingsTab: View { diff --git a/mac/Tests/CodeBurnMenubarTests/CLIDevinConfigTests.swift b/mac/Tests/CodeBurnMenubarTests/CLIDevinConfigTests.swift new file mode 100644 index 00000000..69e9f552 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/CLIDevinConfigTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("CLI Devin config", .serialized) +struct CLIDevinConfigTests { + private func withTemporaryStore(_ body: (URL, CodeburnCLIConfigStore) throws -> Void) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codeburn-devin-config-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: root) + } + try body(root, CodeburnCLIConfigStore(homeDirectory: root.path)) + } + + private func configURL(in home: URL) -> URL { + home + .appendingPathComponent(".config", isDirectory: true) + .appendingPathComponent("codeburn", isDirectory: true) + .appendingPathComponent("config.json") + } + + private func writeConfig(_ object: [String: Any], in home: URL) throws { + let url = configURL(in: home) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: url) + } + + private func readConfig(in home: URL) throws -> [String: Any] { + let data = try Data(contentsOf: configURL(in: home)) + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + @Test("missing config has no ACU rate") + func missingConfigHasNoRate() throws { + try withTemporaryStore { _, store in + #expect(store.loadDevinAcuUsdRate() == nil) + } + } + + @Test("persists and loads ACU rate") + func persistsAndLoadsRate() throws { + try withTemporaryStore { _, store in + store.persistDevinAcuUsdRate(2.25) + + #expect(store.loadDevinAcuUsdRate() == 2.25) + } + } + + @Test("preserves existing config while adding Devin rate") + func preservesExistingConfig() throws { + try withTemporaryStore { home, store in + try writeConfig([ + "currency": [ + "code": "EUR", + "symbol": "\u{20AC}" + ] + ], in: home) + + store.persistDevinAcuUsdRate(3.5) + + let json = try readConfig(in: home) + let currency = try #require(json["currency"] as? [String: Any]) + let devin = try #require(json["devin"] as? [String: Any]) + #expect(currency["code"] as? String == "EUR") + #expect(devin["acuUsdRate"] as? Double == 3.5) + } + } + + @Test("ignores invalid rates") + func ignoresInvalidRates() throws { + try withTemporaryStore { _, store in + store.persistDevinAcuUsdRate(1.75) + store.persistDevinAcuUsdRate(0) + store.persistDevinAcuUsdRate(-2) + store.persistDevinAcuUsdRate(.infinity) + + #expect(store.loadDevinAcuUsdRate() == 1.75) + } + } + + @Test("loads only positive finite numeric rates") + func loadsOnlyPositiveFiniteNumericRates() throws { + try withTemporaryStore { home, store in + try writeConfig(["devin": ["acuUsdRate": 0]], in: home) + #expect(store.loadDevinAcuUsdRate() == nil) + + try writeConfig(["devin": ["acuUsdRate": "2.25"]], in: home) + #expect(store.loadDevinAcuUsdRate() == nil) + } + } +} diff --git a/package.json b/package.json index 73224026..ca499a03 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "bundle-litellm": "node scripts/bundle-litellm.mjs", "build": "node scripts/bundle-litellm.mjs && tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\"", "dev": "tsx src/cli.ts", + "dev:menubar": "npm run build && cd mac && CODEBURN_ALLOW_DEV_BIN=1 CODEBURN_BIN=\"node $(pwd)/../dist/cli.js\" swift run", "test": "vitest", "prepublishOnly": "npm run build" }, @@ -22,6 +23,7 @@ "cursor", "codex", "kimi", + "devin", "ibm-bob", "opencode", "pi", diff --git a/src/config.ts b/src/config.ts index eca65f5a..d94be82b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,6 +24,9 @@ export type CodeburnConfig = { code: string symbol?: string } + devin?: { + acuUsdRate?: number + } plan?: Plan plans?: PlanConfigMap modelAliases?: Record diff --git a/src/parser.ts b/src/parser.ts index 55eb17d9..9dfe3c9d 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1641,7 +1641,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { webSearchRequests: call.webSearchRequests, cacheCreationOneHourTokens: 0, }, - costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity') ? call.costUSD : undefined, + costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin') ? call.costUSD : undefined, speed: call.speed, timestamp: call.timestamp, tools: call.tools, @@ -1812,6 +1812,11 @@ function cachedFileNeedsProviderReparse(providerName: string, sourcePath: string // A 0-turn cache entry may just mean the server was unavailable last run. if (providerName === 'antigravity') return shouldReparseAntigravitySource(sourcePath, cached.turns.length) + // Devin transcript usage is enriched from sessions.db. The cache fingerprint + // only tracks the transcript JSON, so reparse to pick up DB-side project, + // title, model, and timestamp changes. + if (providerName === 'devin') return true + if (providerName !== 'gemini') return false return cached.turns.some(turn => diff --git a/src/providers/devin.ts b/src/providers/devin.ts new file mode 100644 index 00000000..7fd6bec2 --- /dev/null +++ b/src/providers/devin.ts @@ -0,0 +1,395 @@ +import { readdir, stat } from "fs/promises"; +import { basename, join } from "path"; +import { homedir } from "os"; + +import { getShortModelName } from "../models.js"; +import { booleanValue, openDatabase } from "../sqlite.js"; +import { readConfig } from "../config.js"; +import type { + Provider, + SessionParser, + SessionSource, + ParsedProviderCall, +} from "./types.js"; +import { readSessionFile } from "../fs-utils.js"; + +type AgentTrajectory = { + schema_version: string; + session_id?: string; + agent: Agent; + steps: T[]; +}; + +type Agent = { + name: string; + version: string; + model_name?: string; +}; + +type ToolCall = { + tool_call_id: string; + function_name: string; + arguments: unknown; +}; + +type DevinMetadata = { + created_at?: string; + committed_acu_cost?: number; + generation_model?: string; + is_user_input?: boolean; + num_tokens?: number; + request_id?: string; + finish_reason?: string; + metrics?: { + input_tokens?: number; + output_tokens?: number; + cache_creation_tokens?: number; + cache_read_tokens?: number; + tokens_per_sec?: number; + total_time_ms?: number; + ttft_ms?: number; + tpot_ms?: number; + }; +}; + +type Step = { + step_id: number; + source: string; + model_name?: string; + message: string; + tool_calls?: Array; +}; + +type DevinStep = Step & { metadata?: DevinMetadata }; + +type DevinAgentTrajectory = AgentTrajectory; + +type DevinSessionMetadata = { + id: string; + workingDirectory: string; + model: string; + title?: string; + createdAt: string; + lastActivityAt: string; + hidden: boolean; +}; + +type DevinUsage = { + committedAcuCost: number; + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; +}; + +const DEFAULT_DEVIN_CLI_DIR = join( + homedir(), + ".local", + "share", + "devin", + "cli", +); + +const DEFAULT_MODEL_NAME = "devin"; +const DEVIN_PROVIDER_NAME = "devin"; +const DEVIN_PROVIDER_DISPLAY_NAME = "Devin"; +const DEVIN_TRASNCRIPTS_SUBDIR = "transcripts"; +const DEVIN_SESSIONS_DB = "sessions.db"; + +function parseTranscript(raw: string): DevinAgentTrajectory | null { + try { + return JSON.parse(raw) as DevinAgentTrajectory; + } catch { + return null; + } +} + +function parseNumericTimestamp(value: number): string { + const millis = value < 10_000_000_000 ? value * 1000 : value; + return new Date(millis).toISOString(); +} + +function getUsage( + metadata: DevinMetadata | undefined | null, +): DevinUsage | null { + if (!metadata) return null; + const metrics = metadata.metrics; + + const hasAnyUsage = [ + metadata.committed_acu_cost, + metrics?.input_tokens, + metrics?.output_tokens, + metrics?.cache_creation_tokens, + metrics?.cache_read_tokens, + ].some((x) => x !== undefined && x !== null && x > 0); + + if (!hasAnyUsage) return null; + + return { + committedAcuCost: metadata.committed_acu_cost ?? 0, + inputTokens: metrics?.input_tokens ?? 0, + outputTokens: metrics?.output_tokens ?? 0, + cacheCreationInputTokens: metrics?.cache_creation_tokens ?? 0, + cacheReadInputTokens: metrics?.cache_read_tokens ?? 0, + }; +} + +function getSessionId( + source: SessionSource, + transcript: DevinAgentTrajectory, +): string { + const fromTranscript = transcript.session_id?.trim(); + return fromTranscript || basename(source.path, ".json"); +} + +function projectNameFromPath(path: string): string { + const normalized = path.trim().replace(/[/\\]+$/, ""); + return normalized.split(/[/\\]/).filter(Boolean).pop() ?? path; +} + +function getProjectName( + source: SessionSource, + session: DevinSessionMetadata | null, +): string { + if (session?.workingDirectory) + return projectNameFromPath(session.workingDirectory); + if (session?.title) return session.title; + return source.project; +} + +function getProjectPath( + session: DevinSessionMetadata | null, +): string | undefined { + return session?.workingDirectory; +} + +function getTimestamp( + step: DevinStep, + session: DevinSessionMetadata | null, +): string | undefined { + return [ + step.metadata?.created_at, + session?.lastActivityAt, + session?.createdAt, + ] + .filter(Boolean) + .shift(); +} + +function getModelName( + transcript: DevinAgentTrajectory, + step: DevinStep, + session: DevinSessionMetadata | null, +): string { + return ( + [ + step.metadata?.generation_model, + step.model_name, + transcript.agent?.model_name, + session?.model, + ] + .filter(Boolean) + .shift() || DEFAULT_MODEL_NAME + ); +} + +function getToolNames(step: DevinStep): string[] { + return (step.tool_calls ?? []).map((call) => call.function_name); +} + +function getFirstUserMessageBeforeStep( + steps: DevinStep[], + index: number, +): string | null { + for (let i = index - 1; i >= 0; i--) { + const step = steps[i]; + if (!step?.metadata?.is_user_input) continue; + const message = step.message?.trim(); + if (message) return message; + } + return null; +} + +function loadSessionMetadata( + dbPath: string, +): Map { + const sessions = new Map(); + let db: ReturnType | null = null; + try { + db = openDatabase(dbPath); + const rows = db.query<{ + id: string; + working_directory: string; + model: string; + title: string | null; + created_at: number; + last_activity_at: number; + hidden: number; + }>( + `SELECT id, working_directory, model, title, created_at, last_activity_at, hidden + FROM sessions`, + ); + for (const row of rows) { + if (!row.id) continue; + sessions.set(row.id, { + id: row.id, + workingDirectory: row.working_directory, + model: row.model, + title: row.title ?? undefined, + createdAt: parseNumericTimestamp(row.created_at), + lastActivityAt: parseNumericTimestamp(row.last_activity_at), + hidden: booleanValue(row.hidden), + }); + } + } catch { + return sessions; + } finally { + db?.close(); + } + return sessions; +} + +function isValidAcuUsdRate(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +async function getCostFactor(): Promise { + const configRate = (await readConfig()).devin?.acuUsdRate; + return isValidAcuUsdRate(configRate) ? configRate : null; +} + +class DevinSessionParser implements SessionParser { + constructor( + private source: SessionSource, + private seenKeys: Set, + private sessionMetadata: Map, + ) {} + + async *parse(): AsyncGenerator { + const raw = await readSessionFile(this.source.path); + if (!raw) return; + + const transcript = parseTranscript(raw); + if (!transcript?.steps) return; + + const sessionId = getSessionId(this.source, transcript); + const session = this.sessionMetadata.get(sessionId) ?? null; + if (session?.hidden) return; + + const project = getProjectName(this.source, session); + const projectPath = getProjectPath(session); + const costFactor = await getCostFactor(); + if (costFactor === null) return; + + for (let index = 0; index < transcript.steps.length; index++) { + const step = transcript.steps[index]; + if (step.metadata?.is_user_input) continue; + + const usage = getUsage(step.metadata); + if (!usage) continue; + + const timestamp = getTimestamp(step, session) ?? ""; + + const deduplicationKey = `devin:${sessionId}:${step.step_id}`; + + if (this.seenKeys.has(deduplicationKey)) continue; + this.seenKeys.add(deduplicationKey); + + const model = getModelName(transcript, step, session); + const tools = getToolNames(step); + const userMessage = + getFirstUserMessageBeforeStep(transcript.steps, index) ?? ""; + + yield { + provider: DEVIN_PROVIDER_NAME, + model, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheCreationInputTokens: usage.cacheCreationInputTokens, + cacheReadInputTokens: usage.cacheReadInputTokens, + cachedInputTokens: usage.cacheReadInputTokens, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: usage.committedAcuCost * costFactor, + tools, + bashCommands: [], + timestamp, + speed: "standard", + deduplicationKey, + userMessage, + sessionId, + project, + projectPath, + }; + } + } +} + +export function createDevinProvider(cliDir: string): Provider { + const sessionsDbPath = join(cliDir, DEVIN_SESSIONS_DB); + let sessionMetadata: Map | null = null; + + const getSessionMetadata = () => { + if (!sessionMetadata) sessionMetadata = loadSessionMetadata(sessionsDbPath); + return sessionMetadata; + }; + + return { + name: DEVIN_PROVIDER_NAME, + displayName: DEVIN_PROVIDER_DISPLAY_NAME, + + modelDisplayName(model: string): string { + return getShortModelName(model); + }, + + toolDisplayName(rawTool: string): string { + return rawTool; + }, + + async discoverSessions(): Promise { + if ((await getCostFactor()) === null) return []; + + const transcriptsDir = join(cliDir, DEVIN_TRASNCRIPTS_SUBDIR); + const entries = await readdir(transcriptsDir).catch(() => []); + const metadata = getSessionMetadata(); + const sources: SessionSource[] = []; + + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + + const filePath = join(transcriptsDir, entry); + const pathStats = await stat(filePath).catch(() => null); + + if (!pathStats?.isFile()) continue; + + const session = metadata.get(basename(filePath, ".json")) ?? null; + if (session?.hidden) continue; + + const tmpSource: SessionSource = { + path: filePath, + project: DEVIN_PROVIDER_NAME, + provider: DEVIN_PROVIDER_NAME, + }; + + const project = getProjectName(tmpSource, session); + + sources.push({ + path: filePath, + project, + provider: DEVIN_PROVIDER_NAME, + }); + } + + return sources; + }, + + createSessionParser( + source: SessionSource, + seenKeys: Set, + ): SessionParser { + return new DevinSessionParser(source, seenKeys, getSessionMetadata()); + }, + }; +} + +export const devin = createDevinProvider(DEFAULT_DEVIN_CLI_DIR); diff --git a/src/providers/index.ts b/src/providers/index.ts index 98980a62..cadcb086 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -4,6 +4,7 @@ import { codebuff } from './codebuff.js' import { codex } from './codex.js' import { copilot } from './copilot.js' import { droid } from './droid.js' +import { devin } from './devin.js' import { gemini } from './gemini.js' import { ibmBob } from './ibm-bob.js' import { kiloCode } from './kilo-code.js' @@ -136,7 +137,7 @@ async function loadCrush(): Promise { } } -const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode] +const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode] export async function getAllProviders(): Promise { const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp] = await Promise.all([loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp()]) diff --git a/src/session-cache.ts b/src/session-cache.ts index 53f7a78c..85a5171a 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -375,4 +375,3 @@ export async function cleanupOrphanedTempFiles(): Promise { } } catch {} } - diff --git a/src/sqlite.ts b/src/sqlite.ts index 3fb3c6a8..eee5443d 100644 --- a/src/sqlite.ts +++ b/src/sqlite.ts @@ -137,3 +137,7 @@ export function openDatabase(path: string): SqliteDatabase { }, } } + +export function booleanValue(value: number): boolean { + return value === 1 +} diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index f310a564..c531d6ae 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -3,7 +3,7 @@ import { providers, getAllProviders, getProvider } from '../src/providers/index. describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'mux', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) + expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'devin','droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'mux', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) }) it('codebuff tool display names normalize codebuff-native names to canonical set', () => { diff --git a/tests/providers/devin.test.ts b/tests/providers/devin.test.ts new file mode 100644 index 00000000..5197140f --- /dev/null +++ b/tests/providers/devin.test.ts @@ -0,0 +1,336 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { isSqliteAvailable } from '../../src/sqlite.js' +import { createDevinProvider } from '../../src/providers/devin.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string +const originalHome = process.env['HOME'] + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'devin-provider-')) + process.env['HOME'] = tmpDir +}) + +afterEach(async () => { + if (originalHome === undefined) delete process.env['HOME'] + else process.env['HOME'] = originalHome + await rm(tmpDir, { recursive: true, force: true }) +}) + +async function configureDevinRate(rate = 1): Promise { + await mkdir(join(tmpDir, '.config', 'codeburn'), { recursive: true }) + await writeFile(join(tmpDir, '.config', 'codeburn', 'config.json'), JSON.stringify({ + devin: { acuUsdRate: rate }, + })) +} + +async function writeTranscript(name: string, transcript: unknown): Promise { + const transcriptsDir = join(tmpDir, 'transcripts') + await mkdir(transcriptsDir, { recursive: true }) + const filePath = join(transcriptsDir, name) + await writeFile(filePath, JSON.stringify(transcript)) + return filePath +} + +async function parseTranscript(filePath: string, project = 'devin'): Promise { + const provider = createDevinProvider(tmpDir) + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser({ path: filePath, project, provider: 'devin' }, new Set()).parse()) { + calls.push(call) + } + return calls +} + +function createSessionsDb(): void { + const { DatabaseSync: Database } = require('node:sqlite') + const db = new Database(join(tmpDir, 'sessions.db')) + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + working_directory TEXT, + backend_type TEXT, + model TEXT, + agent_mode TEXT, + created_at INTEGER, + last_activity_at INTEGER, + title TEXT, + hidden INTEGER NOT NULL DEFAULT 0 + ) + `) + db.prepare(` + INSERT INTO sessions (id, working_directory, model, created_at, last_activity_at, title, hidden) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('db-session', '/Users/example/work/codeburn', 'claude-sonnet-4-6', 1_800_000_000, 1_800_000_010, 'CodeBurn', 0) + db.prepare(` + INSERT INTO sessions (id, working_directory, model, created_at, last_activity_at, title, hidden) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('hidden-session', '/Users/example/work/hidden', 'claude-opus-4-6', 1_800_000_000, 1_800_000_010, 'Hidden', 1) + db.close() +} + +describe('devin provider', () => { + it('discovers Devin CLI transcript json files', async () => { + await configureDevinRate() + const filePath = await writeTranscript('glimmer-platinum.json', { steps: [] }) + await writeFile(join(tmpDir, 'transcripts', 'ignore.txt'), '{}') + + const provider = createDevinProvider(tmpDir) + const sources = await provider.discoverSessions() + + expect(sources).toEqual([ + { path: filePath, project: 'devin', provider: 'devin' }, + ]) + }) + + it('stays disabled until the Devin ACU rate is configured', async () => { + await writeTranscript('glimmer-platinum.json', { + session_id: 'session-123', + steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.5 } }], + }) + + const provider = createDevinProvider(tmpDir) + expect(await provider.discoverSessions()).toEqual([]) + expect(await parseTranscript(join(tmpDir, 'transcripts', 'glimmer-platinum.json'))).toEqual([]) + }) + + it('parses per-step ACUs, tokens, tools, and model resolution', async () => { + await configureDevinRate() + const filePath = await writeTranscript('glimmer-platinum.json', { + schema_version: '1', + session_id: 'session-123', + agent: { model_name: 'agent-model' }, + steps: [ + { + step_id: 1, + message: 'please inspect the repo', + metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' }, + }, + { + step_id: 2, + model_name: 'step-model', + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.02076149918138981, + generation_model: 'claude-opus-4-6', + metrics: { + input_tokens: 100, + output_tokens: 20, + cache_creation_tokens: 10, + cache_read_tokens: 5, + }, + }, + tool_calls: [{ function_name: 'read_file' }], + }, + { + step_id: 3, + model_name: 'claude-sonnet-4-6', + metadata: { + created_at: '2027-01-15T08:00:02.000Z', + committed_acu_cost: 0.005421000067144632, + metrics: { input_tokens: 1 }, + }, + tool_calls: [{ function_name: 'str_replace' }], + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(2) + expect(calls.reduce((sum, call) => sum + call.costUSD, 0)).toBeCloseTo(0.026182499248534442, 15) + expect(calls[0]).toMatchObject({ + provider: 'devin', + model: 'claude-opus-4-6', + inputTokens: 100, + outputTokens: 20, + cacheCreationInputTokens: 10, + cacheReadInputTokens: 5, + cachedInputTokens: 5, + costUSD: 0.02076149918138981, + tools: ['read_file'], + timestamp: '2027-01-15T08:00:01.000Z', + deduplicationKey: 'devin:session-123:2', + userMessage: 'please inspect the repo', + sessionId: 'session-123', + }) + expect(calls[1]).toMatchObject({ + model: 'claude-sonnet-4-6', + timestamp: '2027-01-15T08:00:02.000Z', + tools: ['str_replace'], + deduplicationKey: 'devin:session-123:3', + }) + }) + + it('includes token-only steps and skips user-input or empty steps', async () => { + await configureDevinRate() + const filePath = await writeTranscript('token-only.json', { + session_id: 'token-session', + agent: { model_name: 'agent-model' }, + steps: [ + { + step_id: 'user-cost', + metadata: { + is_user_input: true, + committed_acu_cost: 99, + metrics: { input_tokens: 99 }, + }, + }, + { step_id: 'empty', metadata: { created_at: '2026-06-05T10:00:00.000Z' } }, + { + step_id: 'tokens', + metadata: { + created_at: '2026-06-05T10:00:01.000Z', + metrics: { output_tokens: 42 }, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('agent-model') + expect(calls[0]!.outputTokens).toBe(42) + expect(calls[0]!.costUSD).toBe(0) + }) + + it('converts ACUs to costUSD using the configured Devin rate', async () => { + await configureDevinRate(2.5) + const filePath = await writeTranscript('configured-rate.json', { + session_id: 'configured-rate', + agent: { model_name: 'agent-model' }, + steps: [ + { step_id: 's1', metadata: { committed_acu_cost: 0.4 } }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeCloseTo(1, 12) + }) + + it('falls back to filename session id and deduplicates by step id', async () => { + await configureDevinRate() + const filePath = await writeTranscript('fallback-session.json', { + steps: [ + { + step_id: 1, + metadata: { + request_id: 'req-1', + committed_acu_cost: 0.1, + }, + }, + { + step_id: 2, + metadata: { + created_at: '2026-06-05T10:00:00.000Z', + committed_acu_cost: 0.2, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls.map(c => c.sessionId)).toEqual(['fallback-session', 'fallback-session']) + expect(calls.map(c => c.model)).toEqual(['devin', 'devin']) + expect(calls.map(c => c.deduplicationKey)).toEqual([ + 'devin:fallback-session:1', + 'devin:fallback-session:2', + ]) + }) + + it('ignores array-root and malformed transcripts', async () => { + await configureDevinRate() + const arrayPath = await writeTranscript('array.json', []) + const malformedPath = join(tmpDir, 'transcripts', 'bad.json') + await writeFile(malformedPath, '{') + + expect(await parseTranscript(arrayPath)).toEqual([]) + expect(await parseTranscript(malformedPath)).toEqual([]) + }) + + it('deduplicates calls with a shared seen key set', async () => { + await configureDevinRate() + const filePath = await writeTranscript('dupe.json', { + session_id: 'dupe-session', + steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.5 } }], + }) + const provider = createDevinProvider(tmpDir) + const seenKeys = new Set() + const source = { path: filePath, project: 'devin', provider: 'devin' } + + const first: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) first.push(call) + const second: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) second.push(call) + + expect(first).toHaveLength(1) + expect(second).toHaveLength(0) + }) +}) + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +skipUnlessSqlite('devin provider sessions.db enrichment', () => { + it('uses sessions.db to enrich project, projectPath, model, and timestamp fallbacks', async () => { + await configureDevinRate() + createSessionsDb() + const filePath = await writeTranscript('db-session.json', { + session_id: 'db-session', + steps: [ + { + step_id: 's1', + metadata: { + committed_acu_cost: 0.25, + metrics: { input_tokens: 10 }, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath, 'fallback-project') + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + model: 'claude-sonnet-4-6', + project: 'codeburn', + projectPath: '/Users/example/work/codeburn', + timestamp: '2027-01-15T08:00:10.000Z', + costUSD: 0.25, + }) + }) + + it('uses sessions.db project labels during discovery when transcript filename matches the session id', async () => { + await configureDevinRate() + createSessionsDb() + const filePath = await writeTranscript('db-session.json', { session_id: 'db-session', steps: [] }) + + const provider = createDevinProvider(tmpDir) + const sources = await provider.discoverSessions() + + expect(sources).toEqual([ + { path: filePath, project: 'codeburn', provider: 'devin' }, + ]) + }) + + it('skips sessions hidden in sessions.db', async () => { + await configureDevinRate() + createSessionsDb() + await writeTranscript('hidden-session.json', { + session_id: 'hidden-session', + steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.25 } }], + }) + + const provider = createDevinProvider(tmpDir) + expect(await provider.discoverSessions()).toEqual([]) + + const calls = await parseTranscript(join(tmpDir, 'transcripts', 'hidden-session.json')) + expect(calls).toEqual([]) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index e1536101..3f1cfb9f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,8 @@ "rootDir": "src", "declaration": true, "sourceMap": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "tests"] From 072598bbfc8a14a8ada84e66e0ba0324ca73c352 Mon Sep 17 00:00:00 2001 From: Tiago Santos Date: Sat, 6 Jun 2026 08:54:52 +0100 Subject: [PATCH 2/3] feat: add devin to gnome extension and improve dev scripts --- gnome/indicator.js | 2 + gnome/prefs.js | 1 + package.json | 3 +- scripts/install-local-mac-menubar.sh | 122 +++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100755 scripts/install-local-mac-menubar.sh diff --git a/gnome/indicator.js b/gnome/indicator.js index 533f6441..19c13ac5 100644 --- a/gnome/indicator.js +++ b/gnome/indicator.js @@ -35,6 +35,7 @@ const PROVIDERS = [ { id: 'codex', label: 'Codex' }, { id: 'cursor', label: 'Cursor' }, { id: 'copilot', label: 'Copilot' }, + { id: 'devin', label: 'Devin' }, { id: 'opencode', label: 'OpenCode' }, { id: 'pi', label: 'Pi' }, { id: 'droid', label: 'Droid' }, @@ -70,6 +71,7 @@ const PROVIDER_PATHS = { codex: '.codex/sessions', cursor: '.config/Cursor/User/globalStorage/state.vscdb', copilot: '.copilot/session-state', + devin: '.local/share/devin/cli', kimi: '.kimi/sessions', pi: '.pi/agent/sessions', }; diff --git a/gnome/prefs.js b/gnome/prefs.js index 08d4b824..dafc1a2f 100644 --- a/gnome/prefs.js +++ b/gnome/prefs.js @@ -8,6 +8,7 @@ const PROVIDERS = [ { id: 'codex', label: 'Codex' }, { id: 'copilot', label: 'Copilot' }, { id: 'cursor', label: 'Cursor' }, + { id: 'devin', label: 'Devin' }, { id: 'droid', label: 'Droid' }, { id: 'gemini', label: 'Gemini' }, { id: 'goose', label: 'Goose' }, diff --git a/package.json b/package.json index ca499a03..f4e7208e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "bundle-litellm": "node scripts/bundle-litellm.mjs", "build": "node scripts/bundle-litellm.mjs && tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\"", "dev": "tsx src/cli.ts", - "dev:menubar": "npm run build && cd mac && CODEBURN_ALLOW_DEV_BIN=1 CODEBURN_BIN=\"node $(pwd)/../dist/cli.js\" swift run", + "dev:mac:menubar": "npm run build && cd mac && CODEBURN_ALLOW_DEV_BIN=1 CODEBURN_BIN=\"node $(pwd)/../dist/cli.js\" swift run", + "local:mac:menubar": "scripts/install-local-mac-menubar.sh", "test": "vitest", "prepublishOnly": "npm run build" }, diff --git a/scripts/install-local-mac-menubar.sh b/scripts/install-local-mac-menubar.sh new file mode 100755 index 00000000..0c8391f8 --- /dev/null +++ b/scripts/install-local-mac-menubar.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Build and install a local CodeBurn CLI tarball, then launch the menubar app +# built from this checkout. Useful when upstream macOS releases lag behind a +# fork/branch you want to test. + +set -euo pipefail + +REPLACE_APP=0 +SYSTEM_APP=0 + +usage() { + cat <<'USAGE' +Usage: + npm run local:mac:menubar -- [--replace-app] [--system-app] + +Options: + --replace-app Replace the installed app with the locally built app before launching. + --system-app Install to /Applications/CodeBurnMenubar.app instead of ~/Applications/CodeBurnMenubar.app. + -h, --help Show this help. +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --replace-app) + REPLACE_APP=1 + shift + ;; + --system-app) + SYSTEM_APP=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +repo_root() { + git rev-parse --show-toplevel 2>/dev/null || (cd "$(dirname "$0")/.." && pwd) +} + +ROOT="$(repo_root)" +PACK_DIR="${TMPDIR:-/tmp}/codeburn-local-pack" +SUPPORT_DIR="${HOME}/Library/Application Support/CodeBurn" +PERSISTED_CLI_PATH="${SUPPORT_DIR}/codeburn-cli-path.v1" +if [[ "${SYSTEM_APP}" -eq 1 ]]; then + INSTALLED_APP_PATH="/Applications/CodeBurnMenubar.app" +else + INSTALLED_APP_PATH="${HOME}/Applications/CodeBurnMenubar.app" +fi + +cd "${ROOT}" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "This script is for the macOS menubar app." >&2 + exit 1 +fi + +echo "==> Building CLI" +npm run build + +echo "==> Packing CLI" +rm -rf "${PACK_DIR}" +mkdir -p "${PACK_DIR}" +TARBALL_NAME="$(npm pack --silent --pack-destination "${PACK_DIR}")" +TARBALL_PATH="${PACK_DIR}/${TARBALL_NAME}" + +echo "==> Installing global CLI from ${TARBALL_PATH}" +npm install -g "${TARBALL_PATH}" + +CLI_PATH="$(command -v codeburn || true)" +if [[ -z "${CLI_PATH}" ]]; then + echo "Global codeburn command was not found after npm install -g." >&2 + exit 1 +fi + +echo "==> Persisting CLI path: ${CLI_PATH}" +mkdir -p "${SUPPORT_DIR}" +printf '%s\n' "${CLI_PATH}" > "${PERSISTED_CLI_PATH}" +chmod 600 "${PERSISTED_CLI_PATH}" + +VERSION="$(node -p "require('./package.json').version")-local" +echo "==> Building menubar app (${VERSION})" +mac/Scripts/package-app.sh "v${VERSION}" + +APP_PATH="${ROOT}/mac/.build/dist/CodeBurnMenubar.app" +if [[ ! -d "${APP_PATH}" ]]; then + echo "Menubar app was not built at ${APP_PATH}" >&2 + exit 1 +fi + +if [[ "${REPLACE_APP}" -eq 1 ]]; then + echo "==> Replacing ${INSTALLED_APP_PATH}" + pkill -f CodeBurnMenubar 2>/dev/null || true + if [[ "${SYSTEM_APP}" -eq 1 ]]; then + sudo mkdir -p "$(dirname "${INSTALLED_APP_PATH}")" + sudo rm -rf "${INSTALLED_APP_PATH}" + sudo cp -R "${APP_PATH}" "${INSTALLED_APP_PATH}" + sudo chown -R root:wheel "${INSTALLED_APP_PATH}" + else + mkdir -p "$(dirname "${INSTALLED_APP_PATH}")" + rm -rf "${INSTALLED_APP_PATH}" + cp -R "${APP_PATH}" "${INSTALLED_APP_PATH}" + fi + APP_PATH="${INSTALLED_APP_PATH}" +fi + +echo "==> Restarting menubar app" +pkill -f CodeBurnMenubar 2>/dev/null || true +open "${APP_PATH}" + +echo "" +echo "Ready." +echo "CLI: ${CLI_PATH}" +echo "App: ${APP_PATH}" From dc7cba9b9028dd94b2ab12e3ab9c25cb5296bcc1 Mon Sep 17 00:00:00 2001 From: Tiago Santos Date: Sat, 6 Jun 2026 14:07:44 +0100 Subject: [PATCH 3/3] fix: fix local installer --- scripts/install-local-mac-menubar.sh | 43 ++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/scripts/install-local-mac-menubar.sh b/scripts/install-local-mac-menubar.sh index 0c8391f8..46667a44 100755 --- a/scripts/install-local-mac-menubar.sh +++ b/scripts/install-local-mac-menubar.sh @@ -7,6 +7,7 @@ set -euo pipefail REPLACE_APP=0 SYSTEM_APP=0 +MIN_NODE_VERSION="22.13.0" usage() { cat <<'USAGE' @@ -46,9 +47,31 @@ repo_root() { git rev-parse --show-toplevel 2>/dev/null || (cd "$(dirname "$0")/.." && pwd) } +require_node_version() { + if ! command -v node >/dev/null 2>&1; then + echo "Node ${MIN_NODE_VERSION}+ is required, but node was not found." >&2 + exit 1 + fi + + local node_version + node_version="$(node -p "process.versions.node")" + if ! node -e " +const current = process.versions.node.split('.').map(Number) +const minimum = '${MIN_NODE_VERSION}'.split('.').map(Number) +const ok = current[0] > minimum[0] + || (current[0] === minimum[0] && current[1] > minimum[1]) + || (current[0] === minimum[0] && current[1] === minimum[1] && current[2] >= minimum[2]) +process.exit(ok ? 0 : 1) +"; then + echo "Node ${MIN_NODE_VERSION}+ is required, but found ${node_version}." >&2 + exit 1 + fi +} + ROOT="$(repo_root)" PACK_DIR="${TMPDIR:-/tmp}/codeburn-local-pack" SUPPORT_DIR="${HOME}/Library/Application Support/CodeBurn" +WRAPPER_PATH="${SUPPORT_DIR}/codeburn-menubar-cli" PERSISTED_CLI_PATH="${SUPPORT_DIR}/codeburn-cli-path.v1" if [[ "${SYSTEM_APP}" -eq 1 ]]; then INSTALLED_APP_PATH="/Applications/CodeBurnMenubar.app" @@ -63,8 +86,16 @@ if [[ "$(uname -s)" != "Darwin" ]]; then exit 1 fi +require_node_version + echo "==> Building CLI" npm run build +NODE_PATH="$(node -p "process.execPath")" +if [[ ! -x "${NODE_PATH}" ]]; then + echo "Node executable was not found at ${NODE_PATH}." >&2 + exit 1 +fi +NODE_BIN_DIR="$(dirname "${NODE_PATH}")" echo "==> Packing CLI" rm -rf "${PACK_DIR}" @@ -81,9 +112,17 @@ if [[ -z "${CLI_PATH}" ]]; then exit 1 fi -echo "==> Persisting CLI path: ${CLI_PATH}" +echo "==> Writing menubar CLI wrapper" mkdir -p "${SUPPORT_DIR}" -printf '%s\n' "${CLI_PATH}" > "${PERSISTED_CLI_PATH}" +{ + printf '%s\n' '#!/bin/sh' + printf 'export PATH=%s:"${PATH:-}"\n' "$(printf '%q' "${NODE_BIN_DIR}")" + printf 'exec %s "$@"\n' "$(printf '%q' "${CLI_PATH}")" +} > "${WRAPPER_PATH}" +chmod 755 "${WRAPPER_PATH}" + +echo "==> Persisting CLI path: ${WRAPPER_PATH}" +printf '%s\n' "${WRAPPER_PATH}" > "${PERSISTED_CLI_PATH}" chmod 600 "${PERSISTED_CLI_PATH}" VERSION="$(node -p "require('./package.json').version")-local"