@yuriai/web 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/assets/{AgentAvatar-D6H7fWWf.js → AgentAvatar-BVnp9gE9.js} +1 -1
- package/dist/assets/AssistantView-BkMKCN9c.js +3 -0
- package/dist/assets/{DiagramView-BXVAioBW.js → DiagramView-B7_hOmVC.js} +1 -1
- package/dist/assets/{FadeInOnReveal-eVZbrwte.js → FadeInOnReveal-CzC5UKnq.js} +1 -1
- package/dist/assets/{Icon-2cKLcXGe.js → Icon-BXTZRnIO.js} +1 -1
- package/dist/assets/{ImageLoadingPlaceholder-DmCEIw55.js → ImageLoadingPlaceholder-Cv6IaJar.js} +1 -1
- package/dist/assets/{LoadingImage-B-cD_lfU.js → LoadingImage-NxtWlewi.js} +1 -1
- package/dist/assets/{MarkdownRenderer-BY7SOGeo.js → MarkdownRenderer-Va7Wdk98.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-KSaMBtRF.js → MarkdownRendererImpl-CsjvA5I5.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-BvkGmuQ6.js → MarkstreamRendererImpl-Du38HKce.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-DACAHTpS.js → MobileDetailNavigation-B_PYZ9xi.js} +1 -1
- package/dist/assets/MobileShareBridge-BbbQ15B3.js +4 -0
- package/dist/assets/MobileTabPageHeader-tiWNRKWN.js +2 -0
- package/dist/assets/{MultiRunWindow-4G9PetNl.js → MultiRunWindow-CFFrKEHW.js} +1 -1
- package/dist/assets/{OnboardingScreen-CG2xI_UT.js → OnboardingScreen-BxS5LEOm.js} +1 -1
- package/dist/assets/{OverlayScrollbar-piA-chKb.js → OverlayScrollbar-CbWvRXm6.js} +1 -1
- package/dist/assets/SettingsView-CKn54R1o.js +26 -0
- package/dist/assets/{SettingsWindow-DGK3Earv.js → SettingsWindow-BKatx9Q_.js} +1 -1
- package/dist/assets/{TerminalView-CagyLr39.js → TerminalView-h15pBKLx.js} +2 -2
- package/dist/assets/{ToolOutputDialog-DHukIjoH.js → ToolOutputDialog-Bj-TWElC.js} +1 -1
- package/dist/assets/{appThemeRegistry-U9eZywBt.js → appThemeRegistry-nntgbF9E.js} +1 -1
- package/dist/assets/button-D_HdP1Oy.js +1 -0
- package/dist/assets/{capgoAdapter-ChF9Cg3G.js → capgoAdapter-BV5kJq2r.js} +1 -1
- package/dist/assets/{collapsible-BRKOX_ML.js → collapsible-sUU_ORMe.js} +1 -1
- package/dist/assets/{diagram-C9F3kAJt.js → diagram-BE50CEiR.js} +1 -1
- package/dist/assets/{dialog-NsOVcGdh.js → dialog-B_87Teec.js} +1 -1
- package/dist/assets/{dropdown-menu.styles-CjPDePJF.js → dropdown-menu.styles-C_b80jxk.js} +1 -1
- package/dist/assets/{fileMentionTouchSelection-Bip-B2J9.js → fileMentionTouchSelection-Bx6vWiqF.js} +1 -1
- package/dist/assets/{gitApi-BN0aozGg.js → gitApi-B1arMnHR.js} +1 -1
- package/dist/assets/{input-BI3Ya8O_.js → input-DDfJnn8e.js} +1 -1
- package/dist/assets/{insertionBoundaries-gfsrexDz.js → insertionBoundaries-B8UdVMC9.js} +1 -1
- package/dist/assets/{main-C8xj4UeZ.js → main-BLSF9qdf.js} +2 -2
- package/dist/assets/{main-CjjADMt-.js → main-DJwLyPc3.js} +12 -12
- package/dist/assets/{markdownHeightCache-CHFSvA8u.js → markdownHeightCache-Cbd62oWw.js} +1 -1
- package/dist/assets/{mcpQueries-DJCrzyc3.js → mcpQueries-BxbbmYAd.js} +1 -1
- package/dist/assets/{miniChat-D17acnB9.js → miniChat-D_cBND3Z.js} +2 -2
- package/dist/assets/{mobile-DiL1n1m5.js → mobile-CZNFYieO.js} +2 -2
- package/dist/assets/{mobileBackNavigation-B1NT3K0K.js → mobileBackNavigation-f8AMDP0N.js} +1 -1
- package/dist/assets/{multirun-D2KlubbP.js → multirun-Cvl4FMPo.js} +1 -1
- package/dist/assets/{native-config-D9DfX51I.js → native-config-DLJCZcXh.js} +1 -1
- package/dist/assets/{projectMeta-B5lVMdxF.js → projectMeta-D3cfkJyk.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-DQGRyvOy.js → renderElectronMiniChatApp-_n69Pa_v.js} +2 -2
- package/dist/assets/renderMobileApp-DUiUPyd7.js +28 -0
- package/dist/assets/{runtimeConfig-MKo97i27.js → runtimeConfig-DwFJL5bm.js} +1 -1
- package/dist/assets/runtimeEndpointReset-C_1daihM.js +149 -0
- package/dist/assets/{scheduledTaskNavigation-DUskP6qh.js → scheduledTaskNavigation-DvSpMLdG.js} +1 -1
- package/dist/assets/sessionChangeSummary-evl_LmRN.js +1 -0
- package/dist/assets/sessionLookup-CJ8mXxHt.js +1 -0
- package/dist/assets/{sortable-tabs-strip-Benojjgi.js → sortable-tabs-strip-CrjW2aQk.js} +1 -1
- package/dist/assets/{speechSettingsQueries-DFAUd_Aw.js → speechSettingsQueries-xWFTnUfF.js} +1 -1
- package/dist/assets/src-omVWmz3o.css +2 -0
- package/dist/assets/{textarea-C5vjnZcE.js → textarea-BkOdxUN0.js} +1 -1
- package/dist/assets/{tooltip-C73L9gDf.js → tooltip-BQQoK7L_.js} +1 -1
- package/dist/assets/{types-DZcrt4Z0.js → types-BzF7Dhzq.js} +1 -1
- package/dist/assets/{useAppFontEffects-DUPuwJH5.js → useAppFontEffects-lmtncXsi.js} +7 -7
- package/dist/assets/{useDesktopWindowControlsLayout-Co_z6hbP.js → useDesktopWindowControlsLayout-BY0_whx0.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-DXPmcf8E.js → useEffectiveDirectory-CqkTJqRg.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-DcG6Ukmu.js → useMobileNavigationStore-BURkCJuM.js} +4 -4
- package/dist/assets/{useWorkerHighlightedLines-BnmJy3ZO.js → useWorkerHighlightedLines-Bla356tF.js} +1 -1
- package/dist/index.html +6 -6
- package/dist/mini-chat.html +6 -6
- package/dist/mobile.html +6 -6
- package/package.json +1 -1
- package/server/lib/assistants/DOCUMENTATION.md +13 -2
- package/server/lib/assistants/contact-store.js +2 -1
- package/server/lib/assistants/contact-store.test.js +13 -3
- package/server/lib/assistants/routes.js +2 -1
- package/server/lib/assistants/service.js +54 -7
- package/server/lib/assistants/service.test.js +59 -10
- package/server/lib/opencode/feature-routes-runtime.js +1 -1
- package/server/lib/relay/DOCUMENTATION.md +2 -0
- package/server/lib/relay/service.js +1 -1
- package/server/lib/relay/service.test.js +3 -3
- package/dist/assets/AssistantView-CLk1T4Of.js +0 -3
- package/dist/assets/MobileShareBridge-DLPBToEM.js +0 -4
- package/dist/assets/MobileTabPageHeader-CvtrPaC2.js +0 -2
- package/dist/assets/SettingsView-pRXWe1jD.js +0 -26
- package/dist/assets/button-_ReBqWzC.js +0 -1
- package/dist/assets/renderMobileApp-BlCyG-WF.js +0 -28
- package/dist/assets/runtimeEndpointReset-DrO__UOn.js +0 -149
- package/dist/assets/sessionChangeSummary-DN7H-rqE.js +0 -1
- package/dist/assets/sessionLookup-DezY-ONT.js +0 -1
- package/dist/assets/src-CmermOKP.css +0 -2
package/dist/assets/{useWorkerHighlightedLines-BnmJy3ZO.js → useWorkerHighlightedLines-Bla356tF.js}
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{r as e}from"./rolldown-runtime-hePW80VL.js";import{it as t,rt as n}from"./vendor-base-ui-CdzJ6My_.js";import{v as r}from"./useUIStore-NMQx_cnG.js";import{n as i}from"./vendor-react-BP2cRVqX.js";import{f as a,r as o,t as s}from"./button-
|
|
1
|
+
import{r as e}from"./rolldown-runtime-hePW80VL.js";import{it as t,rt as n}from"./vendor-base-ui-CdzJ6My_.js";import{v as r}from"./useUIStore-NMQx_cnG.js";import{n as i}from"./vendor-react-BP2cRVqX.js";import{f as a,r as o,t as s}from"./button-D_HdP1Oy.js";import{n as c}from"./useThemeSystem-8jhE4Zyk.js";import{n as l}from"./Icon-BXTZRnIO.js";import{i as u,n as d,t as f}from"./tooltip-BQQoK7L_.js";import{n as p}from"./MarkdownRenderer-Va7Wdk98.js";import{n as m,o as h,s as g,t as _}from"./markdown-worker-CkO3sT4Y.js";import{t as v}from"./vendor-tanstack-react-virtual-B_EO--9t.js";import{n as y}from"./markdownTheme-DHoKvD6O.js";var b=e(t(),1),x=i(),S=n(),C=`w-[min(30rem,calc(100cqw-1rem))] min-w-[280px] max-w-full overflow-hidden rounded-xl shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)] dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]`,w={maxWidth:`min(28rem, calc(100cqw - 4ch))`,backgroundColor:`var(--surface-elevated)`,color:`var(--surface-elevated-foreground)`},T=`flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] px-3 py-2.5`,E=`text-xs font-medium leading-4 text-foreground md:text-[0.8125rem] md:leading-5`,D=`text-xs tabular-nums leading-4 text-muted-foreground md:text-[0.8125rem] md:leading-5`,O=`m-0 list-none divide-y divide-[var(--surface-subtle)] p-0`,k=`${O} max-h-[min(22rem,50vh)] overflow-y-auto`,A=`${O} w-full`,ee={className:`p-0 rounded-none`,maxHeightClass:`max-h-[46vh]`,disableHorizontal:!0},j=`px-2.5 py-1.5 text-xs leading-4 md:py-2 md:text-[0.8125rem] md:leading-5`,M=`flex min-w-0 items-center gap-1 text-xs leading-4 text-muted-foreground md:gap-1.5 md:text-[0.8125rem] md:leading-5`,te=`min-w-0 truncate text-xs leading-4 text-foreground md:text-[0.8125rem] md:leading-5`,ne=`inline-flex shrink-0 items-center gap-1 text-xs leading-4 tabular-nums text-muted-foreground md:text-[0.8125rem] md:leading-5`,re=`h-3 w-3 shrink-0 md:h-3.5 md:w-3.5`,ie={in_progress:`text-foreground`,pending:`text-foreground`,completed:`text-muted-foreground line-through`,cancelled:`text-muted-foreground line-through`},ae={in_progress:`chat.statusRow.todo.status.inProgress`,pending:`chat.statusRow.todo.status.pending`,completed:`chat.statusRow.todo.status.completed`,cancelled:`chat.statusRow.todo.status.cancelled`},N=e=>{let t=(0,x.c)(26),{todo:n,isActive:r,activeRef:i}=e,{t:s}=a(),c=oe.has(n.status)?n.status:`pending`,p=ae[c],m;t[0]!==s||t[1]!==p?(m=s(p),t[0]=s,t[1]=p,t[2]=m):m=t[2];let h=m,g;t[3]===c?g=t[4]:(g=c===`in_progress`?(0,S.jsx)(l,{name:`record-circle`,className:`h-3.5 w-3.5 text-[var(--status-info)]`,"aria-hidden":`true`}):c===`completed`?(0,S.jsx)(l,{name:`checkbox-circle`,className:`h-3.5 w-3.5 text-[var(--status-success)]`,"aria-hidden":`true`}):(0,S.jsx)(l,{name:`time`,className:`h-3.5 w-3.5 text-muted-foreground`,"aria-hidden":`true`}),t[3]=c,t[4]=g);let _=g,v=r?`step`:void 0,y=r&&`bg-[var(--surface-muted)] before:absolute before:inset-y-2 before:left-0 before:w-0.5 before:rounded-full before:bg-[var(--status-info)]`,b;t[5]===y?b=t[6]:(b=o(`group relative grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] items-start gap-2.5`,j,y),t[5]=y,t[6]=b);let C;t[7]!==_||t[8]!==h?(C=(0,S.jsx)(u,{asChild:!0,children:(0,S.jsx)(`span`,{className:`flex size-4 items-center justify-center`,"aria-label":h,children:_})}),t[7]=_,t[8]=h,t[9]=C):C=t[9];let w;t[10]===h?w=t[11]:(w=(0,S.jsx)(d,{side:`left`,sideOffset:6,children:h}),t[10]=h,t[11]=w);let T;t[12]!==C||t[13]!==w?(T=(0,S.jsxs)(f,{children:[C,w]}),t[12]=C,t[13]=w,t[14]=T):T=t[14];let E=ie[c],D;t[15]===E?D=t[16]:(D=o(`block whitespace-normal break-words`,E),t[15]=E,t[16]=D);let O;t[17]!==D||t[18]!==n.content?(O=(0,S.jsx)(`span`,{className:`min-w-0 flex-1`,children:(0,S.jsx)(`span`,{className:D,children:n.content})}),t[17]=D,t[18]=n.content,t[19]=O):O=t[19];let k;return t[20]!==i||t[21]!==O||t[22]!==v||t[23]!==b||t[24]!==T?(k=(0,S.jsxs)(`li`,{ref:i,"aria-current":v,className:b,children:[T,O]}),t[20]=i,t[21]=O,t[22]=v,t[23]=b,t[24]=T,t[25]=k):k=t[25],k},oe=new Set([`in_progress`,`pending`,`completed`,`cancelled`]),P=e=>e.replace(/&/g,`&`).replace(/</g,`<`).replace(/>/g,`>`).replace(/"/g,`"`).replace(/'/g,`'`),F=e=>e?Object.entries(e).map(([e,t])=>t==null?``:`${e.startsWith(`--`)?e:e.replace(/[A-Z]/g,e=>`-${e.toLowerCase()}`)}:${typeof t==`number`?`${t}px`:t};`).join(``):``,I=(e,t,n)=>{let r=e.querySelector(`pre`);r&&(r.style.margin=`0`,r.style.background=`transparent`,r.style.whiteSpace=t?`pre-wrap`:`pre`,t&&(r.style.wordBreak=`break-word`,r.style.overflowWrap=`break-word`));let i=e.querySelector(`code`);if(i){let e=F(n);e&&i.setAttribute(`style`,`${i.getAttribute(`style`)??``}${e}`)}},L=e=>`<pre><code>${P(e)}</code></pre>`,R=e=>{let t=(0,x.c)(21),{code:n,language:r,className:i,style:a,codeStyle:s,wrap:l}=e,u=l!==void 0&&l,{currentTheme:d}=c(),f=b.useRef(null),p;t[0]===d?p=t[1]:(p=y(d),t[0]=d,t[1]=p);let m=p,h,g;t[2]!==n||t[3]!==s||t[4]!==u?(h=()=>{let e=f.current;e&&(e.innerHTML=L(n),I(e,u,s))},g=[n,u,s],t[2]=n,t[3]=s,t[4]=u,t[5]=h,t[6]=g):(h=t[5],g=t[6]),b.useLayoutEffect(h,g);let v,C;t[7]!==n||t[8]!==s||t[9]!==r||t[10]!==u?(v=()=>{let e=f.current;if(!e)return;let t=!0,i=new AbortController;return _(n,(r||`text`).toLowerCase(),{signal:i.signal,priority:`visible`}).then(n=>{!t||!e||!n||(e.innerHTML=n,I(e,u,s))}),()=>{t=!1,i.abort()}},C=[n,r,u,s],t[7]=n,t[8]=s,t[9]=r,t[10]=u,t[11]=v,t[12]=C):(v=t[11],C=t[12]),b.useEffect(v,C);let w;t[13]===i?w=t[14]:(w=o(`typography-code`,i),t[13]=i,t[14]=w);let T=m,E;t[15]!==a||t[16]!==T?(E={...T,...a},t[15]=a,t[16]=T,t[17]=E):E=t[17];let D;return t[18]!==w||t[19]!==E?(D=(0,S.jsx)(`div`,{ref:f,className:w,style:E}),t[18]=w,t[19]=E,t[20]=D):D=t[20],D},z=e=>{let t=e.replace(/^<file>\s*\n?/,``).replace(/\n?<\/file>\s*$/,``);return t=t.replace(/^\s*\d{5}\|\s?/gm,``),t.trim()},B=(e,t=``)=>{if(typeof e==`string`)return e;if(e==null)return t;if(typeof e==`number`||typeof e==`boolean`||typeof e==`bigint`)return String(e);try{return JSON.stringify(e)}catch{return t}},V=e=>e?e.includes(`<diagnostics`)||e.includes(`<file_diagnostics>`)||e.includes(`LSP errors detected`)||e.includes(`This file has errors`):!1,H=e=>e?e.replace(/\n{0,2}LSP errors detected[\s\S]*?<diagnostics[^>]*>[\s\S]*?<\/diagnostics>/g,``).replace(/\n{0,2}This file has errors[\s\S]*?<\/file_diagnostics>/g,``).replace(/<diagnostics[^>]*>[\s\S]*?<\/diagnostics>/g,``).replace(/<file_diagnostics>[\s\S]*?<\/file_diagnostics>/g,``).trim():``,U=(e,t)=>!e||typeof e!=`object`?String(e):g(e,t||``),W=e=>{if(typeof e==`string`){let t=e.trim();return t.length>0?t:void 0}if(e&&typeof e==`object`){let t=e.patch;if(typeof t==`string`){let e=t.trim();return e.length>0?e:void 0}}},G=e=>{if(!e||typeof e!=`object`)return;let t=W(e.patch)??W(e.diff);if(t)return t;let n=Array.isArray(e.files)?e.files:[];for(let e of n){if(!e||typeof e!=`object`)continue;let t=W(e.patch)??W(e.diff);if(t)return t}},K=e=>{if(!e||typeof e!=`string`)return{data:null,isJson:!1};let t=e.trim();if(!t.startsWith(`{`)&&!t.startsWith(`[`)||!t.endsWith(`}`)&&!t.endsWith(`]`)||t.length<2)return{data:null,isJson:!1};try{let e=JSON.parse(t);return typeof e==`object`&&e?{data:e,isJson:!0}:{data:null,isJson:!1}}catch{return{data:null,isJson:!1}}},q=(e,t,n)=>{let r=z(e);if((t===`edit`||t===`multiedit`||t===`write`||t===`apply_patch`)&&V(r)&&(r=H(r)),(t===`edit`||t===`multiedit`||t===`apply_patch`)&&r.trim().length===0){let e=G(n);if(e)return e}return r},se=e=>{let t=e.match(/<type>(file|directory)<\/type>/i)?.[1]?.toLowerCase()??`unknown`,n=(e.match(/<content>([\s\S]*?)<\/content>/i)?.[1]??e).replace(/\r\n/g,`
|
|
2
2
|
`).split(`
|
|
3
3
|
`),r=e=>/\(\s*File has more lines\..*offset.*\)/i.test(e.trim());return{type:t,lines:n.map(e=>{let n=e.trim(),i=n.startsWith(`(`)&&n.endsWith(`)`)||r(n);if(t!==`directory`){let t=e.match(/^(\d+):\s?(.*)$/);if(t){let e=t[2],n=e.trim(),i=n.startsWith(`(`)&&n.endsWith(`)`)||r(n);return{lineNumber:i?null:Number(t[1]),text:e,isInfo:i}}}return{lineNumber:null,text:e,isInfo:i}}).filter((e,t,n)=>{if(e.text.trim().length>0)return!0;let r=n[t-1],i=n[t+1],a=!!(r?.isInfo||i?.isInfo),o=e.lineNumber!==null;return!(a&&o)})}},ce=(e,t)=>{try{let n=e.trim().split(`
|
|
4
4
|
`).filter(Boolean);if(n.length===0)return null;let i=[];return n.forEach(e=>{let t=e.match(/^(\s*)(.+)$/);if(t){let[,e,n]=t,r=Math.floor(e.length/2),a=!n.endsWith(`/`);i.push({name:n.replace(/\/$/,``),depth:r,isFile:a})}}),(0,S.jsx)(`div`,{className:o(`w-full min-w-0 font-mono space-y-0.5`,t?.unstyled?null:`p-3 bg-muted/20 rounded-xl border border-border/30`),style:r.tool.popup,children:i.map((e,t)=>(0,S.jsx)(`div`,{className:`min-w-0`,style:{paddingLeft:`${e.depth*20}px`},children:e.isFile?(0,S.jsx)(`span`,{className:`text-foreground/90 block truncate`,children:e.name}):(0,S.jsxs)(`span`,{className:`font-semibold text-foreground block truncate`,children:[e.name,`/`]})},t))})}catch{return null}},le={backgroundColor:`var(--status-info)`,opacity:.6},ue=(e,t,n)=>{try{let i=e.trim().split(`
|
package/dist/index.html
CHANGED
|
@@ -552,7 +552,7 @@
|
|
|
552
552
|
opacity: 0.72;
|
|
553
553
|
}
|
|
554
554
|
</style>
|
|
555
|
-
<script type="module" crossorigin src="/assets/main-
|
|
555
|
+
<script type="module" crossorigin src="/assets/main-BLSF9qdf.js"></script>
|
|
556
556
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
|
|
557
557
|
<link rel="modulepreload" crossorigin href="/assets/runtime-auth-ef9W7A43.js">
|
|
558
558
|
<link rel="modulepreload" crossorigin href="/assets/runtime-switch-CAZiOV2G.js">
|
|
@@ -575,11 +575,11 @@
|
|
|
575
575
|
<link rel="modulepreload" crossorigin href="/assets/platform-7bX6qtBV.js">
|
|
576
576
|
<link rel="modulepreload" crossorigin href="/assets/terminalApi-BHXvha-U.js">
|
|
577
577
|
<link rel="modulepreload" crossorigin href="/assets/runtime-file-download-DAOam_oV.js">
|
|
578
|
-
<link rel="modulepreload" crossorigin href="/assets/types-
|
|
579
|
-
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-
|
|
580
|
-
<link rel="modulepreload" crossorigin href="/assets/native-config-
|
|
581
|
-
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-
|
|
582
|
-
<link rel="stylesheet" crossorigin href="/assets/src-
|
|
578
|
+
<link rel="modulepreload" crossorigin href="/assets/types-BzF7Dhzq.js">
|
|
579
|
+
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-BV5kJq2r.js">
|
|
580
|
+
<link rel="modulepreload" crossorigin href="/assets/native-config-DLJCZcXh.js">
|
|
581
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-DwFJL5bm.js">
|
|
582
|
+
<link rel="stylesheet" crossorigin href="/assets/src-omVWmz3o.css">
|
|
583
583
|
</head>
|
|
584
584
|
<body class="h-full bg-background text-foreground">
|
|
585
585
|
<!-- Keep the startup layer outside #root. React replaces every child of
|
package/dist/mini-chat.html
CHANGED
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
}
|
|
75
75
|
</style>
|
|
76
76
|
|
|
77
|
-
<script type="module" crossorigin src="/assets/miniChat-
|
|
77
|
+
<script type="module" crossorigin src="/assets/miniChat-D_cBND3Z.js"></script>
|
|
78
78
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
|
|
79
79
|
<link rel="modulepreload" crossorigin href="/assets/runtime-auth-ef9W7A43.js">
|
|
80
80
|
<link rel="modulepreload" crossorigin href="/assets/runtime-switch-CAZiOV2G.js">
|
|
@@ -97,11 +97,11 @@
|
|
|
97
97
|
<link rel="modulepreload" crossorigin href="/assets/platform-7bX6qtBV.js">
|
|
98
98
|
<link rel="modulepreload" crossorigin href="/assets/terminalApi-BHXvha-U.js">
|
|
99
99
|
<link rel="modulepreload" crossorigin href="/assets/runtime-file-download-DAOam_oV.js">
|
|
100
|
-
<link rel="modulepreload" crossorigin href="/assets/types-
|
|
101
|
-
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-
|
|
102
|
-
<link rel="modulepreload" crossorigin href="/assets/native-config-
|
|
103
|
-
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-
|
|
104
|
-
<link rel="stylesheet" crossorigin href="/assets/src-
|
|
100
|
+
<link rel="modulepreload" crossorigin href="/assets/types-BzF7Dhzq.js">
|
|
101
|
+
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-BV5kJq2r.js">
|
|
102
|
+
<link rel="modulepreload" crossorigin href="/assets/native-config-DLJCZcXh.js">
|
|
103
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-DwFJL5bm.js">
|
|
104
|
+
<link rel="stylesheet" crossorigin href="/assets/src-omVWmz3o.css">
|
|
105
105
|
</head>
|
|
106
106
|
<body class="h-full bg-background text-foreground">
|
|
107
107
|
<div id="root" class="h-full">
|
package/dist/mobile.html
CHANGED
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
})();
|
|
64
64
|
</script>
|
|
65
65
|
|
|
66
|
-
<script type="module" crossorigin src="/assets/mobile-
|
|
66
|
+
<script type="module" crossorigin src="/assets/mobile-CZNFYieO.js"></script>
|
|
67
67
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
|
|
68
68
|
<link rel="modulepreload" crossorigin href="/assets/runtime-auth-ef9W7A43.js">
|
|
69
69
|
<link rel="modulepreload" crossorigin href="/assets/runtime-switch-CAZiOV2G.js">
|
|
@@ -86,11 +86,11 @@
|
|
|
86
86
|
<link rel="modulepreload" crossorigin href="/assets/platform-7bX6qtBV.js">
|
|
87
87
|
<link rel="modulepreload" crossorigin href="/assets/terminalApi-BHXvha-U.js">
|
|
88
88
|
<link rel="modulepreload" crossorigin href="/assets/runtime-file-download-DAOam_oV.js">
|
|
89
|
-
<link rel="modulepreload" crossorigin href="/assets/types-
|
|
90
|
-
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-
|
|
91
|
-
<link rel="modulepreload" crossorigin href="/assets/native-config-
|
|
92
|
-
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-
|
|
93
|
-
<link rel="stylesheet" crossorigin href="/assets/src-
|
|
89
|
+
<link rel="modulepreload" crossorigin href="/assets/types-BzF7Dhzq.js">
|
|
90
|
+
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-BV5kJq2r.js">
|
|
91
|
+
<link rel="modulepreload" crossorigin href="/assets/native-config-DLJCZcXh.js">
|
|
92
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-DwFJL5bm.js">
|
|
93
|
+
<link rel="stylesheet" crossorigin href="/assets/src-omVWmz3o.css">
|
|
94
94
|
</head>
|
|
95
95
|
<body class="h-full bg-background text-foreground">
|
|
96
96
|
<div id="root" class="h-full"></div>
|
package/package.json
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Assistants Module
|
|
2
2
|
|
|
3
|
+
## Contact activity ordering
|
|
4
|
+
|
|
5
|
+
Both individual and batched latest-message previews carry the persisted message's
|
|
6
|
+
`created_at` as `createdAt`. The bounded candidate SQL must select this column and
|
|
7
|
+
`messageFromPreviewRow` must preserve it before preview projection; otherwise JSON
|
|
8
|
+
silently omits the ordering field. Contact-list order and navigation avatar identity
|
|
9
|
+
use this timestamp, never assistant configuration `updatedAt`. Regression fixtures
|
|
10
|
+
return the timestamp actually inserted, and snapshot tests require a finite value.
|
|
11
|
+
|
|
3
12
|
## Transient contact errors
|
|
4
13
|
|
|
5
14
|
Contact-turn failures are delivered through `yuri:contact-turn-end` only,
|
|
@@ -177,7 +186,7 @@ and keep their existing error result handling.
|
|
|
177
186
|
- **`watch_session`:** listen-only attach for an existing session (`sessionID` required). No `session.prompt`. Emits the same session card slot and reuses `assistant_contact_watch` + `reportAssignedSession` / reconcile / resume. Baseline card/watch status comes from live `session.get` (+ messages when needed) via `mapSessionToWatchStatus` so an already-terminal idle is **not** treated as a fresh complete transition (no false resume). In-flight baseline (`busy`/`question`) keeps the watch until real settle. A plain user `@session:id` (or JSON title/sessionID/directory) reference is **context only** — it does not auto-call `watch_session`; the model must hear an explicit listen/watch/monitor intent.
|
|
178
187
|
- **`stop_session`:** calls real OpenCode `session.interrupt` for `sessionID` after the same authoritative directory resolve. Returns the true abort result — transport/`not_found`/upstream errors stay errors (never claim stopped on failure). Abort is registered in a private per-session inflight map before `await abort` so concurrent SSE `complete`/`error` cannot schedule a `resume=true` continuation ahead of `resume=false`. On successful abort the service marks the watch/card `cancelled` even when upstream emits no idle/error event. Idle/error events still flow through the existing watch reporter when a watch exists.
|
|
179
188
|
- **Worker model selection:** optional tool args `providerID` / `modelID` / `model` resolve against the live connected OpenCode catalog (`loadConnectedCatalog` / `GET /provider` + `GET /config/providers`). Explicit selection must match uniquely — missing or ambiguous names fail closed (`model_not_found` / `model_ambiguous`) with **no silent fallback**. Omitting model args on a **new** worker keeps the contact assistant's default provider/model. **Reusing `sessionID` without an explicit model** follows that session's last model (`session.get` → `session.model` `{ providerID, id|modelID }`, else newest user/assistant `info.model`) when it is still an exact providerID+modelID match in the connected catalog (`source: 'session'`); missing session model, catalog miss, or lookup failure **degrades** to the assistant default (`source: 'assistant'`) and never fails assign once the session itself resolved. Selecting a worker model **never** mutates the contact assistant row.
|
|
180
|
-
- Every user or automatic contact turn reloads the current instance's **Connected OpenCode models** block (exact providerID, modelID, providerName, name, acceptsImages), plus settings-backed favorite models in saved order and recent models newest first. Only connected catalog entries qualify as preference candidates; variants remain separate from model IDs. Catalog and preference read failures remain explicit unknown states, distinct from successful empty lists; either read can succeed independently.
|
|
189
|
+
- Every user or automatic contact turn reloads the current instance's **Connected OpenCode models** block (exact providerID, modelID, providerName, name, acceptsImages), plus settings-backed favorite models in saved order and recent models newest first. Only connected catalog entries qualify as preference candidates; variants remain separate from model IDs. Catalog and preference read failures remain explicit unknown states, distinct from successful empty lists; either read can succeed independently. Contact prompts project only favoriteModels/recentModels from the strict settings reader; defaultModel/defaultVariant are used separately for initial contact creation, never arbitrary settings or credentials. No catalog is cached across turns or runtime switches. Informal `model` aliases normalize punctuation/spacing and require a unique catalog match; explicit `modelID` stays exact. Ambiguous aliases never silently prefer a favorite/provider. This server-owned context is shared by web, desktop, and mobile clients of that instance.
|
|
181
190
|
- **Current-turn attachments:** server-authoritative `userParts` file parts from this contact turn are forwarded into worker `session.prompt` parts by default (including “just open a session” intents). The model must not base64-encode or invent local paths. History images are **not** unbounded-forwarded. Image parts require a vision-capable catalog entry (`acceptsImages`); otherwise assign fails with `image_not_supported` before create. Sensitive file bodies are never logged.
|
|
182
191
|
- **Create-then-prompt failure:** if `session.create` succeeds and `session.prompt` fails, assign best-effort `session.delete`s the new worker so empty sessions are not left behind. Reused `sessionID` paths never delete on prompt failure.
|
|
183
192
|
- Successful `assign_session` and `watch_session` return `terminate: true` to stop the mutation loop. The harness then requests a tools-disabled model summary. Prerequisite tools (`list_projects` / `list_sessions`) may still run before assign/watch/stop in the same turn; multi-operation user intents remain ordered **across turns** after a successful assign/watch.
|
|
@@ -324,7 +333,9 @@ Snapshots expose the latest 50 `historySessionIDs` in chronological order and `h
|
|
|
324
333
|
|
|
325
334
|
`assistants` owns Assistant configuration, session bindings, and contact/share metadata in `assistants.sqlite`. OpenCode message bodies are read from upstream projections, not mirrored. Routes register before the generic OpenCode proxy.
|
|
326
335
|
|
|
327
|
-
`GET /api/yuri/assistants/capability` returns `supported`,
|
|
336
|
+
`GET /api/yuri/assistants/capability` returns `supported`, `enabled: true`, domain revision, relay-derived stable `serverInstanceID`, and explicit v2 gaps `sharingAvailable` (always `false`), `archiveMetadataAvailable` (true only when Host archive is injected), and `sessionMetadataAvailable` (always `false`). Assistants are always enabled. Startup atomically upgrades an old disabled instance flag and advances its revision without changing contacts. The legacy `PUT /api/yuri/assistants/settings` validates `expectedRevision` but returns `enabled: true` without a mutation, so older clients cannot globally disable the feature. Individual contact enable/disable remains supported. CRUD and snapshots live under `/api/yuri/assistants`. Session routes are `POST /:id/session/ensure`, `/:id/session/new`, and `/:id/session/compact`; composer sends use `POST /:id/messages` (contact harness, not `session.prompt`); contact history is `GET /:id/contact/messages` (full transcript, including rows before a clear-memory boundary); contact reset wipe is `POST /:id/contact/reset`; shared contact read watermark is `POST /:id/contact/read`; session cards are `POST /:id/contact/cards`; read-only inter-assistant DMs are `POST /:fromID/contact/dm`; assistant-owned scheduled tasks are `GET /:id/scheduled-tasks`. Shares still use `POST /:id/share` and `GET /share-operations/:operationID`.
|
|
337
|
+
|
|
338
|
+
Both HTTP snapshot aliases call `ensureDefaultAssistant` before reading. A never-used catalog automatically gets one enabled `Yuri` contact in its managed workspace, with no OpenCode session or model call. It uses the configured default model/variant if connected, otherwise the first connected catalog model. No configured models leaves the catalog empty until a later read after provider setup; catalog/preferences failures reject the read and remain retryable. Same-process initialization is single-flight, and the SQLite write transaction rechecks all rows (including tombstones) so multiple clients/processes cannot seed duplicates or resurrect an explicitly deleted last contact. Existing catalogs bypass model reads entirely. The preference dependency also supplies `defaultModel`/`defaultVariant` for seeding; only favorites/recents enter contact prompts.
|
|
328
339
|
|
|
329
340
|
`AssistantDTO.mode` remains `continuous` or `stateless` (default `continuous`) for share/queue OpenCode session binding. It is **not** a user-facing memory setting: Settings no longer exposes the continuous/stateless toggle, and new assistants stay continuous contact history with server-side LLM trim. `historySessionIDs` lists prior OpenCode session bindings in chronological order (oldest first, capped). Continuous Assistants keep one long-lived current session across composer turns. Stateless Assistants create and bind a fresh OpenCode session for every composer send, share admission, and queued delivery so the model executes without prior transcript context; prior sessions remain available in OpenCode history. The Assistant surface follows the current binding, so each stateless turn rematerializes onto that fresh session. `AssistantDTO.workspacePath` retains the configuration value: `null` selects the managed workspace and a string selects a registered project path. `AssistantDTO.managedWorkspacePath` always carries the canonical managed target at `dataDir/assistant-workspaces/<assistantID>`, including for project-configured Assistants. `AssistantDTO.effectiveWorkspacePath` carries the resolved OpenCode session directory. `AssistantDTO.variant` is nullable and reaches OpenCode `session.prompt` with provider, model, agent, and system prompt. `SessionBinding` is `{ sessionID, directory, sessionGeneration }` with the effective directory. `ensure` and `new` return `SessionBinding`; `compact` returns `{ binding, summarized: true }`; messages return `{ binding, messageID, admitted: true }`; shares return top-level `{ operationID, assistantID, sessionID, messageID, state, phase, attempt, leaseExpiresAt, errorCode }`. `contracts.js` exports serialization fixtures for these DTOs. Binding replacement uses generation CAS, so concurrent creation retains one winner while orphan sessions stay available in OpenCode history. Workspace changes create and bind a session in the new directory. Session metadata carries `yuri.assistant.assistantID` and `name` as the authoritative ownership marker. New OpenCode sessions use the fixed human title `[Assistant] ${name}` and persist that isolation through the Host session-metadata store immediately after create (`persistSessionMetadata` + `onSystemSessionPersisted`) so the sidebar index drops them without waiting for list sync. They are archived via Host archive immediately after create and before binding is returned, so ordinary active lists never flash them; metadata isolation still applies if archive fails after create. The session index and global session lists hide system sessions by this metadata (never by title prefix). Assistant history and direct source open remain the entry points.
|
|
330
341
|
|
|
@@ -821,6 +821,7 @@ const messageFromPreviewRow = (row, parts) => {
|
|
|
821
821
|
role: row.role,
|
|
822
822
|
ordinal: row.ordinal,
|
|
823
823
|
status: row.status,
|
|
824
|
+
createdAt: row.created_at,
|
|
824
825
|
parts: list,
|
|
825
826
|
text: list.filter((part) => part.type === 'text').map((part) => part.text).join(''),
|
|
826
827
|
};
|
|
@@ -849,7 +850,7 @@ const pickPreviewFromCandidateRows = (rows, partsByMessage) => {
|
|
|
849
850
|
* occupy a slot and force a deeper probe only within the same LIMIT.
|
|
850
851
|
*/
|
|
851
852
|
const CONTACT_PREVIEW_CANDIDATE_SQL = `
|
|
852
|
-
SELECT message_id, assistant_id, role, ordinal, status
|
|
853
|
+
SELECT message_id, assistant_id, role, ordinal, status, created_at
|
|
853
854
|
FROM assistant_contact_message m
|
|
854
855
|
WHERE m.assistant_id = ?
|
|
855
856
|
AND m.role IN ('user', 'assistant', 'peer')
|
|
@@ -59,7 +59,7 @@ const openDb = () => {
|
|
|
59
59
|
};
|
|
60
60
|
|
|
61
61
|
const insert = (db, assistantID, {
|
|
62
|
-
role, text = '', parts, status = 'complete', fromAssistantID = null, fromAssistantName = null, turnID = null, messageID = null,
|
|
62
|
+
role, text = '', parts, status = 'complete', fromAssistantID = null, fromAssistantName = null, turnID = null, messageID = null, createdAt = Date.now(),
|
|
63
63
|
}) => {
|
|
64
64
|
const id = typeof messageID === 'string' && messageID ? messageID : crypto.randomUUID();
|
|
65
65
|
const ordinal = nextContactOrdinal(db, assistantID);
|
|
@@ -69,14 +69,14 @@ const insert = (db, assistantID, {
|
|
|
69
69
|
role,
|
|
70
70
|
turnID: typeof turnID === 'string' && turnID ? turnID : id,
|
|
71
71
|
bubbleIndex: 0,
|
|
72
|
-
createdAt
|
|
72
|
+
createdAt,
|
|
73
73
|
ordinal,
|
|
74
74
|
status,
|
|
75
75
|
parts: parts || (text ? [{ type: 'text', text }] : [{ type: 'text', text: '' }]),
|
|
76
76
|
fromAssistantID,
|
|
77
77
|
fromAssistantName,
|
|
78
78
|
});
|
|
79
|
-
return { messageID: id, ordinal };
|
|
79
|
+
return { messageID: id, ordinal, createdAt };
|
|
80
80
|
};
|
|
81
81
|
|
|
82
82
|
describe('contact LLM history trim', () => {
|
|
@@ -503,6 +503,16 @@ describe('contact messages page keyset', () => {
|
|
|
503
503
|
});
|
|
504
504
|
|
|
505
505
|
describe('contact latest message preview', () => {
|
|
506
|
+
it('preserves message time through individual and batched snapshot previews', () => {
|
|
507
|
+
const db = openDb();
|
|
508
|
+
insert(db, 'sent', { role: 'user', text: 'sent now', createdAt: 3000 });
|
|
509
|
+
insert(db, 'received', { role: 'assistant', text: 'new reply', createdAt: 4000 });
|
|
510
|
+
expect(getLatestContactMessagePreview(db, 'sent')?.createdAt).toBe(3000);
|
|
511
|
+
const batch = getLatestContactMessagePreviews(db, ['sent', 'received']);
|
|
512
|
+
expect(batch.get('sent')?.createdAt).toBe(3000);
|
|
513
|
+
expect(batch.get('received')?.createdAt).toBe(4000);
|
|
514
|
+
db.close();
|
|
515
|
+
});
|
|
506
516
|
it('returns null when the assistant has no contact rows', () => {
|
|
507
517
|
const db = openDb();
|
|
508
518
|
expect(getLatestContactMessagePreview(db, 'asst_empty')).toBeNull();
|
|
@@ -50,7 +50,8 @@ export const registerAssistantRoutes = (app, dependencies) => {
|
|
|
50
50
|
const unsubscribeTaskResults = dependencies.setTaskResultHandler?.((run) => service.reportScheduledTaskResult(run));
|
|
51
51
|
app.use('/api/yuri/assistants', (req, res, next) => Promise.resolve(dependencies.refreshAllowedRoots?.()).then(next).catch((error) => respond(res, () => { throw error; })));
|
|
52
52
|
app.get('/api/yuri/assistants/capability', (_req, res) => respond(res, () => service.capability()));
|
|
53
|
-
|
|
53
|
+
const snapshot = async () => { await service.ensureDefaultAssistant(); return service.snapshot(); };
|
|
54
|
+
app.get('/api/yuri/assistants', (_req, res) => respond(res, snapshot)); app.get('/api/yuri/assistants/snapshot', (_req, res) => respond(res, snapshot)); app.put('/api/yuri/assistants/settings', (req, res) => respond(res, () => service.setEnabled(req.body)));
|
|
54
55
|
app.post('/api/yuri/assistants', (req, res) => respond(res, () => service.createAssistant(req.body), 201)); app.patch('/api/yuri/assistants/:assistantID', (req, res) => respond(res, () => service.updateAssistant(req.params.assistantID, req.body))); app.delete('/api/yuri/assistants/:assistantID', (req, res) => respond(res, () => service.removeAssistant(req.params.assistantID, req.body?.expectedRevision)));
|
|
55
56
|
app.post('/api/yuri/assistants/:assistantID/session/ensure', (req, res) => respond(res, () => service.ensure(req.params.assistantID))); app.post('/api/yuri/assistants/:assistantID/session/new', (req, res) => respond(res, () => service.createNew(req.params.assistantID))); app.post('/api/yuri/assistants/:assistantID/session/compact', (req, res) => respond(res, () => service.compact(req.params.assistantID, req.body))); app.post('/api/yuri/assistants/:assistantID/session/abort', (req, res) => respond(res, () => service.abort(req.params.assistantID, req.body)));
|
|
56
57
|
app.get('/api/yuri/assistants/:assistantID/messages', (req, res) => respond(res, () => service.historicalMessages(req.params.assistantID, req.query)));
|
|
@@ -76,7 +76,7 @@ import {
|
|
|
76
76
|
sanitizeAssignFileParts,
|
|
77
77
|
} from './assign.js';
|
|
78
78
|
import { runContactTurn as defaultRunContactTurn } from './harness.js';
|
|
79
|
-
import { loadConnectedCatalog } from '../llm/catalog.js';
|
|
79
|
+
import { loadConnectedCatalog, parseModelRef } from '../llm/catalog.js';
|
|
80
80
|
import { buildAssistantSessionMetadata } from '../session-metadata/system-session.js';
|
|
81
81
|
|
|
82
82
|
const require = createRequire(import.meta.url);
|
|
@@ -301,9 +301,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
301
301
|
const shareColumns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_share_operation')").all().map((column) => column.name));
|
|
302
302
|
if (!shareColumns.has('attempt')) db.exec('ALTER TABLE assistant_share_operation ADD COLUMN attempt INTEGER NOT NULL DEFAULT 0');
|
|
303
303
|
if (!shareColumns.has('lease_expires_at')) db.exec('ALTER TABLE assistant_share_operation ADD COLUMN lease_expires_at INTEGER');
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
db.prepare("INSERT OR IGNORE INTO assistant_meta(key,value) VALUES ('enabled','0')").run(); db.prepare("INSERT OR IGNORE INTO assistant_meta(key,value) VALUES ('revision','0')").run();
|
|
304
|
+
// Assistants are always available; retain the field for older clients.
|
|
305
|
+
db.prepare("INSERT OR IGNORE INTO assistant_meta(key,value) VALUES ('enabled','1')").run(); db.prepare("INSERT OR IGNORE INTO assistant_meta(key,value) VALUES ('revision','0')").run();
|
|
307
306
|
const now = () => Math.trunc(clock());
|
|
308
307
|
const revision = () => Number(db.prepare("SELECT value FROM assistant_meta WHERE key='revision'").get().value);
|
|
309
308
|
const bump = () => {
|
|
@@ -312,7 +311,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
312
311
|
if (typeof onRevisionTip === 'function') queueMicrotask(() => { if (!closed) onRevisionTip({ revision: value, occurredAt: now() }); });
|
|
313
312
|
return value;
|
|
314
313
|
};
|
|
315
|
-
const enabled = () =>
|
|
314
|
+
const enabled = () => true;
|
|
315
|
+
db.transaction(() => {
|
|
316
|
+
if (db.prepare("UPDATE assistant_meta SET value='1' WHERE key='enabled' AND value!='1'").run().changes) bump();
|
|
317
|
+
})();
|
|
316
318
|
const assistant = (assistantID) => db.prepare('SELECT * FROM assistant_v2 WHERE assistant_id=?').get(assistantID);
|
|
317
319
|
const editable = (assistantID) => { const row = assistant(assistantID); if (!row || row.tombstone_at) fail('not_found'); return row; };
|
|
318
320
|
const active = (assistantID) => { const row = editable(assistantID); if (!enabled() || !row.enabled) fail('assistant_disabled'); return row; };
|
|
@@ -1314,7 +1316,52 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1314
1316
|
}
|
|
1315
1317
|
}
|
|
1316
1318
|
};
|
|
1317
|
-
const createAssistant = (input
|
|
1319
|
+
const createAssistant = (input, { onlyIfEmpty = false } = {}) => {
|
|
1320
|
+
const allowed = new Set(['enabled', 'name', 'avatarSeed', 'defaultPrompt', 'providerID', 'modelID', 'agent', 'variant', 'mode', 'workspacePath']);
|
|
1321
|
+
if (!plainObject(input) || Object.keys(input).some((key) => !allowed.has(key))) fail('validation_error');
|
|
1322
|
+
const mode = input.mode == null ? 'continuous' : input.mode === 'stateless' || input.mode === 'continuous' ? input.mode : fail('validation_error');
|
|
1323
|
+
const assistantID = id();
|
|
1324
|
+
const workspacePath = input.workspacePath == null ? null : workspace(input.workspacePath, assistantID);
|
|
1325
|
+
const at = now();
|
|
1326
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1327
|
+
try {
|
|
1328
|
+
// Tombstones count: explicitly deleting the last contact must not recreate it.
|
|
1329
|
+
if (onlyIfEmpty && db.prepare('SELECT 1 FROM assistant_v2 LIMIT 1').get()) {
|
|
1330
|
+
db.exec('COMMIT');
|
|
1331
|
+
return null;
|
|
1332
|
+
}
|
|
1333
|
+
if (Number(db.prepare('SELECT COUNT(*) AS count FROM assistant_v2 WHERE tombstone_at IS NULL').get().count) >= 100) fail('assistant_limit');
|
|
1334
|
+
effectiveWorkspace({ assistant_id: assistantID, workspace_path: workspacePath });
|
|
1335
|
+
db.prepare('INSERT INTO assistant_v2 (assistant_id,revision,enabled,name,default_prompt,workspace_path,provider_id,model_id,agent,variant,mode,current_session_id,session_generation,created_at,updated_at,tombstone_at,avatar_seed) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)').run(assistantID, 1, input.enabled === false ? 0 : 1, string(input.name, 256, true), input.defaultPrompt ? string(input.defaultPrompt, 200_000) : '', workspacePath, string(input.providerID, 256, true), string(input.modelID, 256, true), input.agent == null ? null : string(input.agent, 256), input.variant == null ? null : string(input.variant, 256), mode, null, 0, at, at, null, input.avatarSeed === undefined ? null : string(input.avatarSeed, 256, true));
|
|
1336
|
+
bump();
|
|
1337
|
+
db.exec('COMMIT');
|
|
1338
|
+
return output(assistant(assistantID));
|
|
1339
|
+
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
1340
|
+
};
|
|
1341
|
+
|
|
1342
|
+
let defaultAssistantFlight = null;
|
|
1343
|
+
const ensureDefaultAssistant = () => {
|
|
1344
|
+
if (closed || db.prepare('SELECT 1 FROM assistant_v2 LIMIT 1').get()) return Promise.resolve();
|
|
1345
|
+
if (defaultAssistantFlight) return defaultAssistantFlight;
|
|
1346
|
+
defaultAssistantFlight = (async () => {
|
|
1347
|
+
const [catalog, preferences] = await Promise.all([
|
|
1348
|
+
loadConnectedCatalog(client(), { timeoutMs: CONTACT_CATALOG_DEADLINE_MS }),
|
|
1349
|
+
awaitWithDeadline(Promise.resolve().then(() => readModelPreferences()), CONTACT_CATALOG_DEADLINE_MS, 'upstream_error'),
|
|
1350
|
+
]);
|
|
1351
|
+
if (closed) return;
|
|
1352
|
+
const [modelRef, inlineVariant] = typeof preferences?.defaultModel === 'string' ? preferences.defaultModel.split('#') : [];
|
|
1353
|
+
const preferred = parseModelRef(modelRef);
|
|
1354
|
+
const preferredModel = preferred && catalog.models.find((model) => model.providerID === preferred.providerID && model.modelID === preferred.modelID);
|
|
1355
|
+
const model = preferredModel || catalog.models[0];
|
|
1356
|
+
// No provider configured yet: retry on the next snapshot after configuration.
|
|
1357
|
+
if (!model) return;
|
|
1358
|
+
createAssistant({
|
|
1359
|
+
name: 'Yuri', providerID: model.providerID, modelID: model.modelID,
|
|
1360
|
+
...(preferredModel && (inlineVariant || preferences?.defaultVariant) ? { variant: inlineVariant || preferences.defaultVariant } : {}),
|
|
1361
|
+
}, { onlyIfEmpty: true });
|
|
1362
|
+
})().finally(() => { defaultAssistantFlight = null; });
|
|
1363
|
+
return defaultAssistantFlight;
|
|
1364
|
+
};
|
|
1318
1365
|
const updateAssistant = async (assistantID, input) => { const row = editable(assistantID); const allowed = new Set(['expectedRevision', 'enabled', 'name', 'avatarSeed', 'defaultPrompt', 'providerID', 'modelID', 'agent', 'variant', 'mode', 'workspacePath']); if (!plainObject(input) || !Number.isInteger(input.expectedRevision) || Object.keys(input).some((key) => !allowed.has(key))) fail('validation_error'); const next = { avatarSeed: input.avatarSeed === undefined ? row.avatar_seed : string(input.avatarSeed, 256, true), enabled: input.enabled === undefined ? row.enabled : input.enabled ? 1 : 0, name: input.name === undefined ? row.name : string(input.name, 256, true), prompt: input.defaultPrompt === undefined ? row.default_prompt : string(input.defaultPrompt, 200_000), provider: input.providerID === undefined ? row.provider_id : string(input.providerID, 256, true), model: input.modelID === undefined ? row.model_id : string(input.modelID, 256, true), agent: input.agent === undefined ? row.agent : input.agent === null ? null : string(input.agent, 256), variant: input.variant === undefined ? row.variant : input.variant === null ? null : string(input.variant, 256), mode: input.mode === undefined ? (row.mode === 'stateless' ? 'stateless' : 'continuous') : input.mode === 'continuous' || input.mode === 'stateless' ? input.mode : fail('validation_error'), workspacePath: input.workspacePath === undefined ? row.workspace_path : input.workspacePath == null ? null : workspace(input.workspacePath, assistantID) }; const nextRow = { ...row, workspace_path: next.workspacePath, name: next.name }; effectiveWorkspace(nextRow); const workspaceChanged = next.workspacePath !== row.workspace_path; if (workspaceChanged && row.current_session_id) archiveSession(assistantID, row.current_session_id); const created = workspaceChanged ? await createSession(nextRow) : null; const result = db.prepare('UPDATE assistant_v2 SET avatar_seed=?,enabled=?,name=?,default_prompt=?,provider_id=?,model_id=?,agent=?,variant=?,mode=?,workspace_path=?,current_session_id=?,session_generation=session_generation+?,revision=revision+1,updated_at=? WHERE assistant_id=? AND revision=? AND session_generation=? AND tombstone_at IS NULL').run(next.avatarSeed, next.enabled, next.name, next.prompt, next.provider, next.model, next.agent, next.variant, next.mode, next.workspacePath, created?.sessionID ?? row.current_session_id, workspaceChanged ? 1 : 0, now(), assistantID, input.expectedRevision, row.session_generation); if (!result.changes) fail('revision_conflict'); bump(); return output(assistant(assistantID)); };
|
|
1319
1366
|
const compact = async (assistantID, input) => { const row = active(assistantID); if (!plainObject(input) || input.sessionID !== row.current_session_id || input.sessionGeneration !== row.session_generation || !row.current_session_id) fail('revision_conflict'); let target = row; let result = await invokeSession(() => client().session.compact({ sessionID: target.current_session_id })); if (isMissing(result)) { await restoreOnce(row, row.current_session_id, row.session_generation); target = active(assistantID); result = await invokeSession(() => client().session.compact({ sessionID: target.current_session_id })); } if (result?.error || (result?.data !== true && result?.id == null && result?.type !== 'compaction')) fail('upstream_error'); return { binding: binding(active(assistantID)), summarized: true }; };
|
|
1320
1367
|
const sendWithConfig = async ({ row, sessionID, directory, config, parts, messageID, restore }) => {
|
|
@@ -3727,7 +3774,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
3727
3774
|
)),
|
|
3728
3775
|
};
|
|
3729
3776
|
};
|
|
3730
|
-
return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId(), sharingAvailable: false, archiveMetadataAvailable: typeof archiveSessionHost === 'function', sessionMetadataAvailable: false }), snapshot, createAssistant, updateAssistant, setEnabled: (input) => { if (!plainObject(input) || typeof input.enabled !== 'boolean' || input.expectedRevision !== revision()) fail('revision_conflict');
|
|
3777
|
+
return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId(), sharingAvailable: false, archiveMetadataAvailable: typeof archiveSessionHost === 'function', sessionMetadataAvailable: false }), snapshot, ensureDefaultAssistant, createAssistant, updateAssistant, setEnabled: (input) => { if (!plainObject(input) || typeof input.enabled !== 'boolean' || input.expectedRevision !== revision()) fail('revision_conflict'); return { enabled: true, revision: revision() }; }, removeAssistant: (assistantID, expectedRevision) => {
|
|
3731
3778
|
db.exec('BEGIN IMMEDIATE');
|
|
3732
3779
|
try {
|
|
3733
3780
|
const result = db.prepare('UPDATE assistant_v2 SET tombstone_at=?,revision=revision+1,updated_at=? WHERE assistant_id=? AND revision=? AND tombstone_at IS NULL').run(now(), now(), assistantID, expectedRevision);
|
|
@@ -93,9 +93,8 @@ const createV2Client = (overrides = {}) => {
|
|
|
93
93
|
...(config ? { config } : {}),
|
|
94
94
|
};
|
|
95
95
|
};
|
|
96
|
-
// Behavioral tests enable the global switch after boot; pass enabled:false to assert the fresh-install default.
|
|
97
96
|
const setup = (directory = root(), client = {}, options = {}) => {
|
|
98
|
-
const
|
|
97
|
+
const serviceOptions = options;
|
|
99
98
|
const service = createAssistantsService({
|
|
100
99
|
dbPath: path.join(directory, 'assistants.sqlite'),
|
|
101
100
|
dataDir: directory,
|
|
@@ -106,10 +105,6 @@ const setup = (directory = root(), client = {}, options = {}) => {
|
|
|
106
105
|
runContactTurn: serviceOptions.runContactTurn ?? (async ({ userText }) => ({ text: `reply:${userText}`, bubbles: [`reply:${userText}`] })),
|
|
107
106
|
...serviceOptions,
|
|
108
107
|
});
|
|
109
|
-
if (enabled) {
|
|
110
|
-
const snapshot = service.snapshot();
|
|
111
|
-
if (!snapshot.enabled) service.setEnabled({ enabled: true, expectedRevision: snapshot.revision });
|
|
112
|
-
}
|
|
113
108
|
return service;
|
|
114
109
|
};
|
|
115
110
|
/** Wait for the async contact turn after 202 admission (not part of the HTTP body). */
|
|
@@ -705,6 +700,7 @@ describe('assistants service', () => {
|
|
|
705
700
|
});
|
|
706
701
|
expect(typeof afterUser.messageID).toBe('string');
|
|
707
702
|
expect(Number.isFinite(afterUser.ordinal)).toBe(true);
|
|
703
|
+
expect(Number.isFinite(afterUser.createdAt)).toBe(true);
|
|
708
704
|
|
|
709
705
|
const updated = await service.updateAssistant(created.id, {
|
|
710
706
|
expectedRevision: service.snapshot().assistants[0].revision,
|
|
@@ -1537,11 +1533,64 @@ describe('assistants service', () => {
|
|
|
1537
1533
|
service.close();
|
|
1538
1534
|
});
|
|
1539
1535
|
|
|
1540
|
-
it('
|
|
1541
|
-
const fresh = setup(
|
|
1536
|
+
it('always enables Assistants and migrates the old disabled flag without discarding contacts', async () => {
|
|
1537
|
+
const fresh = setup(); expect(await fresh.capability()).toMatchObject({ supported: true, enabled: true, revision: 0 }); expect(fresh.snapshot()).toMatchObject({ enabled: true, revision: 0 }); expect(fresh.setEnabled({ enabled: false, expectedRevision: 0 })).toMatchObject({ enabled: true, revision: 0 }); fresh.close();
|
|
1542
1538
|
const directory = root(); const Database = require('better-sqlite3'); const db = new Database(path.join(directory, 'assistants.sqlite'));
|
|
1543
|
-
db.exec('CREATE TABLE assistant_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)'); db.prepare("INSERT INTO assistant_meta VALUES ('enabled','
|
|
1544
|
-
const persisted = setup(directory
|
|
1539
|
+
db.exec('CREATE TABLE assistant_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)'); db.prepare("INSERT INTO assistant_meta VALUES ('enabled','0')").run(); db.prepare("INSERT INTO assistant_meta VALUES ('revision','3')").run(); db.prepare("INSERT INTO assistant_meta VALUES ('schema_version','8')").run(); db.close();
|
|
1540
|
+
const persisted = setup(directory); expect(await persisted.capability()).toMatchObject({ supported: true, enabled: true, revision: 4 });
|
|
1541
|
+
const saved = persisted.createAssistant(assistantInput); persisted.close();
|
|
1542
|
+
const reopened = setup(directory);
|
|
1543
|
+
await reopened.ensureDefaultAssistant();
|
|
1544
|
+
expect(reopened.snapshot().assistants.map(item => item.id)).toEqual([saved.id]);
|
|
1545
|
+
expect(reopened.snapshot().revision).toBe(5);
|
|
1546
|
+
reopened.close();
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
it('seeds one default contact across concurrent clients and restarts, honoring the configured model', async () => {
|
|
1550
|
+
const directory = root();
|
|
1551
|
+
const modelList = vi.fn(async () => ({ data: [{ providerID: 'p', id: 'first' }, { providerID: 'p', id: 'preferred' }] }));
|
|
1552
|
+
const client = { model: { list: modelList } };
|
|
1553
|
+
const options = { readModelPreferences: async () => ({ defaultModel: 'p/preferred', defaultVariant: 'fast' }) };
|
|
1554
|
+
const first = setup(directory, client, options);
|
|
1555
|
+
const second = setup(directory, client, options);
|
|
1556
|
+
await Promise.all([first.ensureDefaultAssistant(), first.ensureDefaultAssistant(), second.ensureDefaultAssistant()]);
|
|
1557
|
+
expect(first.snapshot().assistants).toHaveLength(1);
|
|
1558
|
+
const seeded = first.snapshot().assistants[0];
|
|
1559
|
+
expect(seeded).toMatchObject({ name: 'Yuri', enabled: true, providerID: 'p', modelID: 'preferred', variant: 'fast', workspacePath: null, sessionID: null });
|
|
1560
|
+
expect(modelList).toHaveBeenCalledTimes(2);
|
|
1561
|
+
first.close(); second.close();
|
|
1562
|
+
const reopened = setup(directory, client, options);
|
|
1563
|
+
await reopened.ensureDefaultAssistant();
|
|
1564
|
+
expect(reopened.snapshot().assistants[0].id).toBe(seeded.id);
|
|
1565
|
+
reopened.removeAssistant(seeded.id, seeded.revision);
|
|
1566
|
+
await reopened.ensureDefaultAssistant();
|
|
1567
|
+
expect(reopened.snapshot().assistants).toEqual([]);
|
|
1568
|
+
expect(modelList).toHaveBeenCalledTimes(2);
|
|
1569
|
+
reopened.close();
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
it('retries default seeding after catalog failures or no configured model, without writing a bogus contact', async () => {
|
|
1573
|
+
const modelList = vi.fn().mockRejectedValueOnce(new Error('catalog unavailable')).mockResolvedValueOnce({ data: [] }).mockResolvedValue({ data: [{ providerID: 'p', id: 'm' }] });
|
|
1574
|
+
const service = setup(root(), { model: { list: modelList } });
|
|
1575
|
+
await expect(service.ensureDefaultAssistant()).rejects.toThrow();
|
|
1576
|
+
expect(service.snapshot().assistants).toEqual([]);
|
|
1577
|
+
await service.ensureDefaultAssistant();
|
|
1578
|
+
expect(service.snapshot().assistants).toEqual([]);
|
|
1579
|
+
await service.ensureDefaultAssistant();
|
|
1580
|
+
expect(service.snapshot().assistants).toHaveLength(1);
|
|
1581
|
+
service.close();
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
it('does not seed over a contact created while the model catalog is loading', async () => {
|
|
1585
|
+
let resolve;
|
|
1586
|
+
const pending = new Promise(done => { resolve = done; });
|
|
1587
|
+
const service = setup(root(), { model: { list: () => pending } });
|
|
1588
|
+
const seed = service.ensureDefaultAssistant();
|
|
1589
|
+
const created = service.createAssistant(assistantInput);
|
|
1590
|
+
resolve({ data: [{ providerID: 'p', id: 'm' }] });
|
|
1591
|
+
await seed;
|
|
1592
|
+
expect(service.snapshot().assistants.map(item => item.id)).toEqual([created.id]);
|
|
1593
|
+
service.close();
|
|
1545
1594
|
});
|
|
1546
1595
|
|
|
1547
1596
|
it('permits a disabled assistant to be re-enabled through an editable CAS patch', async () => {
|
|
@@ -319,7 +319,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
319
319
|
},
|
|
320
320
|
readModelPreferences: async () => {
|
|
321
321
|
const settings = await readSettingsFromDiskStrict();
|
|
322
|
-
return { favoriteModels: settings.favoriteModels, recentModels: settings.recentModels };
|
|
322
|
+
return { defaultModel: settings.defaultModel, defaultVariant: settings.defaultVariant, favoriteModels: settings.favoriteModels, recentModels: settings.recentModels };
|
|
323
323
|
},
|
|
324
324
|
listScheduledTasks: (projectID) => projectConfigRuntime.listScheduledTasks(projectID),
|
|
325
325
|
sessionIndexService,
|
|
@@ -18,6 +18,8 @@ Traffic is modeled as three stacked layers. The relay understands only Layer 1;
|
|
|
18
18
|
|
|
19
19
|
## Entrypoints and structure
|
|
20
20
|
|
|
21
|
+
The Host and pairing UI default to `wss://relay.openchamber.dev/ws`. Host endpoint precedence is `YURI_RELAY_URL`, saved `privateRelay.relayUrl`, then the product default. Existing saved pairing endpoints remain authoritative for reconnects.
|
|
22
|
+
|
|
21
23
|
Host side (`packages/web/server/lib/relay/`):
|
|
22
24
|
- `service.js` — thin entrypoint: relay config (enabled flag + endpoints), the management routes (`GET/POST /api/yuri/relay/{status,enable,disable}`, `POST /api/yuri/relay/endpoints`), a `getPairingCandidate()` accessor (the relay transport candidate folded into pairing-v2 links when enabled, consumed by the pairing-session route in `core-routes.js`), and lifecycle wiring. **Multi-relay:** `settings.privateRelay = { enabled, relayUrl, extraRelayUrls }` — the host dials EVERY configured endpoint simultaneously with one identity (same serverId); clients treat `serverId + relayUrl` as the instance identity, so the same machine exposed via two domains appears as two separately named instances. The primary (`relayUrl`) owns push registration and stays the default pairing candidate. A pairing link created for an endpoint the host does not have yet APPENDS it as an extra endpoint instead of replacing the primary, so devices paired over the old domain keep working. `POST /relay/endpoints` (owner session / `desktop-local` only) replaces the extra-endpoint list wholesale and starts/stops host connections incrementally. Paired desktop operators may also add an endpoint through pairing creation. Host lifecycle is gated by `isRelayHostRuntime()` (`desktop` or `ssh-remote`): other runtimes (`bun run dev`, `dev:web:hmr`, ordinary CLI `serve`, VS Code, plain `node server`) report `state: 'unavailable'`, refuse `/relay/enable`, `/relay/disable`, `/relay/endpoints`, and relay pairing with 403, never mint a relay identity, never advertise `relayAvailable`, and never call `startRelayHost`. Direct `node server/index.js` forces `YURI_RUNTIME=web` unless it is already `ssh-remote`, so a leftover desktop env cannot open the host-control socket. On host runtimes, started from `packages/web/server/index.js` when demand/opt-in enables the relay. Desktop auto-clears `privateRelay.enabled` when demand drops; `ssh-remote` keeps an explicit opt-in (e.g. `yuri serve --relay-host`) sticky until disabled. The relay endpoint has no hosted default and must be configured in settings or pinned via the `YURI_RELAY_URL` env var; when set it overrides the stored setting for the host connection, the pairing candidate, and status, so paired clients inherit the endpoint automatically. Endpoint identity is scheme/host/path only: accepted schemes are `ws://` and `wss://`; URLs with userinfo are rejected (no silent default fallback); query and fragment are stripped before persistence and pairing candidates. Custom endpoint persistence and Host control-connection switching are explicit authenticated management actions. Pairing creation with `relayUrl` requires an owner UI session, local `desktop-local` shell client, or paired `desktop` operator; `/relay/enable` follows its API auth gate. `derivePushSendUrlFromRelayUrl` maps a canonical Relay `wss://`/`ws://` URL to the same host's `https://`/`http://` `/v1/push/send` path (register is `/v1/push/register-token`); `YURI_PUSH_RELAY_URL` on the Host overrides that derivation. The isolated Push process lives in `packages/relay-server/src/push/` and does not share Layer 1 credentials or tunnel state.
|
|
23
25
|
- `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly.
|
|
@@ -23,7 +23,7 @@ import express from 'express';
|
|
|
23
23
|
import { createRelayIdentityRuntime } from './identity.js';
|
|
24
24
|
import { startRelayHost } from './host-client.js';
|
|
25
25
|
|
|
26
|
-
export const DEFAULT_RELAY_URL = '';
|
|
26
|
+
export const DEFAULT_RELAY_URL = 'wss://relay.openchamber.dev/ws';
|
|
27
27
|
|
|
28
28
|
export const RELAY_HOST_DESKTOP_ONLY_MESSAGE =
|
|
29
29
|
'Relay host is only available in the Yuri desktop app or an SSH-managed remote server';
|
|
@@ -33,7 +33,7 @@ vi.mock('./host-client.js', () => ({
|
|
|
33
33
|
|
|
34
34
|
import {
|
|
35
35
|
createRelayService,
|
|
36
|
-
DEFAULT_RELAY_URL as
|
|
36
|
+
DEFAULT_RELAY_URL as PRODUCT_RELAY_URL,
|
|
37
37
|
derivePushSendUrlFromRelayUrl,
|
|
38
38
|
isRelayHostRuntime,
|
|
39
39
|
RELAY_HOST_DESKTOP_ONLY_MESSAGE,
|
|
@@ -91,8 +91,8 @@ describe('resolveEffectiveRelayUrl', () => {
|
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
it('prefers a valid env URL, then settings, then the default', () => {
|
|
94
|
-
expect(resolveEffectiveRelayUrl()).toBe(
|
|
95
|
-
expect(
|
|
94
|
+
expect(resolveEffectiveRelayUrl()).toBe(PRODUCT_RELAY_URL);
|
|
95
|
+
expect(PRODUCT_RELAY_URL).toBe('wss://relay.openchamber.dev/ws');
|
|
96
96
|
expect(
|
|
97
97
|
resolveEffectiveRelayUrl({
|
|
98
98
|
settings: { privateRelay: { relayUrl: 'wss://settings.example/ws' } },
|