From 8b507e7953bd877a795cda6689b9db8c10507680 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sat, 3 Oct 2026 20:32:40 +0000 Subject: [PATCH] feat: initial Lam Agent extension (#1) --- .gitignore | 14 + .prettierrc | 6 + LICENSE | 21 + README.md | 177 +- assets/extension-icon.png | Bin 0 -> 22477 bytes eslint.config.js | 26 + package-lock.json | 3725 ++++++++++++++++++++++++++++ package.json | 112 + src/__mocks__/@vicinae/api.ts | 48 + src/__tests__/agent.test.ts | 173 ++ src/__tests__/history.test.ts | 68 + src/__tests__/location.test.ts | 68 + src/__tests__/markdown.test.ts | 117 + src/__tests__/ollama.test.ts | 86 + src/__tests__/sessions.test.ts | 161 ++ src/__tests__/utils.test.ts | 65 + src/chat.tsx | 17 + src/lib/agent.ts | 134 + src/lib/components/ChatView.tsx | 238 ++ src/lib/components/ComposeForm.tsx | 58 + src/lib/constants.ts | 33 + src/lib/history.ts | 30 + src/lib/hooks/useChat.ts | 268 ++ src/lib/location.ts | 100 + src/lib/markdown.ts | 98 + src/lib/ollama.ts | 290 +++ src/lib/preferences.ts | 54 + src/lib/schemas.ts | 49 + src/lib/sessions.ts | 182 ++ src/lib/tools.ts | 242 ++ src/lib/types.ts | 87 + src/lib/utils.ts | 56 + src/sessions.tsx | 113 + src/setup.tsx | 185 ++ tsconfig.json | 16 + vitest.config.mts | 21 + 36 files changed, 7136 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 LICENSE create mode 100644 assets/extension-icon.png create mode 100644 eslint.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/__mocks__/@vicinae/api.ts create mode 100644 src/__tests__/agent.test.ts create mode 100644 src/__tests__/history.test.ts create mode 100644 src/__tests__/location.test.ts create mode 100644 src/__tests__/markdown.test.ts create mode 100644 src/__tests__/ollama.test.ts create mode 100644 src/__tests__/sessions.test.ts create mode 100644 src/__tests__/utils.test.ts create mode 100644 src/chat.tsx create mode 100644 src/lib/agent.ts create mode 100644 src/lib/components/ChatView.tsx create mode 100644 src/lib/components/ComposeForm.tsx create mode 100644 src/lib/constants.ts create mode 100644 src/lib/history.ts create mode 100644 src/lib/hooks/useChat.ts create mode 100644 src/lib/location.ts create mode 100644 src/lib/markdown.ts create mode 100644 src/lib/ollama.ts create mode 100644 src/lib/preferences.ts create mode 100644 src/lib/schemas.ts create mode 100644 src/lib/sessions.ts create mode 100644 src/lib/tools.ts create mode 100644 src/lib/types.ts create mode 100644 src/lib/utils.ts create mode 100644 src/sessions.tsx create mode 100644 src/setup.tsx create mode 100644 tsconfig.json create mode 100644 vitest.config.mts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ba6090 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# generated by the vicinae toolchain +vicinae-env.d.ts + +# build output +/dist +*.tgz + +# misc +.DS_Store diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..d57f41d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 110, + "singleQuote": false, + "semi": true, + "trailingComma": "all" +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1a05ebc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Adrian Bonpin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 478a57b..366adb9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,176 @@ -# ned-agent-vicinae +# Lam Agent for Vicinae -test \ No newline at end of file +Agentic AI chat inside the [Vicinae](https://vicinae.com) launcher, powered by [Ollama](https://ollama.com). + +Hit a global shortcut, type a question, get a streamed answer — without leaving whatever you were doing. + + +> **Status:** early. Chat, streaming, persistent sessions and the web tools work. Anything that *acts* on your machine (opening links, clipboard, launching apps) is deliberately not here yet — see [Roadmap](#roadmap). + +--- + +## What it does + +- **Global hotkey, instant input.** Bind a shortcut to the chat command and the search bar is focused before you finish the keystroke. +- **Streaming answers.** Tokens render as they arrive, in a markdown side panel. +- **Persistent conversations.** Every chat is saved locally and can be reopened later. +- **Tools.** Lam decides when to call them — current time, location context, web search, and page fetching. +- **Chat only.** Lam answers questions. It does not touch your files or run commands. + +## Requirements + +- Vicinae with the extension runtime enabled (Node.js 20+ on `PATH`) +- An [Ollama](https://ollama.com) account and API key — [create one here](https://ollama.com/settings/keys) + +Ollama Cloud is the default. A local Ollama server works too: point **Ollama Host** at `http://localhost:11434`. Note that the web tools are a cloud feature and are not available from a local server. + +## Install + +```sh +npm install +npm run dev # build + hot reload into a running Vicinae +``` + +Or build once: + +```sh +npm run build +``` + +## Setup + +Setup is two prompts on first launch, then one command you can revisit any time. + +1. **The API key.** Vicinae prompts for required preferences before a command runs, so the very first launch asks for your Ollama API key. +2. **The model.** The next prompt asks which model to use — type it exactly, e.g. `deepseek-v4.1-flash:cloud` or `gpt-oss:120b`. +3. **`Set Up Lam Agent`** verifies the connection, lists every model your key can actually reach, and copies a model name to the clipboard so you can paste it into preferences. + +To change the model later, either edit the **Model** preference directly, or run **Set Up Lam Agent** and copy the name of a model you would rather use. + +> An extension can read preferences but not write them, which is why setup hands you the exact name to paste rather than saving it for you. It also means the model list can never drift out of sync with your account. + +## Keyboard shortcuts + +| Keys | Action | +| --- | --- | +| `↩` | Send the message in the search bar | +| `⌘ ↩` | Compose a longer, multi-line message | +| `Tab` | Jump to the conversation switcher in the search bar | +| `⌘ N` | New chat | +| `⌘ ⇧ C` | Copy the highlighted answer | +| `⌘ ⌫` | Delete a conversation (in **Chat Sessions**) | + +Inside the composer: `↩` inserts a new line, `⌘ ↩` sends. + +### Suggested global shortcuts + +Assign these in **Vicinae Settings → Extensions → Lam Agent**, or bind them in your compositor/window manager with [deeplinks](https://docs.vicinae.com/deeplinks): + +```sh +vicinae 'vicinae://launch/lam-agent/chat?toggle=true' +``` + +```ini +# Hyprland +bind = $mainAlt, Space, exec, vicinae 'vicinae://launch/lam-agent/chat?toggle=true' + +# niri +Mod+Shift+Space repeat=false { spawn "vicinae" "vicinae://launch/lam-agent/chat?toggle=true"; } +``` + +`fallbackText` pre-fills the prompt, which makes a handy "ask about this" command: + +```sh +vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this' +``` + +## Commands + +| Command | Purpose | +| --- | --- | +| **Chat with Lam** | The chat surface. Bind this to your global shortcut. | +| **Chat Sessions** | Browse, reopen, and delete past conversations. | +| **Set Up Lam Agent** | Verify the connection and list available models. | + +## Configuration + +| Preference | Default | Notes | +| --- | --- | --- | +| **Ollama API Key** | — | Required. Stored by Vicinae as a password preference. | +| **Model** | — | Required. Any model your key can reach. | +| **Ollama Host** | `https://ollama.com` | Set to `http://localhost:11434` for a local server. | +| **System Prompt** | Lam's default | Replaces the built-in prompt entirely. | +| **Web Tools** | on | Enables `web_search` and `web_fetch`. | +| **Reasoning Effort** | Off | `low`/`medium`/`high` for models that support thinking. | + +## Tools + +Lam calls these on its own when they would improve an answer. All four are read-only. + +| Tool | What it does | +| --- | --- | +| `get_datetime` | Current local date, time, weekday, timezone, and UTC offset. | +| `get_location` | Timezone, UTC offset, locale, region, and city. | +| `web_search` | Searches the web; returns titles, URLs, and snippets. | +| `web_fetch` | Fetches one page and returns its content as markdown. | + +`get_location` is derived from your device timezone rather than GPS or an IP lookup, so no third-party geolocation service sees your address. Treat it as "which region am I in", not "where am I". + +Web search results and fetched pages can be large. Lam truncates them before they reach the model, and gives up after 5 rounds of tool calls rather than looping. + +## Architecture + +``` +src/ + chat.tsx # "chat" command entrypoint — thin wrapper + sessions.tsx # browse, reopen, delete conversations + setup.tsx # connection check + live model list + lib/ + ollama.ts # the only file that talks HTTP + agent.ts # the tool-calling loop + tools.ts # tool schemas + dispatch + sessions.ts # LocalStorage persistence + history.ts # turns -> API messages + location.ts # time/place, derived locally + markdown.ts # turn -> detail-panel markdown + preferences.ts # preferences, normalised + hooks/useChat.ts # the chat state machine + components/ChatView.tsx # the chat surface + components/ComposeForm.tsx +``` + +A few decisions worth knowing about before you change things: + +**The search bar is the composer.** It is the only always-focused text input in a `List`, which is what makes "hotkey, then just type" work. It is also single-line and there is no API to change that, which is the entire reason `ComposeForm` exists. `filtering` is off, because this list is a transcript — the builtin fuzzy filter would hide turns as you type a question. + +**Every transcript row shares one primary action**, so `↩` always sends whatever is in the search bar regardless of which row is selected. That is what makes the window feel like a single input box rather than a menu. + +**Turns render newest first**, so the answer you just asked for is nearest the search bar instead of below the fold. + +**Streamed deltas are buffered in refs and flushed to state on a timer** (`FLUSH_INTERVAL_MS`). Re-rendering the transcript per token makes the launcher visibly janky. + +**The logic layer has no Vicinae imports.** `agent.ts`, `ollama.ts`, `tools.ts` and friends are plain TypeScript, which is why they unit test without a runtime. A test enforces this. + +**Sessions are capped twice over** — per turn and per session — because `LocalStorage` has no documented ceiling. + +## Development + +```sh +npm run dev # watch + reload +npm run typecheck +npm run lint:code +npm test +``` + +`vicinae-env.d.ts` is generated by the toolchain and gitignored; `src/lib/preferences.ts` declares the preference shape by hand so the logic layer needs no codegen step. + +## Roadmap + +- **Actions.** Opening links, clipboard access, launching apps. These need an approval step first — an assistant that can silently act on your machine is a different risk profile from one that answers questions, and this release does not pretend otherwise. +- **`@` mentions** for files and clipboard content. +- **Per-conversation model** overrides. +- **Image input**, which Ollama's chat API already supports. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/assets/extension-icon.png b/assets/extension-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..78c06aa3a75d12ec297786847935d80d9374e5bb GIT binary patch literal 22477 zcma(2cT`i)7e0)hP*p%wK%@vrSENX-sPrNoY0{-7v7s~p=^do0bP$jlY(VJJ zdqi50CLxqS%6s^J-ru_G-n-tl-hZN;GqY#UE;DPz`QHpL zBqPvAjm}QF0f9je+D}3k{&F^S)m1o!wR}1`B=I@NYs;uT7$4eeYq}U1Ez_44tww$- zeO#7$)9@4T+_YQXVY&}gq1jt^UN+63p^Kg75s5WjK37p`gl_SoN0zdH~% z^>g0r+Oq>;LrPA!mu%`DomUMr zXZgP-=_-eQy4ad8z4}2+zx$i}hbp1l7shbaSW|S}eFK%rG7qzyKGz)~>Q1p+U3wSB zNC=g+A2YDH-DaB+*#iy{4@3D%rbfjzp5S+CymWV(qKh})DRz|Ed#ww zVT%h|cREOUF2};?nP}VOcHe0gr)Bo~#yoi$xdUZ%@B@0;Y3 zd$PAm&7}<;b(3`?!^|R|?C;8HW9#UVk-u}CyWDm8W(+UTEGC})mc?u7-q6+bCaGb9 z&c4rTdO!CkOx>Wy>S47V`8mO&=N`~s@M;aW;;3@7CxFr$KO?@h^Vt!T+U(_VpNqp2 z8u7>Goia&vP0R@oNYMu{#-ZbOESO%1a_(kUuqF7`c2x}so;;S9_uqd&`R=u~`PH^T zkKwE^bMSiri&Y-kE(MdwG-C5PgRq@%+=-0zYRHQx*b5|!5za}!h}^7d#9X0%e(YIb ze#K5PpKlaX5&zsI7Gmp{XrO*=FtSf+&p6#)JtAOt`sTwgQuewvk)k1qOc8@K>aHmv zd-)*?6F(odADmAYMz|XMJC>?Anf_>bnSK6l+!8fx{c|h?>Q`_X`?y?i_hyBUyvC8S zu1v>j?@gV_5V4zqFYL}RdO-@9VQ%Te&Ad}#j)vdrWzyTBF;VOr$0v#lt8SYcn`04| zc!7dVV%)Rf&8qQLj`@Uw@hl79c#@46+O7H+kDXUJPw04pNz;&eqU-It67rQ^#Lm|= zB<~6#*~{#wo)%s|z3<#~7u`n5e!SkaA3LY9O9!|5cdkJ9oLCO_Z3yOzO ziS|J3u?9Ei6(kj3KEz6p%pf^;>{|N^Q6sEq7E%A%amCzrxNvYu>czXGpg5@i0p?BZ8uoI^z-pQp#=oiko*dxN`Q?6- zXw_tQg%4Un;>4iO8DZXk5qo&MK#~DTEM1AFP=m%p27I-Tm1ckSsAwW*rb5MZgGp$Y z8qj2b_&ILG6NGh39sRaVokmb3e3_0yfB$vT54>kNJqH5eFUL z<-G(2e$(dknL0h^%NH1)ODtW-#LOM8_erQUj3=~1BF%unhwjRQzTQW*QeJIUv;qL!W+_ItOE=l}1ec@?Q@q-K40)>(zL!KKV$qt$Z?O9t)Bv7&&E`_nokHl;eDnGOlAm3dHZz82Mxf>1 zcLr+`oT6?;0+140H$)||OxS+?2Q8bWEi9zQ$}`Vre$I?z9{>7F5A@sm!dRJJW!)Z? z?KrCITdmB=Ec#S%ix#H7e!G>Z!0qh3;P!XFuwJ;V(K&2^+6k1Xl+Wc*!6?_|Rj(A9 zo9w-26FdzuW^ev_q_Vo7;aMTn-^8!+nakUQv5?A>L zbhEN$bv&wiU(?vEb4``NpM_nFl}=^0Q6#g=zfs?=yZH%_udbmX<^c7{@cw%Zzqa=8EZBiH=CJRo2sEQqu~Iu&hUb4*B5OkG54PX z4&K}A!>jwZ;yrMb=2jT1FJ`sM#6AZ z{~nc|0zGW<{@+(Ezn{AqGi*+6JsMUVNI2xu$I$~R8NZq#g{xu%wprh0(;L=kQ+yCv zrgCM*&`86lOYv@kYx$J&dVUqW5X(yj>q@__69|x+&3-{k;F*4Me5DsIq58`omq|7y zBfW2Fa78ftXbJ^kE+oe^Qb%kY=OZVkXuk-5jE}>CpW4ED^k^h4V?qtK>-Joj7ygx zYS)3gWB{q*r1y}fKh!+D_U5Je)4!K(Rx6lfpwdqs_q|HDYxsw=+q|M!rUqE8V40t) zJAq9gF0ObQccZa6#Yr_tzy!64*Q=*rm9pXC?(Umm^S@u8MSx;fO2etNpQF*@Zg%8M zT1;-A_lvb+dRVJGo?dy!I6vIGed5S}BMD9PJ2N?a0VUJ}+9{Y@Oe{41laJ6Rz z!nofDdq?~>&QmX#xYF`4F48jVC*0e)38er4F0OI4SuAo2kKc~jO!J(_hP~ayDLjF{ zS^UN@UvrhGU7qoA>`eGcG7$n?z_2*{*YKlKz0B)ieRG!G7`96lHXgTut2>u1AhGODWfp*zhoo(%+yM}sg+lGreBH5Y~%K$3swyfG|t7mZsQ@NLHT zlB#zmcp$cSd*FYbpX}{5UQMJqaMSLGcyOxZVt@Dg6Zrr@$ZYwpnugSw$6e907fPqg zDT}?KTsYJ%%a5;tH1Sb(58J*kL*w5Te|pUH$8b;t5daW87+A%4H#>9HWS}I=Y;R7v z)k`7xxE0&BCg~%qJ(Tm<$904k0K%oOn*@AVH68dkKXv`d#ElroxIcH+d?ErYc$+)M zvwAgGU1g{N;5b>g=)csi`f9)+H=^xJz))oH{cl*)bfqO5y#y!# zoWKG;uHGN0%yN>O9GoBn!Sa(QlLkkazYR%6R%n`R5&%eXWzz_;d|Z7vP+Q*&MP|-d zQ9?1}qw$(8t)UPApcWoqdXc=^h{>xg1XNmWDVYIPt=8;RfRuYqfOf#9*Lm8_QK-a? zMCP2PU9<0Z7}RBznsr;r9wcf&sxPN+q_B|o>yPD9ef7wdZ_GQMlE~?`Wdu1*VZ5kt zXLO$#=$vDum9Z!D}S7kn`zP2-{@$ACb31BmyOhHh95B6xTeV^lPqM>8X7b z)xjsgqS2sYP}7F8yc!$D=_Jz4y~_Y}=2V;;&M9v;52#sn96*QfHyjZ2v7Cq`=1BKF zs_d7{Tuy6lmKEWwqo568lS*#hh6#*QIj_+7(rs=b%<`Va^N7^U9(;K`toQxOQt`UF zRe8La!Dy)f0i1|=-0A2R0kx~k?d!BtMTi>5SM}xQ`UlSf6nME_?#E^gcxMLdY_2QN zf_^j(s9tp#_?eX~r{MW^A-TF%hn^wl$!$1yp&S>*Oq}yvSt+p&dU}{zP4kmLKmOGw zrJqgF{gD&-?QHl%VDsQhWbN4R7i(Th?RK7X!+NEG+|T7G;6W=+x2WdYz|+42l%vdA zu0v`>fLm2w0}t~3`=fuM*6G@hA`Ne5 zI-|gF?7bHMt*{_fcIapVRQA#}z%lAvJYBf7bKj-d)x*7wy5>BJ*UPt-22U$|{$0^W4rWQ3AT6J>ZIG z9>W(F#oDaE0JDw@2z~1Ve;T4dQ`cI*O?k2Atu#1u9KQ6lT*lD?TwY+&?miRTmCaTG z2iuzr|4dH+i;KxuLdnE1S*z@vnVc(52ktwdO2gh=is?e}?w`}-74jq<$==}~;f?I& z0B-b^1aYZSvOHlbJWe{cu3`nH8)Byl08>peRQ!H}YO~x!Mqf7di%`ALj=gKo*td8r zYHw4KbQ-ROy0y#u!?L=R2?@*00L)|P%o~}3>8w_{*K5rYYr%5rzq^@pirI@j{xCL! zfoM8vQ>kPf5E6bK)~G`Z>%b2=bMfXX3NgMmineA6`@^dbx=B=y#eHyMu2RV+fDkKw zHX@0NkD^vv$?(0vM~nM}Oa+*nl^6wIx5< zpqP}rfM=E2KjbD`2L{Y->cVy#`I|!E6AWqiTzCtO!h)!-lof?!rP%U?!`INTgCYq4 zAAUG@y3Txvmw2nQa;UY;pyh*ExzIUMLU{SC|I6XJGt~^3@b_a^eFhe@`cwi=&ga*U zZd2-;u3pjhN4<$tN*^YHMkrBqGx(qc*B^Q3BuUt8b8|gUy*W&6Qo;UN<5;D^-C3;f zT}mw&N1wW`oXU|a+t6{{J}VV)reIzxQ9OYB^lNkYM=%Tz+|N(iR4KKS3a>^%luaWR zAkSjQ`Q1ja@u834fb)u}33ODX{!m~%%9;j>Gm!Q5?} zH)7@vbp9i2v!Hb%01KQq1yze0ph=BAqh8)cmyf zU{yT)Mt!<|1$p>Pa#uHZkgjp5lki&|^nB-@+t;^1O*#xO005Tvzn(8v;o-v?n7fi~ z{~AU6>J&;p`V#Ajr~96xT(@V{WKMN6M&W?6Kh4Fi=*h$Me8wWJAp&T?$BUa_L|Mlb@0oAe# z=pZXWA;W0tFrONXG?==0f%&8EHR3u|Cr7wrTF^E)2yh&|`gCAFYj1yOvCbuAGVTj? z#13lv%H6Zz{4jn-O*Dg=0Uh8BmcI$z8Eh^Zku9YMmmBqM$goYdl6?Rw-braJ>&aAe z$}VWt|+! zQV&W5A`JrCS0nOI;xnS{P<-Hm6(W0RowR9Td%2*Gdi7s8HGt$1ryZN)RZepHSryB>|1#syFsNaBPI`|w9{z@TlpQok+9R3Z1>U9F|!AJk6^EW#b zz5EJjU9;z0|4euC`Fb!aqdOvNlyr)bAJS5j z890cp9j(=?GbkRvq|2g^yZK#;*|F;iN3JiH76{hB$XF|=_!%Lh17T>i*gvt z+&KD`p_9SmVmle#Pf=G|T>jW?>R}YEN^rk11WiYmuH_oUsZb8B7&YaR53hWVr4Kt( zBO(gaSV#Pk=dZLV9uMr;FBCt%bDnwSUlFwu4hy?1SzGrcDak7SE8M~NYN(e=QEZQH znHTdfzfI?*dZP(wD!PTIz8c-}+t|O_@2wW(3TUbRnS!D>COXC?A~Su0 z#H_qlYRq5yfAhSpbbJK6+_Cw}@OVA#4f*c}+6`rbGs$|mXX>a9%gXG%_NFbQa6Fkc+LiDY*o&nsDHJba!jOcuPHnH6}; zx4)-WB+CCAx{#aVSs3?rYwDY%6&D#pp!|MLRbQpXQF9k7^Ln(=$IpK!H+n8WhyM_h z+tVmM$lUiz?i^&3n-*wN8}wZjve`*wQv;|gu_a9!>K(iax!l3``O4a^y)(mH zAo#&4p);v>N9gtJ8IMxGY&pW#=;wmX^nFktg%Z?r^eWYi`9>co2Tm}loSM=z|FHn3z(x%buS3Y3e2ejs;k@VKx=$v7LU$UPGx@ghZeDDviNq?m$+6 zT73|zv`u_jD6?RdtdMKDw?vBh!Hl=RRAS(JfbsUugFn!Pu z&;1=T^0BaOsXcyO8g59e7^gorUJJKgJ7cr=^Lq0};}2ujF*a(ktK6#K0NSmj&*RWr zLp?jafULRXwC&NI<)(#) z=}oAzJY#ZEEAJI|EyiICYImhHVrQmQGbu^R@vg&#VQMMBmOP*<@_jF^jbhK#R~|%P z%Q24x=#sktUWT}y9aPhH)4pBMO0j`gfGMxz-2#js_mg}P%Ez-Jd+b(96buvk+G~2a zQliAJ6NLx(wD&~Po3G95SB=ds#khAhK2tX8I@K|Ucc*%ZE<7SOS>WyYWenWwhX^9~ zw^_vwJu*}Jj?>GobMYLUDzZ@*WRBTsVZ0*>n~jx5Z%)n5>ei9L~dAn9A zv5PVOD(4;9G!q)U$T=k3kX+5U{0#x!3?Vw!@gnCR7@QlMDwNiAI;#akfGDB>c>Jk< z#_u3y0^q0VpgLFe`*>mOVN`FKBH|v2)*`^ikoi&6I4df;J0Jei2W)LmqAN>Od8 zqW6XI(mv?kk^tdBv!{vJBKp#vFs_S#i<%1ozdhTLX@^ zv#0+1<=mda@G=*P_)95%y#(&{kGg=XJIXWemqn4ZjuZ1q^nET`*n{SSV$^`q0$xQ< zX@%)t9?|kIeSx(;joEIkw$s}#9+YZLWEJp9WO?;L6MsHui2sk;XVAgP{8b$#`hr;5 zF87LG8GA?HKdRPB3^Xwge%XAW+53SHx}6_QQ@H;ouw)~!7Q zNz~YB6J!@(ycTD2Mud$TE5P=?-RtMCTwjE5WEjGy1q1YZh&>J=c!C0orFETH1R&$z z{5sjPUzL{ywC`4Aar+r`@=$;<_Jo;sfHM|OYI=Jp4M0Os_{5{U{H^$>IhzZ2%p0S~_((af^ zlnx^;fE-~1IJ0H7Ju2R_7jTLKdPZEw%2o2FsWmJv5R0T;6$!toD6 zFf%w_zK5w?YJgB_w&&+8e*Nm)+Y%Tu#2iR|@Zs*v7Xm}QCP7vK=dNIS0vxD=}aAA9-| zdMC-FujmT&Vlqmk4V*Z(`u)MtuOY^K`TR{!|BmWa1@HUm17RI5J^g;U%y@_`gUM`JLM0HP@o8IP|Lfm$7 zP*2jVIgmkwLJDrhOqJAbu0f3=yqpy4ZLkp0>n(i%p4!ne6m*7Rct67Zr4%Jtamhwphb4 z3cg|aW2N-#|F3ZRf3CXpTI|x=!LpXx(JJ)+O}{=sKS~G*95eY<=3pVGrP-@?IHa?} zpN#l}rzjuAr^dbE_;)oeW&`(pW3)rUCVy*axPQY;^$rYG0Kit7#f;t6@6HB?EJ;uW zrLAZzAbj%a1KTCE>JBH}6fU)+)MeCB)zt~)Htipmhwdb8hAdf9e~P05QjNUS-J6m- z9{=R`8%5U}ySyt2m6|M=zakITN4uyE)b#Y^`s~JN(m<(juB?b=Ubt|!kwaMS>W-W9 zLWIB_0Tnx5ak>bj&H9ZcL{;!Jr{`O|*;!BOb}iQshtPVv?=a_Imu@z+BzD-q@U}I1PcPI34C>zROP<9>2*jpcQ6gr* z-iAKlqv!T2d(lL<_lccKh6NEYg8qK!Qn#IVKQBxyc`a|SSZT%CapPdyD_sfU{W!ku zu2saFRDril%h+Tp`zx&WO<1{)b$@u+4@18vXR7|&yALP8ZVaifIB&R$Brh3R)v_${ z))3wD6w=s}V$av%aOM^W@AHvbQluc_q)RE+jVLN$oMA!RdDc}VgvghazmU^omf(D; z+vqt=FfNXHsv%6}tH+;PUF_7HQ{7F9p?{Kyn8z?E?hDKa?j%}GjyHM!q}`Z zE<~wk{lkYnZ1`zCXWp*v!H=uw74q+9W}(zyt|Upxk~!Dcu=Pn$6+%!}&V|gWRp;-? z(L|yMe?wN1kd8&@z{VXVf??r~+Il+wr57(i$D7bR^!^2>WQ!BfOwq*0k*n4RS(~1B zLOTz{f4{w4K1sG^pdppmHmhfr@T0!SB!&ALFL!xThmP)O`Tt#iv_ci?=093RlC$?k z5BV55M`R}-CnjApblqBbx32qc`YpwdrG-iB`CX`-vFmP1k8#K3yIEBJ(ZV~Kg^TEW zBFm(e|MGiAxpZ@CL#EX1a$nK}3uGEk`YuhA<+mh_YjY-OQ#4S5b{45qL zTOx%^Q+M2bYYa_8vqP1HK=-9IwWEaf&)27(S3ut8OQPxh}1?{BR$ zJ|(pX?8Q;`B;i>{kJ3idqHq(3qqsZt9*5WaD<_| zg6-rx9e5JD6>5=wC!ull4nxVPH2a^4hG?6;=~D0@lz$X?PnKb_R8+OWTsI%Rmgk3? zrYH&NW9*VK;l2Zioj)9Oy&snMRp=fhJrxnju{r*Ay?OEPR6?J2(C*T>%w+A(-r?wk z!;OxW3VVt^#Z#~2Aaknexk~y->MP^6hs$VtSKJ|GsCX_Q{7-Ts+)Mu5EJH{B!Dr_` zBKQ7EeHUUkKHd&AB+nngPu_pA58J`>{B1XwJ(S-VsV}m(&o_+J-nSfgdzfzvO?aXZ z?tpA|-Ght6Q@1%8YxtjTELYs5%%-S5HP4%i2tmjzL_e^OnOSsz;e4q831>7X;b?8FeqYbF^+6&xbg zUS3@}zhy6ou$?Zr!=9C~7KSZUACsW!u+QV~tTuITBJMA&t8HqRBh2yt%_uTz-*P+0 z`AiBeEug3LaqI6*C+ND@!ql8F#VG5(+_MTom>Bff=%%s1niy5guCt-2{n1HjoG-d~ z0c-e2+)lsp8l3!%t~gr8FaDQ{Oll# zC*2r1YQA4W8OLud$0iIBJUtHeuxcaWDxwzDQB`|XJ{gm_?D5BWzhq$jxpP&rjr1&eFq;J>kEnXBWrq)PA^eRlgJ*8XO%zs*fIfoiv}JwxGc)=i&5&!bgMF0KZ<)9*0qFaaKscn7hXsrXv1;snmo-=jDwp5IJ{T! zq4rMFLV2B_r9F)9PrgTFqYI27B83HXf{dT%yrgOKN#mF=&a9NiT##d4&L|{uR8uuw zyx&oGIRTY@bm#3itI$)?4PrsK_Hye{L|z{cXS+z4FP0DoEa>E=vt?R>6!oqtd*2~V^`d;+B)9uuS+pnd$N;)e;o}KA3UV$ z|9UvK={My4QI+Bq%91l;ckU_z6=bNcR5<%8$BwR@K1>pyhi$*^#8qx6r=6e8{8Q=nh)QO z`5Mn{F+GX-b9d*}0!zDsi}>3V_1WXNjr#4>qXtLtbXL&BTrgN;thJ4j+1IJi)3tu` zis(fp5`xJJR(s>Ng+`!e645LP#ipJqO(D)c~&3Z-qBq@ zoSWn+&e|PnQQvcc-aSi77zOlQ6Vs?J>r>>_v%%_PZGJWjuP1(5p(dzh(C-#xYuF^a z@!_VZE#Zj+?gU|-?GzTv5Z@csp?4HTeikh-w2Qb%@|^m{CO;^0^$=RaDPpu;t`Xz$ zV00~k=hN@Z?pv8z{X^E0DwW2cQ(M=S)~s9LR|@*HVtr1mGq)WL{U5>G>V((+eE4HY zktbKow>4>iMo7M4!ynnTZ#n74gOvk4ZlvM}J&D`X@A))*TXZ7GGuDR1c(>L^wB>Wq zdcLpFL%#2XN66uxReL3mhMBU(pv53|d$XNq3$eGhxN24Ug!VHJZ~V*pg%SmZWwf5p zez}%^&%VUrgKZO6{uYiwo?lnrc|+Ln$pg^@f3je=Zc-&zE8@;RPfDoW4~Ea_L4Rec z^`O3x$apG1?XY~`vV^KI|4fE*C_M7e%J1hR6%e)B+Z(I~KHO$#!e09|y?xvgn=n9D z=tYc%{|-;MaX`e%FylFCfiMZ|woR=b%E$1siyi0m#^gXf^`_csNV8crszuJTwW|m0 zwuA;F+jStwyqg=_-Y%iwsonoLYwb_a-X;*95DDnHu7*vYWAfgfS8RI29hi|CzVl&_ zdBd%RUi-Z2XVUZ5e=V`od;2X(>EiP6X6q}d;bpDsgp1KVZTAEuRisc-wjBAs2*cC( z?SxLCn8kR)F&&&yDGjWv7$S*H9tiLM67v4y z^soPdF*X6xejBvnu7)ZLhqXH?!@b;+%xC8{7%;Z00q1_g-nts=>4*LGEN(#IT|;la z6fP2oWij6!?MTNa+Uyp(`g6VG6Ru!sy{JR)-*fS6Aj9YE7;e8AjzUE@JzKu@b}u8P z(0$*RroMrQyJgsCzrqU-HFpdxjVJa31gw+^L&t9Oh>GHBwin-ndB%IHaW*eB7Q3}( zE5U+QBJrcH`9kLh&=jHbzOMr&o{xGKV%9#eRB-zjr+)b{sl!74rz%jEvtabZbvrEa ze+%12wg{B>E8#zD_E}+r@BDA?E}JYUE~I&Z#W(4UTzTBYU6NCY@b>OFo@|g)SLkNn z2pWD^l^24-UrqYqr^NC)?}1e902=MfxPlZSC>;je`+!v&{!ev|a0otlU8ZuVQGE$! znX*~=T%#Cb%@QTC9!9d}&zwu+-LaDA{DG+x!<`Hbty;R(7)TgZ!$6AVIH>d(@i5D%I%T8cPjU|B7L3w=*jMt_pC7z`wFO=??}vb zmlXPBwEtQ+S?A{9XH1M##dDHtDeQtu^NljBp&KWkX)UYNSnyWv8}idjYxCUKKvo16GEThWdkz)JO2x zO5!5zzUpok&;Gk&{@1)LJv$v`!b{dR*Nwi7YN~XM6C{o>5uOWFJGwiDCxl<&#fN%3 zmX=o2VTSn33Ab=Q2Bcm0Jvchn_Qkt4E##K&_6HU!Lhj+1bKhROq5n@BEn6pY9`ht= zxzdjAiDAWY*yobSk?PlVE>6DH2_1PKb(3DjR*0Rz)bcHy!>wU|!7`)duws&B3%`Js zsTitLAMH@&|MOn+Dfhh_xw_kN&<`uRRrKP$jt7wew~Wax(*_(}KCzl*)w&9^(-TE` zK)dnoef14W4(f4FTkS%VPi(VbST+6_+rC%*k^a!&w9?z>m~fkhst9qU0q0l&(OgIB zoRB=Tv`0#a2 z&H1Rq=ajj`7YP}ymXy}n*y6(qQ7XLM-~-(FNl~@Ne?z%;LsQtgA?f`ETh#yF)55|D zvnoTCx|I@js~feNasv0nF@}}dXm9^HLcCX6WU+(@>SHeP1&-~ma%sc@IApr|bV{p_ zuCw!)0kd_WOtbgw=5lAcbtneq)ARN9?1(KdCKjwY@%!YyRIuLCihbtOcx+uu_Q$2U z$brFvTdwxSKI1yffA-~}kx+&-z--L!S&_2b zi-d0mERQW)8)HvC(#~nTwzAw`FIC0Gj`3F75a%l*tAmGLpYQI+4E2Un+P2LFzP!W7 z0FvODxug_w|3w4cn9yrl|M^cA+ed}+OF0;ek}E8jY-e*pdD5|RxgE>QPDhilTU)zu zLsW`h<~Os>6;tl%ZI$SG ztx22||C;k=KGXR9t2ugeMs$|%4;;zUdi2nW0{3MQqHKPyrYviA$S#kK3MhGo4&)nI zOnkeD)r25NZc;VVO&r3DCCbE^R=PZ`ah*eEh=aECOfMUPtDjw_K28X}G*;I7XjY9u zE2PrMdDPHq0Uw?urvAeRI$rey?Y%m*Y;MfW+e0tO^G*ex*u&g0{Z52OH*&K4GBx~d z=eAA$!5Vgd!gl|2Uj&M}BERS0SVjQzT;h@|TU7!^pmQB!#P23vj;O24FApU(}}cQPx<~9T_2fxP5=gOL$_jzZ44?#I?GXbnwDj7?D>em9e09PF6^;OR&HQ#dnhnQ+r>QTR){iVd|3nb4 zKcke$jPN29Q^C^*!JcKw2)^HcaOaim`VeO5Xrf*CZo9f9;#KNTdSBF)RFwMhj7n7O z=ie%OwsN6+Dq49Fg2~-H1}p2`x2BJBk`0GkZYkBaW5?d>HvPNGQD3)QL!4SU1#1&``z(d}RvNNCI#D_oV|Cr}oII^cz3$3((mq4t-X+D>Sl=g}==1vuUNK$Pc z!WcJ6FOZd5MjnM7!5E5@BGk1|_W6UgCyp69$<5>&*x|&+qu8O*HN&;+{`)re6xD|n z-DgZd2r^Sc_0m&cg6`w3%ND=R-AuFD5_1TF+et%e>@Up@vO;GE;XZ~(GC@YAVwYS{ zcKLtS%tl!tv%I6X@2$}V&P>ZpfZ##n#nIka9^@r{!p`FU3_63wD8m_5VX8Ptg}RPf-If)U~Qq7B!Gz{{JSUK{yx*(FD=W|3g35GS)a-NKhAGE==}D z^MLLD4{iOwmaH)w`}&>d@$e_c06v+&>f)^4tN*(ZEVFEY3O0VRPcPO5DHK`lteaqe z2IzmzpLUNvg&D;2o#O7!_~Y;`{gi2GSGt1dr*N@>Q&yLJ3Ii?7GU*6%3Ke^RP;uhJ z-sGh=PvCxj*DWi`iYCP5n~#jAR$inSFV*jO>Vla6dUAfx{2%Py74yT4-?F!#17LH( zT`2`+a`wKSHI+9KJQJ@klBg50~Gd*$%|y?dk#@M zC{Hy%DN{_ne|0>t8mvY)OZi4H5GwY~kh&C#`+DD|i-r>zf1ga24fX)hdhLs+Oo7WX z10XP@w&*-t8W)WOZYV%ZoYK3b;$8ZkZh$P^C1VUvLI6dA868i|8K# z2@H@#=#6$ky5!NDuuOmciZ)|*^~dg7L26|9ke58WMdK|wTV+-Pu0H@>KCqO&^q{-NdVQRlKSvu;D-%(c~rf};6GX>kdh8Eb!d!< zhT$@yOs1KVHg;IF8%1TJtuD{8TdQ|U&TxvYXoKvMa_qt2&gyrd{{yXwwwBk3g;vfd zx^u{L_s91i2_#_hdz}u8M^(HaFiTdIw8wEH7A4ZLiVxtdzyk-(ktwt{W-uHi4dPA- zDaRRvoC$DW1}j);Qq_JlKBj`<*Eh{{o@UQ04Zhr)gVuo+$9?S-~*`P zCut)ea{6T!FCeA#%y33=?b-3$5R+O3E2kelFPcRBcLt)X+deiz@Xxz=;x#y9WraPw z99IzyFLOq1#3k51j>lJ!Zj0%(-AF9FClg7Q2l{onM>3qWgc}uk|JRs5|2~5nz!tHz z4&hQ>ItZt5HzhD_@0U|aJ#nIFNBoaBjJBoxkDIjh{a&OEKWI-=eWL#-0plzRblUvn zjaT9fm!4uo6Je&=i>S~gRciCNhxGGsUD&r3+xalX-$4s1VyS7)`&3p8{ z1DdMU^5MqSb8j?-zub&PFdK5F1{{OIT(P<2Xs%D-J<7?eolq- zN%MjiGp#7kjzFgNwH#$!2QnSB!-=X4?_E%(d) zWncv&W!2eV@+Z&cL=cM1SRsI`IKk84*VKFIs8DUvcjC~YcTw5~OPIhu(`;660yBIF zBkR0-G59#15eQEvW-l4N*A73imw1FJpf5!?H#8#U6+E7H;h58^vZ*B zYesVdaV4o{{*ze|(R|sokf$1L?!Nz)(vK^3Bl?G@3Z;MW;$C#H9vssaoUl*_Z4CO_ z-4Ys7yO&(DQ*OvB(BB3CT4KXbYghMb=ll&jepZ8Yl7qvtQDTZUk-<%iqK_Od5K1Dp9#Tr(aw7J4%a)V!OW?`86mvmJNB4zJKKGEF_z2gR4p02cIIk3ml0FJdlDG*Ny~UQs|8)o6&c#eT<211vOn4e)Bn9 z`i)GW{(IxuqO`!qwt@3}?#G;e*@eU|UTs|6oz3%m^q>0@SHVT*>drL(F6D2u0UR$W z-n@1aO`(Y#^7QK@=6}_ONc|=rW9}EL=Y){w+Z%;S( zBd}s{3|IMgu)Og8CuMZQ3fI?anmMytEG=2RtV=Xu1*QtipSb7kMw8j0ddpvDO_h$W z+^h}@5bSP~NSsNWUfh$LI(kS){TenTzq5P2Jr}=^TGAC^CJLtJV}{>}@b(CLE7|{M z-|;T!z* zK+Q3WShEF2XU{hkP4FDDn%v!N{*zTySGr?=LO|3W4y3K+RM@BYwy2oq} z+6l|*l!m*+DcU{1hY$z$2n7_v6z2$|)|=^#g^=ovgh$T4pl|0-)Ou}c8TcWUK%a47 zA+S1vcQ(Xdy^6xLeMQ$NxZpO6GBLl*L=FtS{K#q_b)jz>Q97{;R6O*X=K_El{ZUA< zn&mK5Ht8r2nB3-giet}I4L1LqY%J?sEbZvLs4;U++P`!0;oli14iMu?*+-#RNy;Al zb^l-+;aqpUBnPS&8~H1*CzbISkp7+zqxz(SaWaPH5HR~vc}%E&!RTDCh#5jM!D{PT zGheSY;n;4k8_AvwsGQ%vMp!BVZI%#}C;CCROkU72knLGtH)lfw?nmUzEkE49t=ZRr z?DZ`pmIsF-S1>7P-`vqeg$|Y8n_AZW03G?Qf7;T zG_m|TjP{-HjFa*uc=#=e8U;~6%Cj%woc!437_zxK#N*g3M8o2_>4c;6z5-3y9l0uE@pAw~Cz?@GteJr*5t!y=6wt$9obx1b`P z>MVW6sCk^0sq}(z5{-lLFKVP|V_*;o8f-uwI4fdLno7Y0Xt*5*6fhtoH6TdSUlKls zPxqL6vPm!0X-xX@m$-Sh>8}WurZ6S!sQcFMtIng%T@*T0 z7bhnA)W|(5+VK9&jBfediFx_8vs$oWKFO&vFM4jTZ~LrRpkJv62luXjjd(TY_#cZ&2E=Cy-S4TTg1g zG9zz+X7O;3eHwTV5%`83jc}|__9UZ9RpB9j*AxD$~BYzeW1l^s%4nx+t8W z#Xo=-e^a6cAw4I9CAUDO3q6TD9@V?XVWeKb;qa9Ub0Zd!wI>(K^Q2gu!~=|af&~a} z-!Y!@A*o_~!<-HXj{?;xr!P3?N#yNGhJtF`=ja1|g&PUJi4#-*)OHg91NX2>ce$04 zQN3H;FnN^zZ-uH;|CHEv2o&Hm!-3IcxZIllr+ zSYj?fr9{-S(LEEM;bZ-niseRvNn(FGH_~em_)ig@mi44bd?13P1FmMBcNOO*Qi z+Lvp}ph)s5o<90=R;kJk)FnH|rW~B44>-y2lNP|C5kwML?*M!3nWmMys8Wk1V;uc( z83>o}T?1?#6%uTFf}_ApprbhfNi8A`OvV<#JJOye9+&dO(MESTMiK(dYn+;y zW!pkB3ILyC>Q9qrcnB1AB7|&+xb)iwfdM@XC28yb-7|n+zKG`S1RQt)d<08Dm#P56 z`K(rNWMnz2y$dnmQaeZ9Pm|Sd8qxs2tb>7q^{i)GAc^)TJFD<`$`Y7KQU#}y4Uy+> zU6N}-oi%a8QI5N_a6i;OGA7h^@pd|Z%g@lj&ug%sw@_}N*D4bfT$YmGf@@DfE#n0> zLt@KVl_K32>u?~@=ilBwWh4bHo;jvtu}bey1Fn8~44(5RGg>&x&-mzycGWb&%lX9V z8TQTsCpI9gl}74T9rnX^1u!g)Kz$uLywtqSQfu#5@RJU_$q%m9)y>oS)W6el>rbAu zHLvGe!!zx@MJY01c<||flL2p34M!}QLynj;r>&QE6porz(|8d=2WWc~FDRhV={yjo zLUUpD1n37>=Pev!c|ueBpicz+@-i5N;1;$aPaOCjMPd#C@RP^Mh>Gh8nAF}LD&Z4I zXDoPCXvlpeFaCRo^5hYs6||;v;qDzq0GaCq+WMvMrb-gwAq zgP#h6A5?Q~86sD=P^1ey^~|0`U*~uzuzB$zq(DS=ady~%9SCosDzI2WU=A-4&VzGt zNB|ABAkAMCu1F&VhU(SRMYR1i+v@3I!lu0PWLRFi zYTGr2-vM&_h#h;o7Cg=3NsF%tIE}4~*xu{6f#L4kysFQXN~2XCf39zwB`s6pQQo~Q zAtkp|1LC1W(x6Uh5XV6ePgJTRhqV_`0@*r4^8p+`2r-#rc-52Wfv4_Va5@9DFM?M< zFsbXylz#_uWU{Ee{1F5hsSTzB9&q=^Q2w!Fs0;3r4>vd|eU7@0=w#^0F?HwvRmGWy zL%F^W{6$DYrBV^+lyFkSSd+#%q%5J0%n-ti-O<>$u~ZUqaBO2l3Wp4`W}P`PqA}LS zkY$P*+o0^rEZ@gD-|KgMfB(JL^*-2H?uFaMYrRp0Pu; z?icjdRg0KwE^UU{b~`tAH_@>NDm4lk$72au6n*GWO04DHuPMlz4csMMYSFH!8eTQP z&8uBJ9b@~u3jy3=f#5ZAmR<(EiY&sGda~#LY4ZiI8QnitVOX{CsII)aTE{{V^L@sB zgV?#f10%jkRuSCQlJ0|w7Q0-D=Y9;~!h5@kxecDlhzNwS00hVVi*?R?Ordx=DD)}@ zt7NfQZ+wp+=lBZ;qWvUQWjx&wafZb`*+=M73|d!BROru5B%mAROkn=(bqj zk%dfUQsH<0t!`2h#%xO>iVUKUB`7ucrMl} z+vFWf7#8C*;{%U57~^}_?rXa(`u4Idm+H11@BnWyz&@A^FV9@{;qU_a9A({nsUJ{L zAahYMZ8~&?s(i<>bs*S$3HK!WE?vW8Nw%IW)jv^uOEOyI*;Lk1K-fHG z22y|E<~671-?N?`QTDRS^mAqzMvGhgm1o`U#K9m2UM&GweX@9IW%7-)9o8w2_ zLe-jZ)Ja#Qm&Ky+Fl_|SAynl63I{eGTVjD%VmN**~sZPS8wqB(69adJ6IY(v)9nSEOr?Y1(C;LVBb`5-e?N z%~{K|_Zk(!8=Kc_NwR~cXJCWqU8J(qkG##h?Xyj1TrQfOW)m`Q^A;b8Omu0=?B4?! z$eo0~e#K>D)$E~j$lPR+C+Wx{#E%TpL%wMsLaSL0Pk=g$8x87Q%N2}L5%yQc>+>k5 zEnN@sDrJXO+qyxTYd%h$`VBXzS?>wN?ZEyfZU}kOk7Bi6uY3c#kze}BY{zCPYiblr z--;{LW=aagn78U5)IKf*ZoV7mUCu-=FZ9R|2l?}l3F)BMn$ha?#u8bz{Xh`}U2$!i znVuGTBOK!39cZ*^Z;UEyvjjYNc3DO?+zCC-(_;x(DK+jb8XKw@9WJX7tR)l91wOtN z!w3Ckw1K4##dHOGv@n-*19-2cZto#~G2nTuO?vmZF#d{bH4gq8z6aDM_|s!?NT$4D zOpXhCx#O_#pcp=op?8T}~@dpPU!@=xD|MFLke z0R$GoG{V&V%T7A5U=GdDcS#|9{Pp>{cofFa?G$B*1fb0{rW=)ptJVzi6laWlC1#$3 z{NIsqgJ<5(m^t^ls%){X2t{pz=0X4$#NYhimhV)w*#QO8!cTTsO*s`BLu1*lwtt!w zk@=(r1t6<}A>t9`p-&lb$y;+w4bx*6W(`Vb(|lBxFbb9Z#i<&SQ+hzh3~c|8x%J`XjBmZVZ}Qn`PY}2hSRL#@vG-D0E_5PF!I!zIM9&gzn2u zTGNVeP*?(kw(#^_!|LS6!jcgV7SL^t1^~2np;B`jLN~YzvV8)A85LTycPJfjy^721 z;?c<;AD@c(r~C}ETp@pl6b!6uAfz_M5s8`1>$TT^5Sa2ZXk(sr1019Mn8ZI_rK(wJ)fchli2mXtG zgAq09tvd`)bu?KYM>vQ7h{%zWH<|iE8s0ij9~gf;1DHlf@jy<3`PZA7-r{W~FDG^$ zZRKd>EP0GFa!01J!?(q(Ri$=XRaJ(w8diNzq{tqUXNm8HNHYHmuH)FWd$pe;R7v~P zq!NZtFXpmM^vm)trJK)P<~t__z9Ta{rOH^xCC6kX>1vW(=s5Wk)xH;Q(|3J7m$2?visgH7xzB_tGBm&bY+Pn2(Ml6lcWyYjE=3giprdNRFZx7;l`p{%v zH&|m9pR+bM?xS26xZN+JF6>S6P@pEh0R68M=83XH-l;_rz1DQBlXb@&T3pc0=E~*6 zBsXw7>}v8Rb!FBokRyfiu9`?PE$)sy^it|>Sm^8V)| zfulRI)+^`$q+Wh{T^869NQ+53!kVLAn)LTt5yagh4C)kAL<`rn%W{A7XUfZYXL=~s z(a>HdYc^S9lpzYB+DSaqoSKU>)Xem-pp9J|Wt1pOuOD9F>#^4l<=j@^%6z%@!b)ZZ z>_@|A6yyJGiw+wj-m*#lPLqo4c&4y5{Vk2Bf$+t*xO$vY>*(ID1jhc(G~TS^MBA4k z=0`NopVpUmM7IXXsCX?HC0Bp_X9{c?^pb*UK%jg;#x)P_}?7th$|-uqyEK`nxA-OO*gYMk_c z%X&2BvOyh^e5*v)@W8DhhHMhk9-eMqm`$m9mt9TYwOTsxDF*6URYdMeIqQF4qrRCOU(Kx zb`hoZIBVhJB=c$C2Ek&U#;AJyG^tbVt#)+2jE{AlK7+2hEPg@FQ zQX@lS(D|WvZvr5A*HLjql5yS9k1m&fv01h$G${|UB;lGB^L4p;*4@VACvI$91yHd7 zw*vc#gOLO~b9})86>JjM^t^392`G6^|e(uK;iz=gHP`%H}!a~Ewy=P#oP`9kC zi}N4#M=}9 zRuKBQt4E1t2b`^OtZGVq?wzzx=s73K{0>`sde17sg(ogl)sdp?w#3= zbWZGkEjAJ_->MRioNngLg;-_=G-J_c(^Uh`xrXeJ9;&M5Ar20IB5yOOF6wHJCxF_F zbuU-M8MX>vq)sLz*V9)a$S{O!Jpf&m{f<;b155!_uDd}?rIPpx)7 zz2YD^o51_7C4e4BORSd-PEp}lMrjCr_AasPu-{AMPWaTtO%AzLfMm&>f)B~3uN(j={SzzG@c zp>Mw77SQt8TRYZb_S!JvJ2wiOC~NQL&qg09ZnE<2RH$F8y=B7gLiANWb8GiKT~>Ty zZ99M;DkX0{;@Flp7-60=k5bieo*L6`izN@1E5aoVihE31W=pv6YO%ItTHE)4TZD`n zJ3InZTe=u30BF4a_NA)Mk1@uFO<*RGsb{*d+^Guj@jeLxw{@0p*w!~{LZ;S1P`rJZ_n#9e;A3LeX6JM>~(j;+8mu)2+{*Vr2}s^#6no83jx zzd-w7zTfA9-d9-K2R_$aR(w`@FUK4aio4~3zYzg5IRps>Y+Od;6THqJ+jlzK6z9kM zx)S}yMrFrzftjm$K$uKA0zJyMzvOUl;!-~%EHJ}$N$#mfLYleD*?y5&ZX(*O0+^_h z+PwFR#yVcVDXWh>r6WDznXV&fpze_yJ&&IVQ%Of5LH#-R zv2Z@CK9&4JPjcYmEV~d)P1}Oo{d8v>aaYd>u9e=N+Gt&x>BDUQRYd{v&m0|jc-ikz zmXg~;OlVoKD_bv^2lzmi*p!Zm6RR!D<<ZmtMgQKNMFpbvlMhR-+c&VaGg%wy1B?~XwE=l&auPKqgbt1U*}$5f?ACwwsDFIvZMV69WI}V8 z!`J6is)6zxkm7#96YruK!-E fMh=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", + "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", + "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vicinae/api": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/@vicinae/api/-/api-0.29.1.tgz", + "integrity": "sha512-jELjAnGO+/ugthtXgKxKCXNEJsb2EqDq7r42bRM0RQ6DfDE3hk6/LTI9L3NeNupPeU0b18igP8c65KELRvTogA==", + "license": "ISC", + "dependencies": { + "@types/react": "19.1.17", + "chokidar": "^4.0.3", + "esbuild": "^0.25.2", + "react": "19.1.1", + "typescript": "5.9.2", + "zod": "^4.0.17" + }, + "bin": { + "vici": "dist/bin.js" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@vicinae/api/node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "extraneous": true, + "license": "MIT", + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "extraneous": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d401982 --- /dev/null +++ b/package.json @@ -0,0 +1,112 @@ +{ + "$schema": "https://raw.githubusercontent.com/vicinaehq/vicinae/refs/heads/main/extra/schemas/extension.json", + "name": "lam-agent", + "title": "Lam Agent", + "description": "Agentic AI chat powered by Ollama. Ask anything from a global hotkey, with streaming answers, persistent sessions, and web search.", + "icon": "extension-icon.png", + "author": "adrianbonpin", + "categories": ["Productivity", "Developer Tools"], + "license": "MIT", + "commands": [ + { + "name": "chat", + "title": "Chat with Lam", + "subtitle": "Lam Agent", + "description": "Ask Lam anything. Answers stream in as they are generated, your session history is kept, and Lam can search the web when it needs current information.", + "mode": "view" + }, + { + "name": "sessions", + "title": "Chat Sessions", + "description": "Browse, reopen, and clean up your past conversations with Lam.", + "mode": "view" + }, + { + "name": "setup", + "title": "Set Up Lam Agent", + "description": "Check your Ollama connection, pick the model Lam should use, and tune how Lam answers.", + "mode": "view" + } + ], + "preferences": [ + { + "name": "apiKey", + "title": "Ollama API Key", + "description": "Your Ollama API key, created at ollama.com/settings/keys. Required to reach cloud models and the web search tools.", + "type": "password", + "required": true + }, + { + "name": "model", + "title": "Model", + "description": "The model Lam should use, for example deepseek-v4.1-flash:cloud or gpt-oss:120b. Run \"Set Up Lam Agent\" to pick from the models your key can access.", + "type": "textfield", + "required": true + }, + { + "name": "ollamaHost", + "title": "Ollama Host", + "description": "Base URL of the Ollama API. Leave this alone for Ollama Cloud, or point it at http://localhost:11434 to use a local Ollama server.", + "type": "textfield", + "required": false, + "default": "https://ollama.com" + }, + { + "name": "systemPrompt", + "title": "System Prompt", + "description": "Extra instructions prepended to every conversation. Leave empty to use Lam's default prompt.", + "type": "textfield", + "required": false + }, + { + "name": "webSearchEnabled", + "title": "Web Tools", + "label": "Let Lam search the web", + "description": "Let Lam search the web and fetch pages when a question needs up-to-date information.", + "type": "checkbox", + "required": false, + "default": true + }, + { + "name": "thinking", + "title": "Reasoning Effort", + "description": "Ask supported models to reason before answering. Higher effort is slower but more accurate. Ignored by models that do not support it.", + "type": "dropdown", + "required": false, + "default": "off", + "data": [ + { "title": "Off", "value": "off" }, + { "title": "Low", "value": "low" }, + { "title": "Medium", "value": "medium" }, + { "title": "High", "value": "high" } + ] + } + ], + "dependencies": { + "@vicinae/api": "^0.29.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@types/node": "22.13.10", + "@types/react": "19.1.17", + "eslint": "^9.22.0", + "eslint-config-prettier": "^10.1.8", + "globals": "^16.4.0", + "prettier": "^3.5.3", + "react": "19.1.1", + "typescript": "^5.8.2", + "typescript-eslint": "^8.45.0", + "vitest": "^4.1.5" + }, + "scripts": { + "dev": "vici develop", + "build": "vici build", + "lint": "vici lint", + "lint:code": "eslint .", + "format": "prettier --write \"src/**/*.{ts,tsx}\"", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + } +} diff --git a/src/__mocks__/@vicinae/api.ts b/src/__mocks__/@vicinae/api.ts new file mode 100644 index 0000000..0e80da3 --- /dev/null +++ b/src/__mocks__/@vicinae/api.ts @@ -0,0 +1,48 @@ +/** + * Vitest stub for `@vicinae/api`. + * + * The real package talks to the Vicinae runtime over JSON-RPC, which does not + * exist under test. `vitest.config.ts` aliases the package here. + * + * `LocalStorage` is backed by a real in-memory map rather than being a set of + * no-op spies, so the session store's read-after-write behaviour is actually + * exercised instead of being mocked into false confidence. + */ + +type StorageValue = string | number | boolean; + +const memory = new Map(); + +export const LocalStorage = { + async getItem(key: string): Promise { + return memory.get(key) as T | undefined; + }, + async setItem(key: string, value: StorageValue): Promise { + memory.set(key, value); + }, + async removeItem(key: string): Promise { + memory.delete(key); + }, + async allItems(): Promise> { + return Object.fromEntries(memory); + }, + async clear(): Promise { + memory.clear(); + }, +}; + +/** Test-only helper: wipes the fake store between cases. */ +export function __resetLocalStorage(): void { + memory.clear(); +} + +/** Returns whatever the test sets on the override, otherwise an empty object. */ +let preferenceOverride: Record = {}; + +export function __setPreferenceValues(values: Record): void { + preferenceOverride = values; +} + +export function getPreferenceValues>(): T { + return preferenceOverride as T; +} diff --git a/src/__tests__/agent.test.ts b/src/__tests__/agent.test.ts new file mode 100644 index 0000000..22a4fb5 --- /dev/null +++ b/src/__tests__/agent.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import path from "node:path"; +import { readFileSync } from "node:fs"; +import { pickToolCalls, runAgent } from "../lib/agent"; +import { OllamaError } from "../lib/ollama"; +import { toolsFor } from "../lib/tools"; +import type { ApiMessage, ToolCall } from "../lib/types"; + +/** + * The agent loop is the riskiest code in the extension: it drives the tool + * protocol, and the failure modes are subtle (a dropped tool result, an + * infinite loop, a swallowed HTTP error). These tests stub `fetch` with real + * `Response` objects so the actual NDJSON parsing path runs, rather than + * mocking the client and testing nothing. + */ + +function ndjsonResponse(events: unknown[], status = 200): Response { + const body = `${events.map((event) => JSON.stringify(event)).join("\n")}\n`; + return new Response(body, { status, headers: { "Content-Type": "application/x-ndjson" } }); +} + +function toolCall(name: string, args: Record = {}): Record { + return { message: { role: "assistant", content: "", tool_calls: [{ function: { name, arguments: args } }] }, done: true }; +} + +function answer(text: string): Record[] { + return [{ message: { role: "assistant", content: text }, done: true, done_reason: "stop" }]; +} + +const fetchMock = vi.fn(); + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function baseOptions(messages: ApiMessage[]) { + return { + host: "https://ollama.com", + apiKey: "test-key", + model: "test-model", + messages, + tools: toolsFor({ webEnabled: false }), + toolContext: { host: "https://ollama.com", apiKey: "test-key", now: new Date("2026-01-02T03:04:05Z") }, + }; +} + +describe("pickToolCalls", () => { + it("keeps the previous calls when a chunk carries none", () => { + const calls: ToolCall[] = [{ function: { name: "web_search", arguments: {} } }]; + expect(pickToolCalls(calls, undefined)).toBe(calls); + expect(pickToolCalls(calls, [])).toBe(calls); + }); + + it("prefers the newest non-empty array", () => { + const old: ToolCall[] = [{ function: { name: "a", arguments: {} } }]; + const next: ToolCall[] = [{ function: { name: "b", arguments: {} } }]; + expect(pickToolCalls(old, next)).toBe(next); + }); +}); + +describe("runAgent", () => { + it("accumulates streamed content into the final answer", async () => { + fetchMock.mockResolvedValueOnce( + ndjsonResponse([ + { message: { role: "assistant", content: "Hello" }, done: false }, + { message: { role: "assistant", content: " world" }, done: false }, + { message: { role: "assistant", content: "" }, done: true, done_reason: "stop" }, + ]), + ); + + const deltas: string[] = []; + const result = await runAgent({ + ...baseOptions([{ role: "user", content: "hi" }]), + events: { onContentDelta: (delta) => deltas.push(delta) }, + }); + + expect(result.content).toBe("Hello world"); + expect(deltas).toEqual(["Hello", " world"]); + expect(result.toolCalls).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("separates reasoning output from the answer", async () => { + fetchMock.mockResolvedValueOnce( + ndjsonResponse([ + { message: { role: "assistant", thinking: "weighing options" }, done: false }, + { message: { role: "assistant", content: "Answer" }, done: true }, + ]), + ); + + const result = await runAgent(baseOptions([{ role: "user", content: "hi" }])); + expect(result.thinking).toBe("weighing options"); + expect(result.content).toBe("Answer"); + }); + + it("executes a requested tool and feeds the result back to the model", async () => { + fetchMock + .mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime")])) + .mockResolvedValueOnce(ndjsonResponse(answer("It is early January 2026."))); + + const result = await runAgent(baseOptions([{ role: "user", content: "what time is it" }])); + + expect(result.content).toBe("It is early January 2026."); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0].name).toBe("get_datetime"); + expect(result.toolCalls[0].isError).toBe(false); + expect(result.toolCalls[0].result).toContain("2026"); + + // The second request must replay the assistant tool call and the tool + // result, otherwise the model has no idea what the tool returned. + expect(fetchMock).toHaveBeenCalledTimes(2); + const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body as string) as { messages: ApiMessage[] }; + const toolMessage = secondBody.messages.find((message) => message.role === "tool"); + + expect(toolMessage).toBeDefined(); + expect(toolMessage?.tool_name).toBe("get_datetime"); + expect(toolMessage?.content).toContain("2026"); + }); + + it("reports an unknown tool as an error without aborting the turn", async () => { + fetchMock + .mockResolvedValueOnce(ndjsonResponse([toolCall("no_such_tool")])) + .mockResolvedValueOnce(ndjsonResponse(answer("I could not do that."))); + + const result = await runAgent(baseOptions([{ role: "user", content: "hi" }])); + + expect(result.toolCalls[0].isError).toBe(true); + expect(result.toolCalls[0].result).toContain("Unknown tool"); + expect(result.content).toBe("I could not do that."); + }); + + it("stops after the iteration cap instead of looping forever", async () => { + // Every response asks for another tool call, so only the cap can end this. + for (let i = 0; i < 10; i++) { + fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime")])); + } + + const result = await runAgent(baseOptions([{ role: "user", content: "hi" }])); + + expect(result.content).toContain("Stopped after"); + expect(result.toolCalls).toHaveLength(5); + expect(fetchMock).toHaveBeenCalledTimes(5); + }); + + it("surfaces an actionable hint when the API key is rejected", async () => { + fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 })); + + await expect(runAgent(baseOptions([{ role: "user", content: "hi" }]))).rejects.toThrow(OllamaError); + + fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 })); + await expect(runAgent(baseOptions([{ role: "user", content: "hi" }]))).rejects.toMatchObject({ + status: 401, + hint: expect.stringContaining("ollama.com/settings/keys"), + }); + }); +}); + +describe("module wiring", () => { + it("keeps the request, tool, and agent layers free of Vicinae imports", () => { + // These modules must stay renderer-free: they are pure logic, they are what + // the unit tests cover, and nothing in them should need a Vicinae runtime. + // (`sessions.ts` is deliberately excluded: it legitimately needs LocalStorage.) + for (const file of ["agent.ts", "ollama.ts", "tools.ts", "location.ts", "history.ts", "markdown.ts"]) { + const source = readFileSync(path.resolve(__dirname, `../lib/${file}`), "utf8"); + expect(source, `${file} should not import @vicinae/api`).not.toMatch(/from\s+"@vicinae\/api"/); + } + }); +}); diff --git a/src/__tests__/history.test.ts b/src/__tests__/history.test.ts new file mode 100644 index 0000000..f0d35d3 --- /dev/null +++ b/src/__tests__/history.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_SYSTEM_PROMPT, RECENCY_HINT } from "../lib/constants"; +import { buildMessages, composeSystemPrompt } from "../lib/history"; +import type { ChatTurn } from "../lib/types"; + +function turn(prompt: string, response: string): ChatTurn { + return { id: prompt, prompt, response, thinking: "", model: "m", createdAt: 0, toolCalls: [] }; +} + +describe("buildMessages", () => { + it("opens with the system prompt and closes with the new question", () => { + const messages = buildMessages("SYSTEM", [], "hello"); + + expect(messages).toEqual([ + { role: "system", content: "SYSTEM" }, + { role: "user", content: "hello" }, + ]); + }); + + it("replays completed turns in order", () => { + const messages = buildMessages("SYS", [turn("q1", "a1"), turn("q2", "a2")], "q3"); + + expect(messages.map((message) => message.role)).toEqual([ + "system", + "user", + "assistant", + "user", + "assistant", + "user", + ]); + expect(messages[5]).toEqual({ role: "user", content: "q3" }); + }); + + it("skips turns that never produced an answer", () => { + // A cancelled or failed turn would otherwise be replayed to the model as an + // empty assistant message, which some models treat as a hard stop. + const messages = buildMessages("SYS", [turn("q1", ""), turn("q2", "a2")], "q3"); + + expect(messages).toHaveLength(4); + expect(messages[1]).toEqual({ role: "user", content: "q2" }); + }); + + it("does not replay tool messages from earlier turns", () => { + const withTools: ChatTurn = { + ...turn("q1", "a1"), + toolCalls: [{ name: "web_search", arguments: {}, result: "a huge page", isError: false }], + }; + const messages = buildMessages("SYS", [withTools], "q2"); + + expect(messages.some((message) => message.role === "tool")).toBe(false); + expect(JSON.stringify(messages)).not.toContain("a huge page"); + }); +}); + +describe("composeSystemPrompt", () => { + it("uses the default prompt when the user has not set one", () => { + expect(composeSystemPrompt("", false)).toBe(DEFAULT_SYSTEM_PROMPT); + }); + + it("prefers the user's prompt when set", () => { + expect(composeSystemPrompt("Be terse.", false)).toBe("Be terse."); + }); + + it("appends the recency hint only when web tools are on", () => { + expect(composeSystemPrompt("Be terse.", true)).toBe(`Be terse.\n\n${RECENCY_HINT}`); + expect(composeSystemPrompt("Be terse.", false)).not.toContain(RECENCY_HINT); + }); +}); diff --git a/src/__tests__/location.test.ts b/src/__tests__/location.test.ts new file mode 100644 index 0000000..b61e40f --- /dev/null +++ b/src/__tests__/location.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { describeTimezone, getLocationContext, utcOffsetLabel, weekdayName } from "../lib/location"; + +describe("describeTimezone", () => { + it("splits a simple zone into region and city", () => { + expect(describeTimezone("Asia/Manila")).toEqual({ region: "Asia", city: "Manila" }); + }); + + it("handles zones with more than two segments", () => { + expect(describeTimezone("America/Argentina/Salta")).toEqual({ region: "America", city: "Salta" }); + }); + + it("underscores become spaces", () => { + expect(describeTimezone("America/New_York")).toEqual({ region: "America", city: "New York" }); + }); + + it("returns nothing for a bare zone like UTC", () => { + expect(describeTimezone("UTC")).toEqual({}); + }); +}); + +describe("utcOffsetLabel", () => { + it("renders a GMT offset for a fixed zone", () => { + const date = new Date("2026-01-02T03:04:05Z"); + expect(utcOffsetLabel(date, "Asia/Manila")).toContain("+08:00"); + }); + + it("reflects daylight saving rather than a fixed offset", () => { + const winter = utcOffsetLabel(new Date("2026-01-15T12:00:00Z"), "America/New_York"); + const summer = utcOffsetLabel(new Date("2026-07-15T12:00:00Z"), "America/New_York"); + + expect(winter).not.toBe(summer); + expect(winter).toContain("-05:00"); + expect(summer).toContain("-04:00"); + }); +}); + +describe("weekdayName", () => { + it("names the weekday in the requested zone", () => { + // 2026-01-02T03:04:05Z is a Friday in UTC. + expect(weekdayName(new Date("2026-01-02T03:04:05Z"), "UTC")).toBe("Friday"); + }); + + it("respects the zone when the local date differs from UTC", () => { + // 2026-01-02T23:30Z is still Friday in UTC but already Saturday in Manila. + const late = new Date("2026-01-02T23:30:00Z"); + expect(weekdayName(late, "UTC")).toBe("Friday"); + expect(weekdayName(late, "Asia/Manila")).toBe("Saturday"); + }); +}); + +describe("getLocationContext", () => { + it("reports the requested zone with its parts", () => { + const context = getLocationContext(new Date("2026-01-02T03:04:05Z"), "Asia/Manila"); + + expect(context.timezone).toBe("Asia/Manila"); + expect(context.city).toBe("Manila"); + expect(context.region).toBe("Asia"); + expect(context.utcOffset).toContain("+08:00"); + expect(context.locale.length).toBeGreaterThan(0); + }); + + it("still returns a usable context for an unknown zone", () => { + const context = getLocationContext(new Date("2026-01-02T03:04:05Z"), "Not/AReal_Zone"); + expect(context.timezone).toBe("Not/AReal_Zone"); + expect(context.utcOffset).toBe("UTC"); + }); +}); diff --git a/src/__tests__/markdown.test.ts b/src/__tests__/markdown.test.ts new file mode 100644 index 0000000..5d8478a --- /dev/null +++ b/src/__tests__/markdown.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + STREAMING_CURSOR, + buildTurnMarkdown, + pendingMarkdown, + previewText, + sessionToPlainText, +} from "../lib/markdown"; +import type { ChatTurn } from "../lib/types"; + +describe("buildTurnMarkdown", () => { + it("renders the question as a quote block above the answer", () => { + const markdown = buildTurnMarkdown({ prompt: "What is 2+2?", response: "4" }); + + expect(markdown).toContain("> What is 2+2?"); + expect(markdown.indexOf("> What is 2+2?")).toBeLessThan(markdown.indexOf("4")); + }); + + it("keeps a multi-line question inside the quote block", () => { + const markdown = buildTurnMarkdown({ prompt: "line one\nline two", response: "ok" }); + + expect(markdown).toContain("> line one\n> line two"); + }); + + it("marks a streaming answer with a cursor", () => { + const streaming = buildTurnMarkdown({ prompt: "q", response: "partial", isStreaming: true }); + const finished = buildTurnMarkdown({ prompt: "q", response: "partial", isStreaming: false }); + + expect(streaming).toContain(STREAMING_CURSOR); + expect(finished).not.toContain(STREAMING_CURSOR); + }); + + it("shows a placeholder while an answer is still empty", () => { + expect(buildTurnMarkdown({ prompt: "q", response: "", isStreaming: true })).toContain("Thinking"); + expect(buildTurnMarkdown({ prompt: "q", response: "", isStreaming: false })).toContain("No response"); + }); + + it("surfaces an error instead of hiding it", () => { + const markdown = buildTurnMarkdown({ prompt: "q", response: "", error: "Ollama rejected the API key." }); + expect(markdown).toContain("Ollama rejected the API key."); + }); + + it("lists tools that were used, flagging failures", () => { + const markdown = buildTurnMarkdown({ + prompt: "q", + response: "a", + toolCalls: [ + { name: "web_search", arguments: {}, result: "ok", isError: false }, + { name: "web_fetch", arguments: {}, result: "boom", isError: true }, + ], + }); + + expect(markdown).toContain("`web_search`"); + expect(markdown).toContain("`web_fetch` — failed"); + }); + + it("omits the reasoning section when there is none", () => { + expect(buildTurnMarkdown({ prompt: "q", response: "a" })).not.toContain("Reasoning"); + expect(buildTurnMarkdown({ prompt: "q", response: "a", thinking: "hmm" })).toContain("Reasoning"); + }); + + it("includes the model and timestamp in the footer", () => { + const markdown = buildTurnMarkdown({ + prompt: "q", + response: "a", + model: "gpt-oss:120b", + createdAt: 1_700_000_000_000, + }); + expect(markdown).toContain("`gpt-oss:120b`"); + }); +}); + +describe("pendingMarkdown", () => { + it("shows the question with an activity line", () => { + const markdown = pendingMarkdown("q", "Searching the web"); + expect(markdown).toContain("> q"); + expect(markdown).toContain("_Searching the web…_"); + }); + + it("falls back to a generic line when there is no status", () => { + expect(pendingMarkdown("q")).toContain("_Thinking…_"); + }); +}); + +describe("previewText", () => { + it("collapses newlines so a preview stays on one row", () => { + expect(previewText("a\n\nb", 40)).toBe("a b"); + }); + + it("clips with an ellipsis", () => { + expect(previewText("a".repeat(50), 5)).toHaveLength(5); + }); +}); + +describe("sessionToPlainText", () => { + function turn(prompt: string, response: string, error?: string): ChatTurn { + return { id: prompt, prompt, response, thinking: "", model: "m", createdAt: 0, toolCalls: [], error }; + } + + it("returns an empty string for an empty conversation", () => { + expect(sessionToPlainText([])).toBe(""); + }); + + it("keeps every question and answer", () => { + const text = sessionToPlainText([turn("q1", "a1"), turn("q2", "a2")]); + + expect(text).toContain("## q1"); + expect(text).toContain("a1"); + expect(text).toContain("## q2"); + expect(text).toContain("a2"); + }); + + it("includes the failure when a turn errored", () => { + const text = sessionToPlainText([turn("q", "", "Ollama rejected the API key.")]); + expect(text).toContain("Ollama rejected the API key."); + }); +}); diff --git a/src/__tests__/ollama.test.ts b/src/__tests__/ollama.test.ts new file mode 100644 index 0000000..e1563c5 --- /dev/null +++ b/src/__tests__/ollama.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { normalizeHost, parseNdjsonStream } from "../lib/ollama"; + +/** + * The NDJSON reader is the part of the client most likely to break in a way + * that is invisible locally and obvious in production: a chunk boundary landing + * mid-character or mid-line. These cases cover exactly that. + */ +async function collect(chunks: Uint8Array[]): Promise { + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }); + + const out: T[] = []; + for await (const item of parseNdjsonStream(stream)) out.push(item); + return out; +} + +function encode(text: string): Uint8Array { + return new TextEncoder().encode(text); +} + +describe("normalizeHost", () => { + it("falls back to Ollama Cloud when unset", () => { + expect(normalizeHost(undefined)).toBe("https://ollama.com"); + expect(normalizeHost("")).toBe("https://ollama.com"); + expect(normalizeHost(" ")).toBe("https://ollama.com"); + }); + + it("strips trailing slashes so endpoint paths do not double up", () => { + expect(normalizeHost("http://localhost:11434/")).toBe("http://localhost:11434"); + expect(normalizeHost("https://ollama.com///")).toBe("https://ollama.com"); + }); + + it("trims surrounding whitespace", () => { + expect(normalizeHost(" https://ollama.com ")).toBe("https://ollama.com"); + }); +}); + +describe("parseNdjsonStream", () => { + it("parses one object per line", async () => { + const result = await collect<{ n: number }>([encode('{"n":1}\n{"n":2}\n{"n":3}\n')]); + expect(result).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]); + }); + + it("reassembles objects split across chunk boundaries", async () => { + // The same payload, byte-sliced in awkward places. Concatenated, the chunks + // read: '{"message":{"content":"hello"}}\n{"message":{"content":" world"}}\n' + const result = await collect<{ message: { content: string } }>([ + encode('{"message":{"con'), + encode('tent":"hel'), + encode('lo"}}\n{"message'), + encode('":{"content":" world"}}\n'), + ]); + + expect(result.map((event) => event.message.content)).toEqual(["hello", " world"]); + }); + + it("reassembles a multi-byte character split across chunks", async () => { + const payload = `${JSON.stringify({ text: "café ☕ — ok" })}\n`; + const bytes = encode(payload); + + // Split inside the first multi-byte sequence (the "é"). + const cut = payload.indexOf("é") + 1; + const result = await collect<{ text: string }>([bytes.slice(0, cut), bytes.slice(cut)]); + + expect(result).toEqual([{ text: "café ☕ — ok" }]); + }); + + it("emits a trailing line that was never newline-terminated", async () => { + const result = await collect<{ n: number }>([encode('{"n":1}\n{"n":2}')]); + expect(result).toEqual([{ n: 1 }, { n: 2 }]); + }); + + it("skips blank lines", async () => { + const result = await collect<{ n: number }>([encode('{"n":1}\n\n\n{"n":2}\n\n')]); + expect(result).toEqual([{ n: 1 }, { n: 2 }]); + }); + + it("yields nothing for an empty stream", async () => { + expect(await collect([])).toEqual([]); + }); +}); diff --git a/src/__tests__/sessions.test.ts b/src/__tests__/sessions.test.ts new file mode 100644 index 0000000..06aea95 --- /dev/null +++ b/src/__tests__/sessions.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { __resetLocalStorage } from "../__mocks__/@vicinae/api"; +import { MAX_STORED_CHARS_PER_TURN, MAX_TURNS_PER_SESSION } from "../lib/constants"; +import { + capTurn, + capTurns, + createSession, + createTurn, + clearSessions, + deleteSession, + listSessions, + loadSession, + saveSession, + withTitleFromFirstTurn, +} from "../lib/sessions"; +import type { ChatTurn } from "../lib/types"; + +function turn(index: number, response = `answer ${index}`): ChatTurn { + return { + id: `turn-${index}`, + prompt: `question ${index}`, + response, + thinking: "", + model: "test-model", + createdAt: 1_000 + index, + toolCalls: [], + }; +} + +beforeEach(() => { + __resetLocalStorage(); +}); + +describe("session store", () => { + it("round-trips a session through LocalStorage", async () => { + const session = { ...createSession("test-model", 1_000), title: "First chat", turns: [turn(1)] }; + + await saveSession(session); + + const summaries = await listSessions(); + expect(summaries).toHaveLength(1); + expect(summaries[0]).toMatchObject({ id: session.id, title: "First chat", turnCount: 1 }); + + const loaded = await loadSession(session.id); + expect(loaded?.turns[0].response).toBe("answer 1"); + expect(loaded?.model).toBe("test-model"); + }); + + it("returns undefined for an unknown session id", async () => { + expect(await loadSession("does-not-exist")).toBeUndefined(); + }); + + it("orders sessions by most recently updated", async () => { + const older = { ...createSession("m", 1_000), title: "older", updatedAt: 1_000 }; + const newer = { ...createSession("m", 2_000), title: "newer", updatedAt: 5_000 }; + + await saveSession(older); + await saveSession(newer); + + const summaries = await listSessions(); + expect(summaries.map((summary) => summary.title)).toEqual(["newer", "older"]); + }); + + it("updates an existing index entry instead of duplicating it", async () => { + const session = createSession("m", 1_000); + await saveSession(session); + await saveSession({ ...session, title: "renamed", updatedAt: 2_000 }); + + const summaries = await listSessions(); + expect(summaries).toHaveLength(1); + expect(summaries[0].title).toBe("renamed"); + }); + + it("deletes a session and its index entry", async () => { + const session = createSession("m"); + await saveSession(session); + await deleteSession(session.id); + + expect(await listSessions()).toHaveLength(0); + expect(await loadSession(session.id)).toBeUndefined(); + }); + + it("clears everything", async () => { + await saveSession(createSession("m", 1)); + await saveSession(createSession("m", 2)); + await clearSessions(); + + expect(await listSessions()).toHaveLength(0); + }); + + it("survives a corrupt index rather than throwing", async () => { + const { LocalStorage } = await import("../__mocks__/@vicinae/api"); + await LocalStorage.setItem("lam.sessions.index", "{not json"); + + await expect(listSessions()).resolves.toEqual([]); + }); + + it("returns undefined for a corrupt session body", async () => { + const { LocalStorage } = await import("../__mocks__/@vicinae/api"); + await LocalStorage.setItem("lam.session.broken", '{"id":"broken"}'); + + await expect(loadSession("broken")).resolves.toBeUndefined(); + }); +}); + +describe("caps", () => { + it("keeps only the most recent turns", () => { + const many = Array.from({ length: MAX_TURNS_PER_SESSION + 5 }, (_, index) => turn(index)); + const capped = capTurns(many); + + expect(capped).toHaveLength(MAX_TURNS_PER_SESSION); + // The oldest five should be the ones dropped, not the newest. + expect(capped[0].id).toBe("turn-5"); + expect(capped[capped.length - 1].id).toBe(`turn-${MAX_TURNS_PER_SESSION + 4}`); + }); + + it("truncates an oversized turn and marks it", () => { + const huge = turn(1, "x".repeat(MAX_STORED_CHARS_PER_TURN + 500)); + const capped = capTurn(huge); + + expect(capped.response.length).toBeLessThan(MAX_STORED_CHARS_PER_TURN + 100); + expect(capped.response).toContain("[truncated]"); + }); + + it("applies caps on save so storage cannot grow without bound", async () => { + const session = { + ...createSession("m", 1_000), + turns: Array.from({ length: MAX_TURNS_PER_SESSION + 10 }, (_, index) => turn(index)), + }; + + await saveSession(session); + const loaded = await loadSession(session.id); + + expect(loaded?.turns).toHaveLength(MAX_TURNS_PER_SESSION); + }); +}); + +describe("withTitleFromFirstTurn", () => { + it("names the session from its first prompt", () => { + const session = { ...createSession("m", 1_000), turns: [turn(1)] }; + expect(withTitleFromFirstTurn(session).title).toBe("question 1"); + }); + + it("leaves an already-named session alone", () => { + const session = { ...createSession("m", 1_000), title: "Kept", turns: [turn(1)] }; + expect(withTitleFromFirstTurn(session).title).toBe("Kept"); + }); + + it("leaves an empty session unnamed", () => { + expect(withTitleFromFirstTurn(createSession("m", 1_000)).title).toBe("New chat"); + }); +}); + +describe("createTurn", () => { + it("starts with an empty response so the UI can stream into it", () => { + const created = createTurn("hello", "test-model", 1_000); + expect(created.response).toBe(""); + expect(created.prompt).toBe("hello"); + expect(created.toolCalls).toEqual([]); + }); +}); diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts new file mode 100644 index 0000000..b2df5f5 --- /dev/null +++ b/src/__tests__/utils.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { errorMessage, formatRelativeTime, titleFromPrompt, truncate } from "../lib/utils"; + +describe("truncate", () => { + it("collapses whitespace so list rows never wrap unexpectedly", () => { + expect(truncate("hello\n\n world", 40)).toBe("hello world"); + }); + + it("leaves short text alone", () => { + expect(truncate("short", 40)).toBe("short"); + }); + + it("clips with an ellipsis at the requested width", () => { + const result = truncate("a".repeat(100), 10); + expect(result).toBe(`${"a".repeat(9)}…`); + expect(result).toHaveLength(10); + }); +}); + +describe("formatRelativeTime", () => { + const now = 1_700_000_000_000; + + it("reports very recent timestamps as just now", () => { + expect(formatRelativeTime(now - 5_000, now)).toBe("just now"); + }); + + it("reports minutes and hours", () => { + expect(formatRelativeTime(now - 12 * 60_000, now)).toBe("12m ago"); + expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe("3h ago"); + }); + + it("reports days within the last week", () => { + expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe("2d ago"); + }); + + it("treats clock skew as just now rather than a negative age", () => { + expect(formatRelativeTime(now + 60_000, now)).toBe("just now"); + }); +}); + +describe("titleFromPrompt", () => { + it("uses the first non-empty line, stripped of markdown decoration", () => { + expect(titleFromPrompt("\n\n## How do I deploy?\nmore text", 60)).toBe("How do I deploy?"); + }); + + it("falls back to a placeholder for an empty prompt", () => { + expect(titleFromPrompt(" \n ", 60)).toBe("New chat"); + }); +}); + +describe("errorMessage", () => { + it("unwraps Error instances", () => { + expect(errorMessage(new Error("boom"))).toBe("boom"); + }); + + it("passes strings through", () => { + expect(errorMessage("plain failure")).toBe("plain failure"); + }); + + it("survives values that cannot be serialised", () => { + const circular: Record = {}; + circular.self = circular; + expect(typeof errorMessage(circular)).toBe("string"); + }); +}); diff --git a/src/chat.tsx b/src/chat.tsx new file mode 100644 index 0000000..d048b25 --- /dev/null +++ b/src/chat.tsx @@ -0,0 +1,17 @@ +import { type LaunchProps } from "@vicinae/api"; +import { ChatView } from "./lib/components/ChatView"; + +/** + * Entrypoint for the "chat" command. + * + * Deliberately thin: everything lives in {@link ChatView} so the same view can + * also be pushed onto the navigation stack from the sessions command. + * + * Assign this command a global shortcut in Vicinae's settings to get the + * launcher-and-start-typing behaviour. `fallbackText` is populated when the + * command is opened through a deeplink carrying text, so + * `vicinae://launch/lam-agent/chat?fallbackText=...` arrives pre-filled. + */ +export default function ChatCommand(props: LaunchProps) { + return ; +} diff --git a/src/lib/agent.ts b/src/lib/agent.ts new file mode 100644 index 0000000..34d7005 --- /dev/null +++ b/src/lib/agent.ts @@ -0,0 +1,134 @@ +import { MAX_TOOL_ITERATIONS } from "./constants"; +import { streamChat } from "./ollama"; +import { executeTool, toolLabel, type ToolContext } from "./tools"; +import type { ApiMessage, ToolCall, ToolCallRecord, ToolDefinition } from "./types"; + +/** + * The tool-calling loop. + * + * Kept free of any React or Vicinae imports so the whole thing can be driven + * from a plain unit test with a stubbed `fetch`. + */ + +export interface AgentEvents { + onContentDelta?: (delta: string) => void; + onThinkingDelta?: (delta: string) => void; + onToolCall?: (record: ToolCallRecord) => void; + /** A short line for the UI's activity indicator, e.g. "Searching the web". */ + onStatus?: (status: string) => void; +} + +export interface AgentOptions { + host: string; + apiKey: string; + model: string; + /** Omit or pass "off" to leave reasoning disabled. */ + think?: string; + messages: ApiMessage[]; + tools: ToolDefinition[]; + toolContext: Omit & { now?: Date }; + signal?: AbortSignal; + events?: AgentEvents; +} + +export interface AgentResult { + content: string; + thinking: string; + toolCalls: ToolCallRecord[]; +} + +/** + * Picks the authoritative tool-call list out of a stream. + * + * Ollama sends tool calls complete in the final streamed message rather than + * dribbling them out across chunks the way OpenAI does, so the last non-empty + * array wins. Exported because the assumption is worth pinning down in a test: + * if Ollama ever starts streaming partial calls, this is the one place to fix. + */ +export function pickToolCalls( + previous: ToolCall[] | undefined, + incoming: ToolCall[] | undefined, +): ToolCall[] | undefined { + return incoming && incoming.length > 0 ? incoming : previous; +} + +function normalizeToolCall(call: ToolCall): { name: string; arguments: Record } { + const name = typeof call.function?.name === "string" ? call.function.name : ""; + const rawArgs = call.function?.arguments; + const args = rawArgs && typeof rawArgs === "object" && !Array.isArray(rawArgs) ? rawArgs : {}; + return { name, arguments: args as Record }; +} + +export async function runAgent(options: AgentOptions): Promise { + const conversation: ApiMessage[] = [...options.messages]; + const toolCalls: ToolCallRecord[] = []; + let thinking = ""; + + for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) { + let content = ""; + let streamedThinking = ""; + let pending: ToolCall[] | undefined; + + for await (const chunk of streamChat({ + host: options.host, + apiKey: options.apiKey, + model: options.model, + messages: conversation, + tools: options.tools.length > 0 ? options.tools : undefined, + think: options.think, + signal: options.signal, + })) { + if (chunk.thinking) { + streamedThinking += chunk.thinking; + options.events?.onThinkingDelta?.(chunk.thinking); + } + if (chunk.content) { + content += chunk.content; + options.events?.onContentDelta?.(chunk.content); + } + pending = pickToolCalls(pending, chunk.toolCalls); + } + + thinking += streamedThinking; + + // No tool calls means this was the model's answer. Earlier iterations only + // produced a preamble, which is intentionally discarded rather than shown + // as a half-answer. + if (!pending || pending.length === 0) { + return { content, thinking, toolCalls }; + } + + conversation.push({ role: "assistant", content, tool_calls: pending }); + + for (const rawCall of pending) { + const { name, arguments: args } = normalizeToolCall(rawCall); + + if (!name) { + conversation.push({ role: "tool", content: "Malformed tool call: missing function name.", tool_name: "unknown" }); + toolCalls.push({ name: "unknown", arguments: {}, result: "Malformed tool call.", isError: true }); + continue; + } + + options.events?.onStatus?.(toolLabel(name)); + + const result = await executeTool(name, args, { + host: options.toolContext.host, + apiKey: options.toolContext.apiKey, + now: options.toolContext.now, + signal: options.signal, + }); + + const record: ToolCallRecord = { name, arguments: args, result: result.content, isError: result.isError }; + toolCalls.push(record); + options.events?.onToolCall?.(record); + + conversation.push({ role: "tool", content: result.content, tool_name: name }); + } + } + + return { + content: `_Stopped after ${MAX_TOOL_ITERATIONS} rounds of tool calls without a final answer._`, + thinking, + toolCalls, + }; +} diff --git a/src/lib/components/ChatView.tsx b/src/lib/components/ChatView.tsx new file mode 100644 index 0000000..085d286 --- /dev/null +++ b/src/lib/components/ChatView.tsx @@ -0,0 +1,238 @@ +import { Action, ActionPanel, Icon, Keyboard, List } from "@vicinae/api"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useChat } from "../hooks/useChat"; +import { buildTurnMarkdown, pendingMarkdown, previewText } from "../markdown"; +import { getLamPreferences } from "../preferences"; +import { listSessions, loadSession } from "../sessions"; +import type { ChatTurn, Session, SessionSummary } from "../types"; +import { formatRelativeTime, truncate } from "../utils"; +import { ComposeForm } from "./ComposeForm"; + +/** + * The chat surface. + * + * Layout notes, because they are consequences of how Vicinae renders lists + * rather than free choices: + * + * - The search bar doubles as the composer. It is the only always-focused text + * input in a List, which is what makes "hit a hotkey and just start typing" + * work. It is single-line, hence the separate ⌘↩ composer. + * - `filtering` is off. This list is a transcript, not a set of search results, + * so the builtin fuzzy filter would hide turns as the user types a question. + * - Turns render newest first. The answer the user just asked for should be the + * thing nearest the search bar, not something they have to scroll to. + * - Tab reaches the session dropdown in the search bar accessory, which is how + * "Tab to move through previous conversations" is wired. + */ + +/** Sentinel value for the dropdown row that starts a fresh conversation. */ +const NEW_SESSION_VALUE = "__new__"; + +export interface ChatViewProps { + /** Reopens an existing conversation instead of starting a new one. */ + initialSession?: Session; + /** Text handed over by a deeplink or fallback command. */ + initialPrompt?: string; +} + +function detailMarkdown(turn: ChatTurn, live: boolean, streamed: string, streamedThinking: string, status?: string): string { + if (live && !streamed && !turn.response) { + return pendingMarkdown(turn.prompt, status); + } + + return buildTurnMarkdown({ + prompt: turn.prompt, + response: live ? streamed : turn.response, + thinking: live ? streamedThinking : turn.thinking, + toolCalls: turn.toolCalls, + error: live ? undefined : turn.error, + model: turn.model, + createdAt: turn.createdAt, + isStreaming: live, + }); +} + +export function ChatView({ initialSession, initialPrompt }: ChatViewProps) { + const preferences = useMemo(() => getLamPreferences(), []); + const chat = useChat({ preferences, initialSession }); + + const [input, setInput] = useState(initialPrompt ?? ""); + const [sessions, setSessions] = useState([]); + + const refreshSessions = useCallback(() => { + void listSessions() + .then(setSessions) + .catch(() => setSessions([])); + }, []); + + // Refreshed after each turn so the Tab dropdown picks up the newly saved chat. + useEffect(() => { + refreshSessions(); + }, [refreshSessions, chat.session.updatedAt]); + + const send = useCallback( + (text: string) => { + chat.ask(text); + setInput(""); + }, + [chat.ask], + ); + + const stopAction = ( + + ); + + const sendAction = ( + send(input)} /> + ); + + const composeAction = ( + } + /> + ); + + const newChatAction = ( + + ); + + /** + * Every row carries the same primary action. Enter therefore always sends the + * current search text, whichever row happens to be selected, which is what + * makes the whole window feel like a single input box. + */ + const rowActions = (turn: ChatTurn) => ( + + {chat.isStreaming ? stopAction : sendAction} + {composeAction} + + + {newChatAction} + + ); + + const dropdownItems = useMemo(() => { + const hasCurrent = sessions.some((summary) => summary.id === chat.session.id); + const withCurrent: SessionSummary[] = hasCurrent + ? sessions + : [ + { + id: chat.session.id, + title: chat.session.title, + model: chat.session.model, + createdAt: chat.session.createdAt, + updatedAt: chat.session.updatedAt, + turnCount: chat.session.turns.length, + preview: "", + }, + ...sessions, + ]; + + return withCurrent; + }, [sessions, chat.session]); + + const orderedTurns = useMemo(() => [...chat.session.turns].reverse(), [chat.session.turns]); + + return ( + { + if (value === NEW_SESSION_VALUE) { + chat.newSession(); + return; + } + void loadSession(value).then((session) => { + if (session) chat.replaceSession(session); + }); + }} + > + + + {dropdownItems.map((summary) => ( + + ))} + + + } + actions={ + + {chat.isStreaming ? stopAction : sendAction} + {composeAction} + {newChatAction} + + } + > + {orderedTurns.length === 0 ? ( + + ) : ( + orderedTurns.map((turn, index) => { + const live = index === 0 && chat.isStreaming; + + const accessories: List.Item.Accessory[] = [{ text: formatRelativeTime(turn.createdAt) }]; + if (turn.toolCalls.length > 0) { + accessories.unshift({ tag: `${turn.toolCalls.length} tools` }); + } + + return ( + + } + actions={rowActions(turn)} + /> + ); + }) + )} + + ); +} diff --git a/src/lib/components/ComposeForm.tsx b/src/lib/components/ComposeForm.tsx new file mode 100644 index 0000000..9ab665c --- /dev/null +++ b/src/lib/components/ComposeForm.tsx @@ -0,0 +1,58 @@ +import { useNavigation } from "@vicinae/api"; +import { Form, Action, ActionPanel, Icon } from "@vicinae/api"; +import { useState } from "react"; + +/** + * The multi-line composer. + * + * This exists because the launcher's search bar is single-line and there is no + * API to change that. Pressing ⌘↩ in the chat view pushes this view, where a + * real multi-line text area gives the behaviour people expect from a chat box: + * Return inserts a newline, and ⌘↩ sends. + */ +export interface ComposeFormProps { + initialValue: string; + onSubmit: (text: string) => void; +} + +export function ComposeForm({ initialValue, onSubmit }: ComposeFormProps) { + const { pop } = useNavigation(); + const [error, setError] = useState(undefined); + + return ( +
+ { + const text = String(values.prompt ?? "").trim(); + if (!text) { + setError("Write something first."); + return; + } + // Pop before handing off so the chat view is already visible when + // streaming starts, rather than showing a stale composer. + pop(); + onSubmit(text); + }} + /> + + + } + > + + + + ); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..704e1c9 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,33 @@ +/** Tunable constants for Lam Agent. Anything a future contributor might want to change lives here. */ + +export const DEFAULT_HOST = "https://ollama.com"; +export const DEFAULT_MODEL = "gpt-oss:120b"; + +/** LocalStorage keys. The index is small; each session body gets its own key so saving one never rewrites the others. */ +export const STORAGE_INDEX_KEY = "lam.sessions.index"; +export const STORAGE_SESSION_PREFIX = "lam.session."; + +/** How many times the model may call tools before Lam gives up and reports back. */ +export const MAX_TOOL_ITERATIONS = 5; + +/** Oldest sessions past this count are evicted, along with their storage keys. */ +export const MAX_SESSIONS = 100; + +/** Defence against a single runaway session bloating LocalStorage. */ +export const MAX_TURNS_PER_SESSION = 200; +export const MAX_STORED_CHARS_PER_TURN = 20_000; + +/** How much of a prompt becomes the session title in the session list. */ +export const TITLE_MAX_LENGTH = 60; + +export const DEFAULT_SYSTEM_PROMPT = `You are Lam, a concise assistant built into the Vicinae launcher on the user's desktop. + +- Lead with the answer, then add supporting detail. Do not pad with pleasantries. +- You render inside a narrow launcher window: keep paragraphs short and prefer tight markdown lists. Avoid wide tables and large headings. +- Call the tools you are given when they would make your answer more accurate. Anything time-sensitive, or after your training cutoff, should be checked with a tool rather than guessed. +- Never invent tool output. If a tool fails, say so plainly and answer with what you already know. +- When you are unsure, say what you are unsure about instead of hedging vaguely.`; + +/** Extra instruction appended when the user has web tools enabled and the question looks time-sensitive. */ +export const RECENCY_HINT = + "If the answer depends on current events, prices, versions, or releases, use web_search before answering."; diff --git a/src/lib/history.ts b/src/lib/history.ts new file mode 100644 index 0000000..8271d4f --- /dev/null +++ b/src/lib/history.ts @@ -0,0 +1,30 @@ +import { DEFAULT_SYSTEM_PROMPT, RECENCY_HINT } from "./constants"; +import type { ApiMessage, ChatTurn } from "./types"; + +/** + * Turns stored turns into the message array sent to the model. + * + * Only completed question/answer pairs are replayed. The intermediate tool + * messages from a previous turn are intentionally dropped: they are noise once + * the answer is written, and replaying them would re-send large page contents + * on every subsequent request. + */ +export function buildMessages(systemPrompt: string, turns: ChatTurn[], prompt: string): ApiMessage[] { + const messages: ApiMessage[] = [{ role: "system", content: systemPrompt }]; + + for (const turn of turns) { + if (!turn.response) continue; + messages.push({ role: "user", content: turn.prompt }); + messages.push({ role: "assistant", content: turn.response }); + } + + messages.push({ role: "user", content: prompt }); + return messages; +} + +/** Uses the user's prompt if they set one, otherwise Lam's default. */ +export function composeSystemPrompt(customPrompt: string, webEnabled: boolean): string { + const parts = [customPrompt.trim() || DEFAULT_SYSTEM_PROMPT]; + if (webEnabled) parts.push(RECENCY_HINT); + return parts.join("\n\n"); +} diff --git a/src/lib/hooks/useChat.ts b/src/lib/hooks/useChat.ts new file mode 100644 index 0000000..e4dd7d1 --- /dev/null +++ b/src/lib/hooks/useChat.ts @@ -0,0 +1,268 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { runAgent } from "../agent"; +import { buildMessages, composeSystemPrompt } from "../history"; +import { OllamaError } from "../ollama"; +import type { ResolvedPreferences } from "../preferences"; +import { createSession, createTurn, saveSession, withTitleFromFirstTurn } from "../sessions"; +import { toolsFor } from "../tools"; +import type { Session } from "../types"; +import { errorMessage, isAbortError } from "../utils"; + +/** + * The chat state machine. + * + * Kept out of the component so `chat.tsx` stays a layout file, and so the + * streaming behaviour has one obvious home. + * + * Two details are worth calling out: + * + * 1. Streaming deltas are accumulated in refs and flushed to React state on a + * timer. A long answer arrives as thousands of tokens, and re-rendering the + * whole transcript per token makes the launcher visibly janky. + * 2. The live session is mirrored in a ref. `ask` needs the session as it is at + * call time, and reading it from state inside a stable callback would close + * over a stale value. + */ + +/** Coalescing window for streamed deltas. Short enough to feel live, long enough to stay smooth. */ +const FLUSH_INTERVAL_MS = 60; + +export interface UseChatOptions { + preferences: ResolvedPreferences; + /** Restores an existing conversation, e.g. when reopening from the sessions command. */ + initialSession?: Session; +} + +export interface UseChat { + session: Session; + isStreaming: boolean; + streamingResponse: string; + streamingThinking: string; + /** Short activity line such as "Searching the web", shown while a turn is in flight. */ + status?: string; + error?: string; + ask: (prompt: string) => void; + cancel: () => void; + replaceSession: (session: Session) => void; + newSession: () => void; + setModel: (model: string) => void; +} + +export function useChat(options: UseChatOptions): UseChat { + const [session, setSession] = useState( + () => options.initialSession ?? createSession(options.preferences.model), + ); + const [isStreaming, setIsStreaming] = useState(false); + const [streamingResponse, setStreamingResponse] = useState(""); + const [streamingThinking, setStreamingThinking] = useState(""); + const [status, setStatus] = useState(undefined); + const [error, setError] = useState(undefined); + + const sessionRef = useRef(session); + const preferencesRef = useRef(options.preferences); + const abortRef = useRef(null); + const liveRef = useRef(false); + const responseRef = useRef(""); + const thinkingRef = useRef(""); + const statusRef = useRef(undefined); + const flushTimerRef = useRef | null>(null); + + preferencesRef.current = options.preferences; + + /** Updates state and the ref together, so `ask` never reads a stale session. */ + const commit = useCallback((next: Session) => { + sessionRef.current = next; + setSession(next); + }, []); + + const clearFlushTimer = useCallback(() => { + if (flushTimerRef.current !== null) { + clearTimeout(flushTimerRef.current); + flushTimerRef.current = null; + } + }, []); + + const scheduleFlush = useCallback(() => { + if (flushTimerRef.current !== null) return; + + flushTimerRef.current = setTimeout(() => { + flushTimerRef.current = null; + if (!liveRef.current) return; + setStreamingResponse(responseRef.current); + setStreamingThinking(thinkingRef.current); + }, FLUSH_INTERVAL_MS); + }, []); + + const flushNow = useCallback(() => { + clearFlushTimer(); + setStreamingResponse(responseRef.current); + setStreamingThinking(thinkingRef.current); + }, [clearFlushTimer]); + + const setStatusValue = useCallback((value: string | undefined) => { + if (statusRef.current === value) return; + statusRef.current = value; + setStatus(value); + }, []); + + const cancel = useCallback(() => { + abortRef.current?.abort(); + }, []); + + /** Aborts any in-flight request when the command unmounts, so no stream survives the view. */ + useEffect(() => { + return () => { + liveRef.current = false; + abortRef.current?.abort(); + if (flushTimerRef.current !== null) clearTimeout(flushTimerRef.current); + }; + }, []); + + const ask = useCallback( + (rawPrompt: string) => { + const prompt = rawPrompt.trim(); + if (!prompt || liveRef.current) return; + + const preferences = preferencesRef.current; + const base = sessionRef.current; + + // The turn is inserted immediately with an empty response so the UI shows + // the question while the answer streams in beneath it. + const turn = createTurn(prompt, preferences.model); + const started = withTitleFromFirstTurn({ + ...base, + model: preferences.model, + turns: [...base.turns, turn], + }); + commit(started); + + responseRef.current = ""; + thinkingRef.current = ""; + setStreamingResponse(""); + setStreamingThinking(""); + setError(undefined); + setStatusValue("Thinking"); + liveRef.current = true; + setIsStreaming(true); + + const controller = new AbortController(); + abortRef.current = controller; + + const messages = buildMessages( + composeSystemPrompt(preferences.systemPrompt, preferences.webEnabled), + base.turns, + prompt, + ); + + void (async () => { + let failure: string | undefined; + let toolCalls = turn.toolCalls; + + try { + const result = await runAgent({ + host: preferences.host, + apiKey: preferences.apiKey, + model: preferences.model, + think: preferences.think, + messages, + tools: toolsFor({ webEnabled: preferences.webEnabled }), + toolContext: { host: preferences.host, apiKey: preferences.apiKey }, + signal: controller.signal, + events: { + onContentDelta: (delta) => { + responseRef.current += delta; + setStatusValue(undefined); + scheduleFlush(); + }, + onThinkingDelta: (delta) => { + thinkingRef.current += delta; + scheduleFlush(); + }, + onStatus: (value) => setStatusValue(value), + onToolCall: (record) => { + toolCalls = [...toolCalls, record]; + }, + }, + }); + + responseRef.current = result.content; + thinkingRef.current = result.thinking; + toolCalls = result.toolCalls; + } catch (thrown) { + if (isAbortError(thrown)) { + failure = "Cancelled."; + } else { + const hint = thrown instanceof OllamaError ? thrown.hint : undefined; + failure = hint ? `${errorMessage(thrown)} ${hint}` : errorMessage(thrown); + } + } + + const finished = { + ...turn, + response: responseRef.current, + thinking: thinkingRef.current, + toolCalls, + error: failure, + }; + + const updated = withTitleFromFirstTurn({ + ...started, + turns: [...base.turns, finished], + updatedAt: Date.now(), + }); + + liveRef.current = false; + abortRef.current = null; + commit(updated); + setIsStreaming(false); + setStatusValue(undefined); + setError(failure); + flushNow(); + + // Persistence is best-effort: a storage failure should not lose the + // answer that is already on screen. + void saveSession(updated).catch(() => undefined); + })(); + }, + [commit, flushNow, scheduleFlush, setStatusValue], + ); + + const replaceSession = useCallback( + (next: Session) => { + abortRef.current?.abort(); + liveRef.current = false; + setIsStreaming(false); + setStatusValue(undefined); + setError(undefined); + setStreamingResponse(""); + setStreamingThinking(""); + commit(next); + }, + [commit, setStatusValue], + ); + + const newSession = useCallback(() => { + replaceSession(createSession(preferencesRef.current.model)); + }, [replaceSession]); + + const setModel = useCallback( + (model: string) => { + commit({ ...sessionRef.current, model, updatedAt: Date.now() }); + }, + [commit], + ); + + return { + session, + isStreaming, + streamingResponse, + streamingThinking, + status, + error, + ask, + cancel, + replaceSession, + newSession, + setModel, + }; +} diff --git a/src/lib/location.ts b/src/lib/location.ts new file mode 100644 index 0000000..9e1dcdf --- /dev/null +++ b/src/lib/location.ts @@ -0,0 +1,100 @@ +/** + * Time and place, derived locally. + * + * `get_location` is intentionally timezone-derived rather than a GPS or IP + * lookup. It answers the questions people actually ask a launcher ("what time + * is it there", "is it late in London") without shipping the user's IP address + * to a third-party geolocation service on every tool call. It is also pure, so + * it is cheap to test. + */ + +export interface LocationContext { + timezone: string; + utcOffset: string; + locale: string; + region?: string; + city?: string; +} + +/** Splits an IANA zone like "Asia/Manila" or "America/Argentina/Salta" into readable parts. */ +export function describeTimezone(timeZone: string): { region?: string; city?: string } { + const parts = timeZone.split("/").filter(Boolean); + if (parts.length < 2) return {}; + + const region = parts[0]; + const city = parts[parts.length - 1].replace(/_/g, " "); + + return { region, city: city || undefined }; +} + +/** "GMT+08:00" on a modern V8; falls back to a short name if `longOffset` is unavailable. */ +export function utcOffsetLabel(date: Date, timeZone: string): string { + try { + const parts = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "longOffset" }).formatToParts( + date, + ); + const label = parts.find((part) => part.type === "timeZoneName")?.value; + if (label) return label; + } catch { + // Unsupported timeZone name: fall through to the short form below. + } + + try { + const parts = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "short" }).formatToParts(date); + return parts.find((part) => part.type === "timeZoneName")?.value ?? "UTC"; + } catch { + return "UTC"; + } +} + +function resolveTimeZone(timeZone?: string): string { + if (timeZone) return timeZone; + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return "UTC"; + } +} + +export function getLocationContext(date: Date = new Date(), timeZone?: string): LocationContext { + const zone = resolveTimeZone(timeZone); + + let locale = "en-US"; + try { + locale = Intl.DateTimeFormat().resolvedOptions().locale; + } catch { + // Keep the fallback locale. + } + + return { + timezone: zone, + utcOffset: utcOffsetLabel(date, zone), + locale, + ...describeTimezone(zone), + }; +} + +/** A full, unambiguous rendering of "now", e.g. "Saturday, 3 October 2026 at 20:11:04 GMT+08:00". */ +export function formatNow(date: Date, timeZone?: string): string { + const zone = resolveTimeZone(timeZone); + + try { + return new Intl.DateTimeFormat("en-US", { + timeZone: zone, + dateStyle: "full", + timeStyle: "long", + }).format(date); + } catch { + return date.toISOString(); + } +} + +/** The weekday name, which models frequently get wrong when left to arithmetic. */ +export function weekdayName(date: Date, timeZone?: string): string { + const zone = resolveTimeZone(timeZone); + try { + return new Intl.DateTimeFormat("en-US", { timeZone: zone, weekday: "long" }).format(date); + } catch { + return new Intl.DateTimeFormat("en-US", { weekday: "long" }).format(date); + } +} diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts new file mode 100644 index 0000000..64287cc --- /dev/null +++ b/src/lib/markdown.ts @@ -0,0 +1,98 @@ +import type { ChatTurn } from "./types"; + +/** + * Renders a turn as markdown for the detail panel. + * + * Kept as a pure function so the transcript layout can be tested without + * mounting a component, and so the same renderer can serve both a finished + * turn and one that is still streaming. + */ + +export const STREAMING_CURSOR = "▍"; + +export interface TurnMarkdownInput { + prompt: string; + response: string; + thinking?: string; + toolCalls?: ChatTurn["toolCalls"]; + error?: string; + model?: string; + createdAt?: number; + isStreaming?: boolean; +} + +/** Prefixes every line so a multi-line prompt renders as one continuous quote block. */ +function quote(text: string): string { + return text + .split("\n") + .map((line) => `> ${line}`.trimEnd()) + .join("\n"); +} + +export function buildTurnMarkdown(input: TurnMarkdownInput): string { + const sections: string[] = []; + + sections.push(quote(input.prompt)); + sections.push(""); + + const body = input.response.trim() || (input.isStreaming ? "_Thinking…_" : "_No response._"); + sections.push(input.isStreaming ? `${body} ${STREAMING_CURSOR}` : body); + + if (input.error) { + sections.push("", `**Error** — ${input.error}`); + } + + const toolCalls = input.toolCalls ?? []; + if (toolCalls.length > 0) { + const lines = toolCalls.map((call) => `- \`${call.name}\`${call.isError ? " — failed" : ""}`); + sections.push("", "**Tools used**", ...lines); + } + + if (input.thinking && input.thinking.trim().length > 0) { + sections.push("", "---", "", "**Reasoning**", "", quote(input.thinking.trim())); + } + + const footer: string[] = []; + if (input.model) footer.push(`\`${input.model}\``); + if (input.createdAt) footer.push(new Date(input.createdAt).toLocaleString()); + + if (footer.length > 0) { + sections.push("", "---", "", footer.join(" · ")); + } + + return sections.join("\n"); +} + +/** The placeholder shown while the first tokens are still in flight. */ +export function pendingMarkdown(prompt: string, status?: string): string { + const lines = [quote(prompt), ""]; + lines.push(status ? `_${status}…_` : "_Thinking…_"); + return lines.join("\n"); +} + +/** A short one-line preview of an answer, for a collapsed row. */ +export function previewText(text: string, max: number): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + if (collapsed.length <= max) return collapsed; + return `${collapsed.slice(0, Math.max(0, max - 1)).trimEnd()}…`; +} + +/** + * Flattens a whole conversation into plain text for the clipboard. + * + * This has to load the full transcript rather than reuse the index summary — + * copying a truncated preview and calling it "the conversation" would be a + * quiet lie. + */ +export function sessionToPlainText(turns: ChatTurn[]): string { + if (turns.length === 0) return ""; + + return turns + .map((turn) => { + const header = `## ${turn.prompt}`; + const body = turn.response.trim() || (turn.error ? "" : "_(no response)_"); + const failure = turn.error ? `\n\n**[error]** ${turn.error}` : ""; + return `${header}\n\n${body}${failure}`; + }) + .join("\n\n---\n\n"); +} diff --git a/src/lib/ollama.ts b/src/lib/ollama.ts new file mode 100644 index 0000000..e9690b6 --- /dev/null +++ b/src/lib/ollama.ts @@ -0,0 +1,290 @@ +import { DEFAULT_HOST } from "./constants"; +import type { ApiMessage, ModelInfo, ToolCall, ToolDefinition } from "./types"; + +/** + * Thin client for the Ollama HTTP API. + * + * Everything here is plain `fetch`, deliberately: the extension runs in Node + * inside Vicinae's extension runtime, so there is no reason to pull in the + * `ollama` npm package and its own transport for four endpoints. + */ + +/** An error with enough context for the UI to render something actionable rather than a raw status code. */ +export class OllamaError extends Error { + readonly status?: number; + readonly hint?: string; + + constructor(message: string, options: { status?: number; hint?: string } = {}) { + super(message); + this.name = "OllamaError"; + this.status = options.status; + this.hint = options.hint; + } +} + +/** Accepts `https://ollama.com/`, `http://localhost:11434`, or an empty preference, and yields a base URL with no trailing slash. */ +export function normalizeHost(host: string | undefined): string { + const trimmed = (host ?? "").trim(); + return (trimmed || DEFAULT_HOST).replace(/\/+$/, ""); +} + +function authHeaders(apiKey: string): Record { + const key = apiKey.trim(); + return key ? { Authorization: `Bearer ${key}` } : {}; +} + +async function safeText(response: Response): Promise { + try { + return await response.text(); + } catch { + return ""; + } +} + +function describeHttpError(status: number, bodyText: string, host: string): OllamaError { + const detail = bodyText.trim().slice(0, 300); + + switch (status) { + case 401: + case 403: + return new OllamaError("Ollama rejected the API key.", { + status, + hint: "Check the Ollama API Key preference. Create a new key at https://ollama.com/settings/keys.", + }); + case 404: + return new OllamaError(`Ollama returned 404 from ${host}.`, { + status, + hint: "The model name may be wrong, or this host may not expose that endpoint. Local Ollama servers do not provide the web tools.", + }); + case 429: + return new OllamaError("Ollama is rate limiting this key.", { + status, + hint: "Wait a moment and retry, or switch to a lighter model.", + }); + default: + return new OllamaError(`Ollama request failed with HTTP ${status}.`, { status, hint: detail }); + } +} + +/** + * Splits newline-delimited JSON into parsed objects. + * + * Exported for tests. Ollama streams NDJSON, and a network chunk can land + * anywhere: mid-line, or between the bytes of a multi-byte UTF-8 character. + * Both are handled here by buffering until a newline arrives and letting + * TextDecoder hold incomplete sequences back. + */ +export async function* parseNdjsonStream(stream: ReadableStream): AsyncGenerator { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + if (line) yield JSON.parse(line) as T; + newlineIndex = buffer.indexOf("\n"); + } + } + + // Flush whatever the decoder is still holding, then the final unterminated line. + buffer += decoder.decode(); + const tail = buffer.trim(); + if (tail) yield JSON.parse(tail) as T; + } finally { + try { + reader.releaseLock(); + } catch { + // The stream was already torn down, typically because the request was aborted. + } + } +} + +interface OllamaChatEvent { + message?: { + role?: string; + content?: string; + thinking?: string; + tool_calls?: ToolCall[]; + }; + done?: boolean; + done_reason?: string; + error?: string; +} + +export interface ChatStreamChunk { + content?: string; + thinking?: string; + toolCalls?: ToolCall[]; + done: boolean; + doneReason?: string; +} + +export interface StreamChatOptions { + host: string; + apiKey: string; + model: string; + messages: ApiMessage[]; + tools?: ToolDefinition[]; + /** Omit, or pass "off", to leave reasoning disabled. */ + think?: string | boolean; + signal?: AbortSignal; +} + +/** + * Streams a chat completion. + * + * Because this is an async generator, the request is not issued until the + * caller starts iterating, which lets the UI attach its loading state first. + */ +export async function* streamChat(options: StreamChatOptions): AsyncGenerator { + const host = normalizeHost(options.host); + + const body: Record = { + model: options.model, + messages: options.messages, + stream: true, + }; + if (options.tools && options.tools.length > 0) body.tools = options.tools; + if (options.think && options.think !== "off") body.think = options.think; + + const response = await fetch(`${host}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) }, + body: JSON.stringify(body), + signal: options.signal, + }); + + if (!response.ok) { + throw describeHttpError(response.status, await safeText(response), host); + } + if (!response.body) { + throw new OllamaError("Ollama returned an empty response body."); + } + + for await (const event of parseNdjsonStream(response.body)) { + if (event.error) { + throw new OllamaError(event.error); + } + + yield { + // Ollama sends empty strings for absent fields; normalise to undefined so + // callers can use a simple truthiness check. + content: event.message?.content || undefined, + thinking: event.message?.thinking || undefined, + toolCalls: event.message?.tool_calls, + done: Boolean(event.done), + doneReason: event.done_reason, + }; + } +} + +export interface ListModelsOptions { + host: string; + apiKey: string; + signal?: AbortSignal; +} + +/** Lists the models this API key can reach. On Ollama Cloud this reflects the account's entitlements. */ +export async function listModels(options: ListModelsOptions): Promise { + const host = normalizeHost(options.host); + + const response = await fetch(`${host}/api/tags`, { + headers: authHeaders(options.apiKey), + signal: options.signal, + }); + + if (!response.ok) { + throw describeHttpError(response.status, await safeText(response), host); + } + + const payload = (await response.json()) as { + models?: Array<{ name?: string; model?: string; details?: { parameter_size?: string } }>; + }; + + return (payload.models ?? []) + .map((model) => ({ + name: model.name ?? model.model ?? "", + parameterSize: model.details?.parameter_size, + })) + .filter((model) => model.name.length > 0) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export interface WebSearchResult { + title: string; + url: string; + content: string; +} + +export interface WebSearchOptions { + host: string; + apiKey: string; + query: string; + maxResults?: number; + signal?: AbortSignal; +} + +export async function webSearch(options: WebSearchOptions): Promise { + const host = normalizeHost(options.host); + + const body: Record = { query: options.query }; + if (typeof options.maxResults === "number") body.max_results = options.maxResults; + + const response = await fetch(`${host}/api/web_search`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) }, + body: JSON.stringify(body), + signal: options.signal, + }); + + if (!response.ok) { + throw describeHttpError(response.status, await safeText(response), host); + } + + const payload = (await response.json()) as { results?: WebSearchResult[] }; + return payload.results ?? []; +} + +export interface WebFetchResult { + title: string; + content: string; + links: string[]; +} + +export interface WebFetchOptions { + host: string; + apiKey: string; + url: string; + signal?: AbortSignal; +} + +export async function webFetch(options: WebFetchOptions): Promise { + const host = normalizeHost(options.host); + + const response = await fetch(`${host}/api/web_fetch`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) }, + body: JSON.stringify({ url: options.url }), + signal: options.signal, + }); + + if (!response.ok) { + throw describeHttpError(response.status, await safeText(response), host); + } + + const payload = (await response.json()) as Partial; + return { + title: payload.title ?? options.url, + content: payload.content ?? "", + links: payload.links ?? [], + }; +} diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts new file mode 100644 index 0000000..006e511 --- /dev/null +++ b/src/lib/preferences.ts @@ -0,0 +1,54 @@ +import { getPreferenceValues } from "@vicinae/api"; +import { DEFAULT_HOST, DEFAULT_MODEL } from "./constants"; + +/** + * Preferences, read and normalised. + * + * Declared locally rather than relying on the `vicinae-env.d.ts` that the + * `vici` toolchain generates, so this file typechecks and unit-tests without a + * codegen step. The manifest in `package.json` remains the source of truth; if + * a preference is added there, mirror it here. + */ + +export interface LamPreferences { + apiKey?: string; + model?: string; + ollamaHost?: string; + systemPrompt?: string; + webSearchEnabled?: boolean; + thinking?: string; +} + +/** Preferences with every optional value filled in, so callers never branch on undefined. */ +export interface ResolvedPreferences { + apiKey: string; + model: string; + host: string; + systemPrompt: string; + webEnabled: boolean; + /** Undefined when reasoning is off, which is what the API client expects. */ + think?: string; +} + +export function resolvePreferences(raw: LamPreferences): ResolvedPreferences { + const thinking = (raw.thinking ?? "").trim(); + + return { + apiKey: (raw.apiKey ?? "").trim(), + model: (raw.model ?? "").trim() || DEFAULT_MODEL, + host: (raw.ollamaHost ?? "").trim() || DEFAULT_HOST, + systemPrompt: (raw.systemPrompt ?? "").trim(), + // Defaults to on: the preference is a checkbox the user has to actively untick. + webEnabled: raw.webSearchEnabled ?? true, + think: thinking && thinking !== "off" ? thinking : undefined, + }; +} + +export function getLamPreferences(): ResolvedPreferences { + return resolvePreferences(getPreferenceValues()); +} + +/** True when the extension cannot work until the user supplies a key. */ +export function needsSetup(preferences: ResolvedPreferences): boolean { + return preferences.apiKey.length === 0; +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts new file mode 100644 index 0000000..91091c2 --- /dev/null +++ b/src/lib/schemas.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +/** + * Schemas guarding everything read back from LocalStorage. + * + * Stored data outlives the code that wrote it, so this is the boundary where an + * older or hand-edited payload gets rejected instead of crashing a render. + */ + +export const ToolCallRecordSchema = z.object({ + name: z.string(), + arguments: z.record(z.string(), z.unknown()), + result: z.string(), + isError: z.boolean(), +}); + +export const ChatTurnSchema = z.object({ + id: z.string(), + prompt: z.string(), + response: z.string(), + thinking: z.string().default(""), + model: z.string(), + createdAt: z.number(), + error: z.string().optional(), + toolCalls: z.array(ToolCallRecordSchema).default([]), +}); + +export const SessionSchema = z.object({ + id: z.string(), + title: z.string(), + model: z.string(), + createdAt: z.number(), + updatedAt: z.number(), + turns: z.array(ChatTurnSchema).default([]), +}); + +export const SessionSummarySchema = z.object({ + id: z.string(), + title: z.string(), + model: z.string(), + createdAt: z.number(), + updatedAt: z.number(), + turnCount: z.number(), + preview: z.string(), +}); + +export const SessionIndexSchema = z.array(SessionSummarySchema); + +export type StoredSession = z.infer; diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts new file mode 100644 index 0000000..3f2856b --- /dev/null +++ b/src/lib/sessions.ts @@ -0,0 +1,182 @@ +import { LocalStorage } from "@vicinae/api"; +import { MAX_SESSIONS, MAX_STORED_CHARS_PER_TURN, MAX_TURNS_PER_SESSION, STORAGE_INDEX_KEY, STORAGE_SESSION_PREFIX, TITLE_MAX_LENGTH } from "./constants"; +import { SessionIndexSchema, SessionSchema } from "./schemas"; +import type { ChatTurn, Session, SessionSummary } from "./types"; +import { titleFromPrompt, truncate } from "./utils"; + +/** + * Session persistence on top of Vicinae's LocalStorage. + * + * LocalStorage only accepts strings, numbers, and booleans, so everything is + * JSON-encoded. An index holding small summaries is stored separately from the + * session bodies: listing sessions is a hot path, and it should not require + * decoding every transcript. + * + * A session body is one key, so saving a turn rewrites only that session. + * Transcripts are capped twice over, per turn and per session, because + * LocalStorage has no documented ceiling and a runaway loop should not be able + * to wedge the extension. + */ + +function newId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function sessionKey(id: string): string { + return `${STORAGE_SESSION_PREFIX}${id}`; +} + +function summarize(session: Session): SessionSummary { + const lastTurn = session.turns[session.turns.length - 1]; + return { + id: session.id, + title: session.title, + model: session.model, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + turnCount: session.turns.length, + preview: lastTurn ? truncate(lastTurn.response || lastTurn.prompt, TITLE_MAX_LENGTH * 2) : "", + }; +} + +export function createSession(model: string, now: number = Date.now()): Session { + return { + id: newId(), + title: "New chat", + model, + createdAt: now, + updatedAt: now, + turns: [], + }; +} + +/** Creates the turn record for a prompt that is about to be sent. */ +export function createTurn(prompt: string, model: string, now: number = Date.now()): ChatTurn { + return { + id: newId(), + prompt, + response: "", + thinking: "", + model, + createdAt: now, + toolCalls: [], + }; +} + +/** Drops the oldest turns once a session exceeds its cap, keeping the most recent context. */ +export function capTurns(turns: ChatTurn[]): ChatTurn[] { + return turns.length <= MAX_TURNS_PER_SESSION ? turns : turns.slice(turns.length - MAX_TURNS_PER_SESSION); +} + +/** Trims a single turn's stored text so one enormous answer cannot dominate storage. */ +export function capTurn(turn: ChatTurn): ChatTurn { + const cap = (text: string): string => + text.length <= MAX_STORED_CHARS_PER_TURN ? text : `${text.slice(0, MAX_STORED_CHARS_PER_TURN)}\n\n[truncated]`; + + return { + ...turn, + prompt: cap(turn.prompt), + response: cap(turn.response), + thinking: cap(turn.thinking), + toolCalls: turn.toolCalls.map((call) => ({ ...call, result: cap(call.result) })), + }; +} + +export async function listSessions(): Promise { + try { + const raw = await LocalStorage.getItem(STORAGE_INDEX_KEY); + if (!raw) return []; + + const parsed = SessionIndexSchema.safeParse(JSON.parse(raw)); + if (!parsed.success) return []; + + return [...parsed.data].sort((a, b) => b.updatedAt - a.updatedAt); + } catch { + // A corrupt index should degrade to "no sessions", never to a crashed command. + return []; + } +} + +async function writeIndex(summaries: SessionSummary[]): Promise { + await LocalStorage.setItem(STORAGE_INDEX_KEY, JSON.stringify(summaries)); +} + +export async function loadSession(id: string): Promise { + try { + const raw = await LocalStorage.getItem(sessionKey(id)); + if (!raw) return undefined; + + const parsed = SessionSchema.safeParse(JSON.parse(raw)); + if (!parsed.success) return undefined; + + return { + ...parsed.data, + turns: parsed.data.turns.map((turn) => ({ ...turn, toolCalls: turn.toolCalls ?? [] })), + }; + } catch { + return undefined; + } +} + +/** + * Persists a session and refreshes its index entry, evicting the oldest + * sessions past {@link MAX_SESSIONS} along with their storage keys. + */ +export async function saveSession(session: Session): Promise { + const capped: Session = { + ...session, + turns: capTurns(session.turns).map(capTurn), + }; + + await LocalStorage.setItem(sessionKey(capped.id), JSON.stringify(capped)); + + const summaries = await listSessions(); + const others = summaries.filter((summary) => summary.id !== capped.id); + const merged = [summarize(capped), ...others].sort((a, b) => b.updatedAt - a.updatedAt); + + const kept = merged.slice(0, MAX_SESSIONS); + const evicted = merged.slice(MAX_SESSIONS); + + await writeIndex(kept); + + for (const summary of evicted) { + try { + await LocalStorage.removeItem(sessionKey(summary.id)); + } catch { + // Losing an eviction just leaves an orphaned key; not worth failing the save. + } + } +} + +export async function deleteSession(id: string): Promise { + const summaries = await listSessions(); + await writeIndex(summaries.filter((summary) => summary.id !== id)); + + try { + await LocalStorage.removeItem(sessionKey(id)); + } catch { + // Already gone. + } +} + +export async function clearSessions(): Promise { + const summaries = await listSessions(); + + for (const summary of summaries) { + try { + await LocalStorage.removeItem(sessionKey(summary.id)); + } catch { + // Keep going: clearing as much as possible beats failing outright. + } + } + + await LocalStorage.removeItem(STORAGE_INDEX_KEY); +} + +/** Recomputes a session's title from its first prompt, once there is a prompt worth naming it after. */ +export function withTitleFromFirstTurn(session: Session): Session { + if (session.turns.length === 0) return session; + if (session.title !== "New chat") return session; + + return { ...session, title: titleFromPrompt(session.turns[0].prompt, TITLE_MAX_LENGTH) }; +} diff --git a/src/lib/tools.ts b/src/lib/tools.ts new file mode 100644 index 0000000..a79b6c4 --- /dev/null +++ b/src/lib/tools.ts @@ -0,0 +1,242 @@ +import { formatNow, getLocationContext, weekdayName } from "./location"; +import { webFetch, webSearch } from "./ollama"; +import type { ToolDefinition } from "./types"; +import { errorMessage } from "./utils"; + +/** + * The tools Lam can call. + * + * All four are read-only. That is a deliberate boundary for this version: an + * assistant that can silently act on the user's machine needs a confirmation + * story first, and this release is chat-only. Anything with a side effect + * (opening URLs, clipboard access, launching apps) belongs behind an approval + * step that does not exist yet. + */ + +export interface ToolContext { + host: string; + apiKey: string; + signal?: AbortSignal; + /** Injectable clock so tests are deterministic. */ + now?: Date; +} + +export interface ToolResult { + content: string; + isError: boolean; +} + +/** Search snippets are truncated so a chatty result set cannot swallow the model's context window. */ +const MAX_SNIPPET_CHARS = 1200; +const MAX_FETCH_CHARS = 8000; + +export const TOOL_DEFINITIONS: ToolDefinition[] = [ + { + type: "function", + function: { + name: "get_datetime", + description: + "Get the user's current local date and time, including the weekday, timezone, and UTC offset. Use this whenever an answer depends on what today's date is or what time it is now.", + parameters: { type: "object", properties: {}, required: [] }, + }, + }, + { + type: "function", + function: { + name: "get_location", + description: + "Get the user's approximate location context: timezone, UTC offset, locale, region, and city. Use it for local questions such as weather, business hours, or travel timing. This is derived from the device timezone, not GPS, so treat it as a region rather than an address.", + parameters: { type: "object", properties: {}, required: [] }, + }, + }, + { + type: "function", + function: { + name: "web_search", + description: + "Search the web for current information. Use it for recent events, prices, releases, documentation changes, or anything that may have happened after your training cutoff. Returns a list of titles, URLs, and content snippets.", + parameters: { + type: "object", + properties: { + query: { type: "string", description: "The search query. Be specific." }, + max_results: { + type: "integer", + description: "How many results to return, between 1 and 10. Defaults to 5.", + }, + }, + required: ["query"], + }, + }, + }, + { + type: "function", + function: { + name: "web_fetch", + description: + "Fetch a single web page and return its title, main content as markdown, and the links on the page. Use it after web_search to read a promising result in full, or directly when the user supplies a URL.", + parameters: { + type: "object", + properties: { + url: { type: "string", description: "Absolute URL to fetch, including the scheme." }, + }, + required: ["url"], + }, + }, + }, +]; + +/** Tools that need Ollama Cloud. A local Ollama server does not implement these endpoints. */ +export const WEB_TOOL_NAMES = new Set(["web_search", "web_fetch"]); + +/** Human-readable labels for the activity line in the UI. */ +export const TOOL_LABELS: Record = { + get_datetime: "Checking the time", + get_location: "Checking your location", + web_search: "Searching the web", + web_fetch: "Reading a page", +}; + +export function toolLabel(name: string): string { + return TOOL_LABELS[name] ?? `Running ${name}`; +} + +/** Filters out the web tools when the user has turned them off. */ +export function toolsFor(options: { webEnabled: boolean }): ToolDefinition[] { + return TOOL_DEFINITIONS.filter((tool) => options.webEnabled || !WEB_TOOL_NAMES.has(tool.function.name)); +} + +function asString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function asCount(value: unknown, min: number, max: number): number | undefined { + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed)) return undefined; + return Math.min(max, Math.max(min, Math.trunc(parsed))); +} + +function isWebTool(name: string): boolean { + return WEB_TOOL_NAMES.has(name); +} + +/** + * Runs one tool call and always resolves. + * + * Tool failures are returned as `isError: true` rather than thrown, because the + * model should get a chance to recover, retry with different arguments, or + * explain the failure to the user. Throwing here would abort the whole turn + * over something the model can often route around. + */ +export async function executeTool( + name: string, + args: Record, + context: ToolContext, +): Promise { + try { + switch (name) { + case "get_datetime": + return { content: describeNow(context.now), isError: false }; + + case "get_location": + return { content: describeLocation(context.now), isError: false }; + + case "web_search": + return await runWebSearch(args, context); + + case "web_fetch": + return await runWebFetch(args, context); + + default: + return { content: `Unknown tool "${name}".`, isError: true }; + } + } catch (error) { + const suffix = isWebTool(name) + ? " The web tools require an Ollama Cloud host and an API key with web search enabled." + : ""; + return { content: `${errorMessage(error)}${suffix}`, isError: true }; + } +} + +function describeNow(now?: Date): string { + const date = now ?? new Date(); + const location = getLocationContext(date); + + return [ + `Current date and time: ${formatNow(date, location.timezone)}`, + `Weekday: ${weekdayName(date, location.timezone)}`, + `Timezone: ${location.timezone} (${location.utcOffset})`, + `Unix timestamp: ${Math.floor(date.getTime() / 1000)}`, + `ISO 8601 (UTC): ${date.toISOString()}`, + ].join("\n"); +} + +function describeLocation(now?: Date): string { + const date = now ?? new Date(); + const location = getLocationContext(date); + + const lines = [ + `Timezone: ${location.timezone}`, + `UTC offset: ${location.utcOffset}`, + `Locale: ${location.locale}`, + ]; + if (location.city) lines.push(`City: ${location.city}`); + if (location.region) lines.push(`Region: ${location.region}`); + lines.push("Note: derived from the device timezone. It indicates region, not a precise address."); + + return lines.join("\n"); +} + +async function runWebSearch(args: Record, context: ToolContext): Promise { + const query = asString(args.query); + if (!query) { + return { content: 'web_search requires a "query" string.', isError: true }; + } + + const results = await webSearch({ + host: context.host, + apiKey: context.apiKey, + query, + maxResults: asCount(args.max_results, 1, 10), + signal: context.signal, + }); + + if (results.length === 0) { + return { content: `No web results for "${query}".`, isError: false }; + } + + const rendered = results + .map((result, index) => { + const snippet = result.content.replace(/\s+/g, " ").trim().slice(0, MAX_SNIPPET_CHARS); + return `${index + 1}. ${result.title}\n ${result.url}\n ${snippet}`; + }) + .join("\n\n"); + + return { content: `Web results for "${query}":\n\n${rendered}`, isError: false }; +} + +async function runWebFetch(args: Record, context: ToolContext): Promise { + const url = asString(args.url); + if (!url) { + return { content: 'web_fetch requires a "url" string.', isError: true }; + } + + const page = await webFetch({ + host: context.host, + apiKey: context.apiKey, + url, + signal: context.signal, + }); + + const content = page.content.slice(0, MAX_FETCH_CHARS); + const truncated = page.content.length > MAX_FETCH_CHARS ? "\n\n[content truncated]" : ""; + const links = page.links.slice(0, 20); + + const sections = [`Title: ${page.title}`, `URL: ${url}`, "", content + truncated]; + if (links.length > 0) { + sections.push("", `Links on page:\n${links.map((link) => `- ${link}`).join("\n")}`); + } + + return { content: sections.join("\n"), isError: false }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..f4635cb --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,87 @@ +/** + * Shared domain types for Lam Agent. + * + * These are deliberately split into two families: + * + * - `ApiMessage` and friends mirror exactly what Ollama's `/api/chat` accepts. + * - `Session`, `ChatTurn` and friends are Lam's own storage model. + * + * Keeping them separate matters because the storage model is derived from the + * API model, not identical to it: the tool-calling messages that Ollama needs + * mid-turn are not persisted, so a reloaded session replays cleanly as plain + * user/assistant pairs. + */ + +export type ChatRole = "system" | "user" | "assistant" | "tool"; + +export interface ToolCall { + function: { + name: string; + arguments: Record; + }; +} + +export interface ToolDefinition { + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +/** A message in the shape Ollama's `/api/chat` endpoint expects. */ +export interface ApiMessage { + role: ChatRole; + content: string; + tool_calls?: ToolCall[]; + /** Only set on `role: "tool"` messages, naming the tool that produced this content. */ + tool_name?: string; +} + +/** What a tool actually returned during a turn, kept for display and debugging. */ +export interface ToolCallRecord { + name: string; + arguments: Record; + result: string; + isError: boolean; +} + +/** One question and its answer. The unit the transcript is rendered from. */ +export interface ChatTurn { + id: string; + prompt: string; + response: string; + thinking: string; + model: string; + createdAt: number; + /** Set when the turn failed, so a stored session can show what went wrong. */ + error?: string; + toolCalls: ToolCallRecord[]; +} + +export interface Session { + id: string; + title: string; + model: string; + createdAt: number; + updatedAt: number; + turns: ChatTurn[]; +} + +/** The lightweight record kept in the session index, so listing sessions never loads full transcripts. */ +export interface SessionSummary { + id: string; + title: string; + model: string; + createdAt: number; + updatedAt: number; + turnCount: number; + preview: string; +} + +/** A model as reported by `GET /api/tags`. */ +export interface ModelInfo { + name: string; + parameterSize?: string; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..fe98a7e --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,56 @@ +/** Small pure helpers shared across the extension. Kept dependency-free so they are trivial to test. */ + +/** + * Collapses whitespace and clips to `max` characters, adding an ellipsis when clipped. + * Used for list rows and session titles, where a stray newline would break the layout. + */ +export function truncate(text: string, max: number): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + if (collapsed.length <= max) return collapsed; + return `${collapsed.slice(0, Math.max(0, max - 1)).trimEnd()}…`; +} + +/** "just now", "12m ago", "3h ago", then an absolute date once it is older than a day. */ +export function formatRelativeTime(timestamp: number, now: number = Date.now()): string { + const deltaSeconds = Math.round((now - timestamp) / 1000); + + if (!Number.isFinite(deltaSeconds) || deltaSeconds < 0) return "just now"; + if (deltaSeconds < 45) return "just now"; + + const minutes = Math.round(deltaSeconds / 60); + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + + const days = Math.round(hours / 24); + if (days < 7) return `${days}d ago`; + + return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +/** Pulls a human-readable message out of anything that can be thrown. */ +export function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +/** True when a rejected promise came from an AbortController rather than a real failure. */ +export function isAbortError(error: unknown): boolean { + return error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted")); +} + +/** + * Builds the first line of a prompt into a session title. + * Prompt text often starts with markdown decoration, which reads badly in a list row. + */ +export function titleFromPrompt(prompt: string, max: number): string { + const firstLine = prompt.split("\n").find((line) => line.trim().length > 0) ?? prompt; + const cleaned = firstLine.replace(/^[#>*\-\s]+/, "").trim(); + return truncate(cleaned || "New chat", max); +} diff --git a/src/sessions.tsx b/src/sessions.tsx new file mode 100644 index 0000000..bbe5fb6 --- /dev/null +++ b/src/sessions.tsx @@ -0,0 +1,113 @@ +import { Action, ActionPanel, Clipboard, Icon, List, useNavigation } from "@vicinae/api"; +import { useCallback, useEffect, useState } from "react"; +import { ChatView } from "./lib/components/ChatView"; +import { sessionToPlainText } from "./lib/markdown"; +import { clearSessions, deleteSession, listSessions, loadSession } from "./lib/sessions"; +import type { SessionSummary } from "./lib/types"; +import { formatRelativeTime, truncate } from "./lib/utils"; + +/** + * Browse and manage past conversations. + * + * Loading a transcript is deferred until the user actually opens one: the + * index holds only summaries, so this view stays fast no matter how much + * history has piled up. + */ +export default function SessionsCommand() { + const { push } = useNavigation(); + const [sessions, setSessions] = useState(undefined); + + const refresh = useCallback(() => { + void listSessions() + .then(setSessions) + .catch(() => setSessions([])); + }, []); + + useEffect(refresh, [refresh]); + + const openSession = useCallback( + (id: string) => { + void loadSession(id).then((session) => { + if (session) push(); + }); + }, + [push], + ); + + const remove = useCallback( + (id: string) => { + void deleteSession(id).then(refresh); + }, + [refresh], + ); + + const copyConversation = useCallback((id: string) => { + // Loads the full transcript rather than copying the index preview. + void loadSession(id).then((session) => { + if (session) void Clipboard.copy(sessionToPlainText(session.turns)); + }); + }, []); + + const removeAll = useCallback(() => { + void clearSessions().then(refresh); + }, [refresh]); + + const loaded = sessions !== undefined; + const items = sessions ?? []; + + return ( + + {loaded && items.length === 0 ? ( + + ) : null} + + {items.map((summary) => ( + + openSession(summary.id)} + /> + copyConversation(summary.id)} + /> + + remove(summary.id)} + /> + + + + } + /> + ))} + + ); +} diff --git a/src/setup.tsx b/src/setup.tsx new file mode 100644 index 0000000..22615b1 --- /dev/null +++ b/src/setup.tsx @@ -0,0 +1,185 @@ +import { Action, ActionPanel, Icon, List, openExtensionPreferences } from "@vicinae/api"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { getLamPreferences, needsSetup } from "./lib/preferences"; +import { listModels } from "./lib/ollama"; +import type { ModelInfo } from "./lib/types"; +import { errorMessage } from "./lib/utils"; + +/** + * Setup and diagnostics. + * + * Two things are worth understanding about how configuration works here. + * + * First, Vicinae asks for `required` preferences with no default *before* the + * command starts. The API key and the model are both declared that way in the + * manifest, so the two-step "key, then model" onboarding happens on the very + * first launch without any code in this file. + * + * Second, an extension can read preferences but cannot write them: there is no + * `setPreferenceValues`. So this command does not save anything. It verifies + * the connection, lists the models the key can actually reach, and hands the + * exact model name to the clipboard so it can be pasted into settings. That is + * more useful than a form that silently fights the launcher's own settings UI, + * and it means the model list can never drift out of sync with the real one. + */ + +/** Shows enough of a key to recognise it without printing the whole thing. */ +function maskKey(apiKey: string): string { + if (apiKey.length === 0) return "Not set"; + if (apiKey.length <= 8) return "•".repeat(apiKey.length); + return `${apiKey.slice(0, 4)}${"•".repeat(6)}${apiKey.slice(-4)}`; +} + +export default function SetupCommand() { + const preferences = useMemo(() => getLamPreferences(), []); + const [models, setModels] = useState([]); + const [error, setError] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [hasTested, setHasTested] = useState(false); + + const missingKey = needsSetup(preferences); + + const testConnection = useCallback(async () => { + setIsLoading(true); + setError(undefined); + + try { + const result = await listModels({ host: preferences.host, apiKey: preferences.apiKey }); + setModels(result); + } catch (thrown) { + setModels([]); + setError(errorMessage(thrown)); + } finally { + setHasTested(true); + setIsLoading(false); + } + }, [preferences]); + + // Verifying on open means the user sees something real immediately rather + // than having to guess whether their key works. + useEffect(() => { + if (!missingKey) void testConnection(); + }, [missingKey, testConnection]); + + const openPreferences = useCallback(() => { + void openExtensionPreferences(); + }, []); + + const settingsActions = ( + + + void testConnection()} + /> + + ); + + const statusText = (() => { + if (missingKey) return "No API key yet"; + if (isLoading) return "Checking…"; + if (error) return "Connection failed"; + if (hasTested) return `Connected · ${models.length} models`; + return "Not checked"; + })(); + + const statusIcon = error || missingKey ? Icon.QuestionMarkCircle : Icon.SpeechBubble; + + return ( + + + + + + + + + + + {error ? ( + + + + ) : null} + + {missingKey ? ( + + + + + + } + /> + + ) : null} + + {models.length > 0 ? ( + + {models.map((model) => ( + + + + + } + /> + ))} + + ) : null} + + ); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a580baa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "include": ["src/**/*", "vicinae-env.d.ts"], + "compilerOptions": { + "lib": ["ES2023", "DOM"], + "module": "commonjs", + "target": "ES2023", + "strict": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "resolveJsonModule": true + } +} diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..d8ca48c --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,21 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +/** + * `.mts` rather than `.ts`: this package is CommonJS (the Vicinae toolchain + * compiles extensions with `module: commonjs`), and Vite warns when an ESM + * config is loaded as CJS. The extension lets us write the config as ESM. + */ +export default defineConfig({ + test: { + globals: true, + include: ["src/__tests__/**/*.test.ts"], + }, + resolve: { + alias: { + // Point the API package at the in-memory stub; there is no Vicinae + // runtime under test. + "@vicinae/api": fileURLToPath(new URL("./src/__mocks__/@vicinae/api.ts", import.meta.url)), + }, + }, +});