Compare commits
13
Commits
35d88f635c
..
prod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00307021c6 | ||
|
|
2adc6d74b5 | ||
|
|
34c4383428 | ||
|
|
ade6145425 | ||
|
|
0705d5ec2f | ||
|
|
fcc6d26c9d | ||
|
|
72a63e69e6 | ||
|
|
a804afd56a | ||
|
|
550a2bbefe | ||
|
|
ff99778e5d | ||
|
|
26e05dea5b | ||
|
|
05fd9ff13b | ||
|
|
576c5946f7 |
@@ -16,9 +16,15 @@ Hit a global shortcut, type a question, get a streamed answer without leaving wh
|
||||
- **Persistent conversations.** Every chat is saved locally. The sidebar lists them by recency, and reopening the command picks up where you left off, unless it has been idle longer than you allow.
|
||||
- **Readable code.** Fenced blocks render in a dark panel with the language named in a header, syntax colouring, and a Copy button.
|
||||
- **Real markdown.** Tables, bulleted and numbered lists, headings, blockquotes, bold, italics and links all render natively, and the default prompt asks for the shape that fits the answer rather than flattening everything into prose.
|
||||
- **Tools.** Lam decides when to call them: current time, location context, web search, and page fetching.
|
||||
- **Maths, real HTML, and diagrams.** LaTeX renders, so an equation arrives as an equation instead of a wall of backslashes. A small amount of HTML draws as well, for the status chip or tiny grid a sentence is worse at, and a diagram drawn with box characters keeps its alignment instead of being reflowed. See [Rendering](#rendering).
|
||||
- **Tools.** Lam decides when to call them: current time, location context, web search, page fetching, and, when you switch file access on, seven tools that list, search, read, write, edit, move and delete files.
|
||||
- **Files you allow, and nothing else.** Off by default. When it is on, Lam can read under your home directory except for credential stores, and can only change files in Downloads, `Documents/Lam Agent`, its own folder, and wherever you add. Every change is shown to you with the exact path before it happens, a delete or a move asks every time, and a credential file is refused by name. See [Files](#files).
|
||||
- **Sources you can check.** A search result and a fetched page each get a number, and those numbers are the ones Lam cites, so `[1]` in the answer and `[1]` under it are the same page. The list sits under the answer it belongs to, each number opens the page it names, and a number Lam was never shown stays as plain text rather than becoming a link to somewhere else.
|
||||
- **Tool calls you can watch and stop.** What Lam is doing right now stays on screen, including when it asks the same question twice, the round limit is a preference, Force Stop is always one keystroke away, and a call that repeats itself with identical arguments ends the turn instead of running forever.
|
||||
- **Models, from a list.** Browse every model your host can reach, pick one and start using it immediately, and on a server you run, pull new ones or delete old ones. Tag names are picked, never typed.
|
||||
- **Optional auto-deletion.** Set a retention window and Lam clears out the conversations you have stopped using the next time you open a command, taking their screenshots with them and collecting any capture you never sent. Off by default, and nothing at all is read or written while it is off. The setup screen also has both tidy-ups as explicit actions, confirmed first, for when you would rather press the button yourself.
|
||||
- **Screenshots, two ways.** Snip a region into the conversation you are already in from the action panel, or run **Snip to Lam** to start a new chat about it. The launcher window gets out of the way first, so it never ends up in the shot, and the capture appears in the transcript as a thumbnail with Open Screenshot for the full image. Neither is offered unless the current model can actually see images.
|
||||
- **Chat only.** Lam answers questions. It does not touch your files or run commands. The one exception is capture, and it only ever happens because you pressed the key for it.
|
||||
- **Chat answers, and nothing else happens unless you allow it.** Lam does not run commands, open URLs or touch the clipboard, and the one thing it can change on the machine is a file, which it can only do when you turn File Access on and approve each change.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -62,12 +68,12 @@ npm run build
|
||||
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.
|
||||
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`. Typing it by hand is the fragile part, so run **Manage Models** afterwards and pick the real one from the list.
|
||||
3. **`Setup & Status`** verifies the connection, lists every model your key can actually reach, and reports which one is in use and where that came from.
|
||||
|
||||
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.
|
||||
To change the model later, run **Manage Models** and pick one, or edit the **Model** preference directly.
|
||||
|
||||
> 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.
|
||||
> An extension can read preferences but not write them, which is why a pick made in **Manage Models** is stored by the extension and layered over the preference rather than saved into it. See [Models](#models) for how the two are reconciled, and for what a reset does.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
@@ -77,7 +83,7 @@ To change the model later, either edit the **Model** preference directly, or run
|
||||
| `↩` with an empty bar | Open the highlighted conversation |
|
||||
| `⌘ N` | New chat |
|
||||
| `⌘ ⇧ S` | Snip a region and attach it to this chat (only with a vision model) |
|
||||
| `⌘ ⇧ ⌫` | Delete the highlighted conversation (also in **Chat Sessions**) |
|
||||
| `⌘ ⇧ ⌫` | Delete the highlighted conversation (also in **Chat History**) |
|
||||
|
||||
Prompts are single line. An earlier `⌘ ↩` multi-line composer was removed. See [Architecture](#architecture) for why a `Form.TextArea` cannot be laid out cleanly in a launcher. The `Tab` shortcut went with it: the left column *is* the conversation switcher now, so an accessory dropdown would only duplicate it.
|
||||
|
||||
@@ -112,37 +118,233 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
|
||||
| --- | --- |
|
||||
| **Chat with Lam** | The chat surface. Bind this to your global shortcut. |
|
||||
| **Snip to Lam** | Capture a region and start a new chat about it. A `no-view` command, so the launcher window closes before the selector appears. Bind this to a second shortcut for one-key "snip and ask". |
|
||||
| **Chat Sessions** | Browse, reopen, and delete past conversations. |
|
||||
| **Set Up Lam Agent** | Verify the connection, list available models, and check whether the selected model can see images. |
|
||||
| **Chat History** | Browse, reopen, and delete past conversations. |
|
||||
| **Manage Models** | Browse the models the configured host can reach, pick the one Lam uses, and pull or delete models on a server you run. See [Models](#models). |
|
||||
| **Setup & Status** | Verify the connection, list available models, check whether the selected model can see images, and clear out chats or unattached photos. |
|
||||
|
||||
## Configuration
|
||||
|
||||
| Preference | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| **Ollama API Key** | None | Required. Stored by Vicinae as a password preference. |
|
||||
| **Model** | None | Required. Any model your key can reach. |
|
||||
| **Model** | None | Required. Any model your key can reach. **Manage Models** can pick one for you, and a pick takes precedence over this value until you reset it from the setup screen. |
|
||||
| **Ollama Host** | `https://ollama.com` | Set to `http://localhost:11434` for a local server. |
|
||||
| **Location** | None | The place Lam should treat as yours, for example `Cebu City, Philippines`. Optional. The only exact location available, and the only one that stops anything being inferred. |
|
||||
| **IP Geolocation** | off | Off by default. When on, and only when **Location** is empty, your IP is sent to ipwho.is to resolve a rough area. |
|
||||
| **System Prompt** | Lam's default | Replaces the built-in prompt entirely. The default asks for plain prose: no em dashes, no generated-text tells, no restating the question. |
|
||||
| **Web Tools** | on | Enables `web_search` and `web_fetch`. |
|
||||
| **File Access** | off | Off by default. Enables the seven file tools. Off means the model never learns they exist. See [Files](#files). |
|
||||
| **Extra Readable Folders** | None | One absolute path per line. Added to your home directory, which is always readable except for credential stores. |
|
||||
| **Extra Writable Folders** | None | One absolute path per line. Added to Downloads and `Documents/Lam Agent`. Every change is still approved one at a time. |
|
||||
| **Reasoning Effort** | Off | `low`/`medium`/`high` for models that support thinking. |
|
||||
| **Show Tool Calls** | on | Lists the tools Lam called under each answer. |
|
||||
| **Show Reasoning** | on | Includes the model's thinking trace. Turn off to keep replies short. |
|
||||
| **Show Tool Calls** | on | Lists which tools Lam called, each named once, with a count of any that failed. Off hides the names; a failed or repeating call is still noted in one line. |
|
||||
| **Show Reasoning** | on | Includes the model's thinking trace under each answer. Turn off to keep replies short. Either way the trace streams live during a turn and disappears when the turn ends. |
|
||||
| **Start New Chat After** | 1 hour | Idle time past which opening the command starts a fresh chat. `Never` always reopens the last one. The old chat is never deleted either way. |
|
||||
| **Delete Old Chats After** | Disabled | Permanently deletes a conversation once you have not opened it for this long, along with any screenshots it captured. Nothing is ever deleted while this is off. See [Auto-deletion](#auto-deletion). |
|
||||
| **Tool Call Limit** | 8 rounds | How many rounds of tool calls Lam may make before it has to answer with what it has. `Unlimited` removes the cap. A call that repeats itself with identical arguments ends the turn either way. |
|
||||
|
||||
## Models
|
||||
|
||||
**Manage Models** lists everything the configured host can reach, filtered by the search bar. Each row shows the parameter count and how much disk it takes, with tags for the model in use and for anything that reports image input. Picking one writes it to LocalStorage and `loadPreferences` layers it over the `Model` preference, so it takes effect on the next question with no restart.
|
||||
|
||||
| Action | Notes |
|
||||
| --- | --- |
|
||||
| **Use This Model** | The primary action. Immediate, and survives restarts. |
|
||||
| **Unload Model** | Only on a row that is loaded. No confirmation, because nothing is deleted. |
|
||||
| **Copy Model Name** | For pasting into the `Model` preference. |
|
||||
| **Delete Model** | Confirmed first. A server you run only. |
|
||||
| **Pull a Model…** | Confirmed by the download itself. A server you run only. |
|
||||
|
||||
### Loaded models
|
||||
|
||||
Installed and loaded are different states, and the list shows both. `GET /api/ps` reports what is holding memory right now, and those rows get a green `loaded` tag, so "why is my GPU busy" has an answer in the same place as "what do I have".
|
||||
|
||||
**Unload** frees the memory without deleting anything, which is why it needs no confirmation: the worst case is that the next question pays to load the model again. It is `POST /api/generate` with `keep_alive: 0` and no prompt, which is how Ollama expresses "release this now", and what its own `ollama stop` does. The reply reports `done_reason: "unload"`, and the toast says how much was freed.
|
||||
|
||||
A host that does not serve `/api/ps` (older builds answer 404) simply shows no loaded tags. That check resolving to nothing is not an error, and it must not take the model list down with it.
|
||||
|
||||
### Why a pick is not written to the preference
|
||||
|
||||
It cannot be. `@vicinae/api` exposes `getPreferenceValues` and nothing that sets one, so the only way to change the preference would be to write Vicinae's own config file. That does not work: preferences are read once at startup and held in memory, so an edit to `~/.config/vicinae/settings.json` is invisible until Vicinae restarts. This was measured rather than assumed. Pointing `ollamaHost` at a local listener that logged every request, then launching a command, showed the requests still going to the old host.
|
||||
|
||||
Writing that file would also be a lost update, because Vicinae owns it and rewrites it (its own header warns as much), and it is undocumented, so it would break on any release that changes the layout.
|
||||
|
||||
So the pick lives in LocalStorage and the preference is left alone. The cost is stated rather than hidden: **while a pick is active, editing the `Model` preference has no effect.** That is why the setup screen shows both values, `ornith-1.5:9b (chosen in Models; the preference says gpt-oss:120b)`, and offers **Reset to the Preference**.
|
||||
|
||||
### Local servers versus Ollama Cloud
|
||||
|
||||
Pull and delete only appear when the host is not `ollama.com`, because they are operations on a machine you control.
|
||||
|
||||
A local daemon's list is often mixed, and the two kinds are worth telling apart:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **local** | Real weights on disk. Deleting frees the gigabytes the size column shows. |
|
||||
| **cloud** | A `:cloud` entry the daemon proxies to ollama.com (`remote_model`/`remote_host` in the API). Only a few hundred bytes are stored, so there is no size to show, and deleting drops the reference rather than any weights. |
|
||||
|
||||
Deleting is confirmed with wording that matches the kind, since "delete this model" means something different in each case. Deleting the model that is currently in use clears the pick, so the next question does not fail on a name that no longer exists. If the preference is what pointed at it, that is said instead: a preference cannot be edited from here.
|
||||
|
||||
### Pull
|
||||
|
||||
`POST /api/pull` streams its progress, and the toast follows it: the manifest status first, then `downloading · 42% · 1.2 GB / 2.9 GB`, then success. A bad name comes back as **HTTP 200 with the error in the body**, so the stream is checked and not just the status, which is the same trap the chat stream already had.
|
||||
|
||||
The download runs inside this command's worker, so **closing the launcher cancels a pull.** The command description says so, and the form says so again next to the field.
|
||||
|
||||
## Tools
|
||||
|
||||
Lam calls these on its own when they would improve an answer. All four are read-only, and all four are the entire list of things Lam is allowed to do without being asked twice. Screen capture is deliberately not among them.
|
||||
Lam calls these on its own when they would improve an answer. The four below are read-only, and they are the entire list of things Lam is allowed to do without being asked first. The file tools are a separate family, because they are the only ones that can change the machine: they are absent from the model's list until you switch **File Access** on, and every change they make is confirmed by you. Screen capture is deliberately not among any of them.
|
||||
|
||||
| Tool | What it does |
|
||||
| --- | --- |
|
||||
| `get_datetime` | Current local date, time, weekday, timezone, and UTC offset. |
|
||||
| `get_location` | Timezone, UTC offset, locale, region, and city. |
|
||||
| `get_location` | The place you configured, or a rough city from your IP if you enabled that, plus timezone, UTC offset, locale and region. |
|
||||
| `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".
|
||||
`get_location` reports the **Location** you set, if you set one, because that is the only answer here that is actually about where you are. Everything else is inferred from your device, and the inference is coarser than it looks.
|
||||
|
||||
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.
|
||||
**A timezone is not a city.** This is the trap the tool used to fall into: it reported `City: Manila`, taken from the last segment of the IANA zone name. That name is the database's representative place, not yours, and a single zone is often a whole country. `Asia/Manila` is the Philippines, `Asia/Kolkata` is India, `Europe/Oslo` is Norway, `Australia/Brisbane` is Queensland. Reporting the zone's namesake as your city was wrong for anyone outside it, and confidently so, because a model asked about the weather will use it without hesitating. The zone's label is still reported, as `Timezone area`, and named for what it is.
|
||||
|
||||
The **region** comes from your locale, which is a declaration rather than an inference: a locale of `en-PH` says the Philippines because that is what you set. It costs nothing and it cannot be wrong the way a zone name can.
|
||||
|
||||
If you turn on **IP Geolocation**, and only then, `get_location` resolves a rough area from your IP address via [ipwho.is](https://ipwho.is). That is a third party learning approximately where you are, so it is off by default, it is skipped entirely whenever you have set a Location (the better answer is already there, and there is no reason to spend your privacy on a worse one), and the tool output names the provider and calls the result a rough guess rather than a city.
|
||||
|
||||
**It is coarser than "city-level" suggests, and that is worth knowing before trusting it.** An IP address identifies the point where the traffic left the ISP, not the person. Measured from one machine on Globe Telecom, an IPv4 lookup resolved to Cebu City and an IPv6 lookup moments later resolved to Talisay in Calabarzon, roughly 500km apart. If you want a place you can rely on, set **Location** and turn this off.
|
||||
|
||||
The answer is cached for 6 hours rather than looked up per question, because an area does not change between questions and every lookup hands over another copy of your address. `Setup & Status` clears that cache, and the capability cache, when you re-test.
|
||||
|
||||
This extension does not use GPS. That would mean CoreLocation on macOS, which has no command-line interface, or GeoClue on Linux, whose usual provider resolves nearby Wi-Fi networks through a Mozilla service anyway. Neither is worth it for "is it late where you are".
|
||||
|
||||
**The provider has to be one that answers an unauthenticated caller.** The first choice was ipapi.co, which no longer does: a request with no User-Agent now returns `HTTP 429` with `"Please sign up for a paid plan"`, and adding a browser User-Agent gets an `HTTP 403` Cloudflare bot challenge instead. ipwho.is answers without a key and signals failures with `success: false` on an `HTTP 200`, so the body is checked as well as the status. `GEO_PROVIDER` is a single constant, and swapping it means changing the parser beside it.
|
||||
|
||||
Web search results and fetched pages can be large. Lam truncates them before they reach the model.
|
||||
|
||||
**The web tools use a different path per host, and there is no path that works for both.** Ollama Cloud serves `/api/web_search` and `/api/web_fetch`; a local daemon serves the same operations under `/api/experimental/`. Both were probed directly: the cloud route answers `401` without a key, which means it exists, and its experimental alias is a `404`; a local 0.35.1 does the exact opposite. This was a live bug, because the extension posted to the cloud path on every host, so pointing it at a local server turned every web call into a 404 and the model, handed a failure and no explanation, told the user its web tools were not configured. The other path is now tried when the first answers 404, since that means the route is absent rather than the request being refused.
|
||||
|
||||
**Lam is told what it is.** `describeRuntime` in `history.ts` appends a short paragraph naming the model, the server it is served by, whether a `:cloud` name means the conversation leaves the machine, which tools are actually available, and whether it can read images. It is appended even when the user has written their own system prompt, because none of it is a matter of style. Without it the model guessed, and the guesses were confident and wrong: a local model described Vicinae as "a desktop launcher for Ollama", reported its own web tools as unavailable, and answered a question about an attached screenshot by reasoning out loud about whether it was able to see, instead of describing it.
|
||||
|
||||
**A turn that runs out of tool calls still ends in an answer.** The loop stops after 8 rounds by default, and the first version of it stopped by throwing away every preamble the model had written and returning a notice about the loop: asked for the fish in every Valheim biome, it researched for five rounds and then said nothing useful at all. The budget is now spent on a *closing round* with the tools taken away and an instruction to answer from what was gathered and to name what it could not confirm. So the worst case is a partial answer that says what is missing, rather than an app-generated message about its own control flow. If the closing round produces nothing either, the last preamble is used, and only then a notice. The closing round is why the default cap is 8 rather than 5: the extra round is not more searching, it is the answer.
|
||||
|
||||
The cap is a preference rather than a constant, because how many rounds a question needs is a property of the question and of the model. `Unlimited` is there for research that genuinely needs a dozen pages.
|
||||
|
||||
**Two identical calls run, a third refuses to.** No round cap can be a complete guard, because one of the settings is `Unlimited`. What a stuck model actually does is repeat itself: the same tool, the same arguments, the same non-answer. So `agent.ts` counts the tool name together with its sorted arguments, per turn, allows the call twice (a retry after a search that failed is reasonable), and on the third occurrence does not run it. The round finishes, the loop breaks, and the closing round is reached with an instruction that names the repeated call instead of claiming a budget ran out. The refusal is recorded on the turn with `repeated: true`, so the transcript can say what happened.
|
||||
|
||||
Arguments are compared with their keys sorted, because models are not consistent about key order and a call that differs only by key order is still the same call.
|
||||
|
||||
**What Lam is doing is visible while it is doing it, even with tool calls hidden.** `Show Tool Calls` governs the finished transcript, where a list of names adds little. The live activity line is separate and always shown, because during a turn the difference between working and circling is the entire question: `_Reading a page…_` for a first call, and `_Reading a page · the same call again (2)…_` once it has come round. Hiding that would leave the user watching a spinner they cannot interpret, which is the situation the repeat guard exists to fix.
|
||||
|
||||
When tool calls are off, nothing is listed. A call that failed, or that was cut short for repeating, still gets one quiet sentence (`_2 tool calls failed._`, `_a call kept repeating, so the turn was cut short._`) rather than the names: those are the two cases where hiding the line leaves an answer the user cannot account for, and naming the tools was never the part that mattered.
|
||||
|
||||
**That line is rendered in exactly one place, on the live message.** It is not a view-level note above the transcript, and it is not a substitute for missing text either: it is shown whether or not tokens have arrived, because a tool that starts mid-answer would otherwise be invisible for the rest of the turn. The first version had both, a note in the view and the placeholder in the transcript, and a tool running before any text arrived printed "Reading a page" twice, one line above the other. The cursor rides on whichever line is written last, so it always trails the text actually arriving.
|
||||
|
||||
### Files
|
||||
|
||||
Off by default, and off means absent rather than refused: with **File Access** unticked the seven tools are not in the list the model is given, so it never learns they exist and cannot ask for one. That is the honest way to say "Lam cannot read files" when the setting is off.
|
||||
|
||||
| Tool | What it does |
|
||||
| --- | --- |
|
||||
| `list_directory` | Names, sizes and modification times in a folder, directories first. Dotfiles are hidden unless asked for. |
|
||||
| `search_files` | Finds files by a name glob, optionally only those whose contents contain a string. |
|
||||
| `read_file` | Numbered lines, 200 at a time, with the offset to continue from. |
|
||||
| `write_file` | Creates a file, or replaces one that exists. |
|
||||
| `edit_file` | Replaces one exact string, refusing when it appears more than once unless `all` is set. |
|
||||
| `delete_path` | One file, or one empty folder. |
|
||||
| `move_path` | Moves or renames, refusing when the destination exists. |
|
||||
|
||||
**Where it may read.** Everything under your home directory, except the credential stores below, plus anything you add under **Extra Readable Folders**.
|
||||
|
||||
**Where it may write.** `~/Downloads`, `~/Documents/Lam Agent`, the extension's own support directory, and anything you add under **Extra Writable Folders**. A file outside those is refused before any dialog appears, because there is nothing to approve.
|
||||
|
||||
**Every change is a dialog.** It names the tool's intent and the exact path, and states what is being replaced: the size and age of the file it is about to overwrite, or the substitution itself for an edit. Allow is the default button. A refusal comes back to the model as a refusal, and the model is told it is final, so it should say what it could not do rather than trying again.
|
||||
|
||||
The ledger behind that dialog is per answer, not per session. The second write to the same path while answering one question goes through without asking, and the next question asks again, because "yes, edit that file while we work on it" is not consent to whatever happens to it twenty minutes later. A delete or a move skips the ledger entirely and asks every time: an approval given for an edit is not consent to remove the thing that was just edited.
|
||||
|
||||
**A delete never recurses.** A folder with anything in it is refused, with the count, so the worst a misread path can do is bounded. A move is refused when the destination exists, so a move can never destroy the file it lands on.
|
||||
|
||||
**The denylist.** These are refused by name, on the resolved path, so a symlink is judged by where it lands: `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.config/gh`, `~/.netrc`, `~/.docker/config.json`, `id_rsa` and its siblings, `credentials.json`, `.env` and `.env.*`, and `.pem`, `.key` and `.p12` files anywhere. It is a guard, not a proof: a key file with a name nobody thought of is still readable. It exists because of the sentence below.
|
||||
|
||||
**Reading is disclosure.** Everything Lam reads is sent to the model provider with the conversation. That is the same disclosure the web tools already make, and it is why the read boundary is as considered as the write boundary, why the denylist exists, and why the prompt tells the model to read what the question needs and nothing more. If that trade is not one you want, leave File Access off: nothing else in this extension reads your machine, and the switch is the whole of the permission.
|
||||
|
||||
**Caps, stated in the output when they bite.** 200 lines and 8 KB per read, 200 entries per listing, 50 results and 8 levels deep per search, 5 MB per file. A binary file is refused with its size rather than returned as unreadable text, and a file too large to read is refused rather than streamed: this is a launcher, not a file manager.
|
||||
|
||||
**What the boundary does not do.** Containment is checked after resolving symlinks, but the check and the write are not one atomic step, so a symlink swapped in between them by another process running as you would not be noticed. That is a threat from a process that already has your account, and closing it needs handle-based operations rather than a path policy. It is written down here so nobody assumes otherwise.
|
||||
|
||||
## Auto-deletion
|
||||
|
||||
Off by default. When it is on, Lam deletes a conversation once you have not used it for the configured period, and deletes the screenshots that belonged to it.
|
||||
|
||||
It is checked when any Lam command opens (chat, sessions, setup) and after every answer, so an expired conversation goes the next time you use Lam. The alternative is a timer, which would mean waking up to do work while nothing is open.
|
||||
|
||||
**Age is counted from the last time you used a conversation, not from when you started it.** A chat started last month and continued this morning is not an old chat, and deleting it because of its birthday would be a surprise.
|
||||
|
||||
**The conversation you are in is never deleted**, even if it is expired. It is about to be rendered, and being the one the command opens by default is the clearest possible sign that it is still in use. The practical consequence is that a conversation you keep returning to outlives the window indefinitely, which is the intended reading of "conversations you have not opened". Sweeping *after* the startup resolution rather than before is what makes this work: sweeping first would delete the very conversation on its way to the screen.
|
||||
|
||||
**A screenshot the user never sent is collected too.** Snips are written to the support directory before the chat view opens, so a capture you abandon belongs to no conversation and no amount of session deletion would ever reach it. The orphan pass reads the snip directory directly, compares it against the paths recorded on the conversation index, and deletes anything unreferenced that is more than a day old. That wait is the safety story: a snip is on disk seconds after it is taken and stays unreferenced until you send it, so without a grace period the sweep would delete the file out from under a question you were still typing. Files belonging to a conversation that has just been deleted go immediately with it instead of waiting, because the conversation they belonged to is already gone.
|
||||
|
||||
The index entry for a conversation carries its image paths (`SessionSummary.attachmentPaths`), so deciding what is still referenced does not mean reading every transcript, which the sweep would otherwise do after every single answer. An index written before that field existed is repaired from its transcripts before anything is deleted, because a missing list is not the same as an empty one: reading it as empty would mark every screenshot in your history as an orphan.
|
||||
|
||||
**The whole design leans towards deleting nothing.** Retention off means nothing is read and nothing is written. An unrecognised value for the preference resolves to off. The orphan pass is skipped entirely when the referenced set cannot be established, because an incomplete set of references looks exactly like a pile of orphans and guessing wrong destroys real images. A path is checked to be inside the snip directory before anything is removed, so a hand-edited entry in storage cannot reach anything else on the disk. And the sweep never throws, so a failed tidy-up cannot break the command you opened.
|
||||
|
||||
### Doing it by hand
|
||||
|
||||
**Setup & Status** has a Maintenance section with the same two jobs as explicit actions:
|
||||
|
||||
| Action | What it deletes |
|
||||
| --- | --- |
|
||||
| **Clear Out All Chats** | Every conversation, and every screenshot they captured. |
|
||||
| **Clear Out Unattached Photos** | Captures that no conversation refers to any more. |
|
||||
|
||||
Both ask for confirmation first, and both say what they did even when the answer is nothing, because a button that appears to do nothing reads as a bug. That is deliberately the opposite of the background sweep, which stays silent unless it deleted something.
|
||||
|
||||
The photo clean-up applies exactly the same rule as the sweep, including the one-day wait, and it works whether or not **Delete Old Chats After** is switched on. So it covers the case that setting exists for: you would rather decide when your history is cleared than have Lam decide it for you.
|
||||
|
||||
When a conversation cannot be read there is no way to know which captures are still in use, so that action says it could not check and deletes nothing, rather than risking a screenshot a surviving chat still shows.
|
||||
|
||||
## Rendering
|
||||
|
||||
The answer is model output, and Vicinae draws a fixed set of things. Everything between the model and the pane happens in one pass, in `render.ts`, and every step of it is there because the renderer cannot do that part alone.
|
||||
|
||||
### LaTeX
|
||||
|
||||
Vicinae has no maths renderer. `MarkdownModel` maps cmark nodes to a fixed set of block components (`MdHeading`, `MdCodeBlock`, `MdTable`, `MdImage` and friends) and there is no maths block among them, so `$x^2$` used to reach the pane as literal TeX. `latex.ts` converts it instead.
|
||||
|
||||
Two facts about the renderer decided the design. A paragraph is a `TextEdit` with `textFormat: RichText` (`MdParagraph.qml`), and `markdown-model.cpp` passes `CMARK_NODE_HTML_INLINE` through untouched, so `sup`, `sub`, `b` and `i` are available. And the pane is a launcher-width column, so a formula has to read linearly: a stacked fraction could not survive a narrow column anyway.
|
||||
|
||||
So this is a linearisation rather than a typesetter. `\frac{a}{b}` becomes `a/b`, `\sqrt{x+1}` becomes `√(x+1)`, `\sum_{i=1}^{n}` becomes `∑ᵢ₌₁ⁿ`, and only what Unicode has no form for falls back to a tag, so `x^{a/b}` becomes `x<sup>a/b</sup>`. `$...$`, `\(...\)`, `$$...$$` or `\[...\]` on a line of its own, and a fence labelled `math`, are all read.
|
||||
|
||||
Brackets appear only where the slash would otherwise change what the formula means. `\frac{n(n+1)}{2}` is `n(n+1)/2` and `\frac{\sqrt{\pi}}{2}` is `√π/2`, because one product either side of a slash needs nothing, while `\frac{1}{2a}` is `1 / (2a)` because `1/2a` is read two ways, and a sum is always bracketed so the slash cannot divide only its last term: `(−b ± √(b² − 4ac)) / (2a)`. The space around the slash shows up only when something beside it is bracketed, which is where the brackets would otherwise crowd it.
|
||||
|
||||
The one guard is worth knowing about. A lone `$` pair counts as maths only when the inside looks like maths, because `x=$a$b` and `cd $A; echo $B` are not formulas, and turning them into one is the single way this feature can make an answer worse than the dollar signs it replaced. `$5 and $10` is left exactly as written.
|
||||
|
||||
### Custom HTML
|
||||
|
||||
Raw HTML is not merely tolerated by the renderer, it is the only route to anything past what GFM offers: a `<span>` with a colour or a size draws, and so does a table. That is the extension point for a model that would rather show a status chip than describe one, and the default prompt says the capability exists.
|
||||
|
||||
**A block of HTML has to be moved before it can draw anything.** `markdown-model.cpp` has no HTML renderer. An HTML *block* goes through `processHtmlNodes`, which pulls out every `<img>` as a block image and keeps the trimmed text of everything else with the markup thrown away, so `<table><tr><td>a</td><td>b</td></tr></table>` arrives as the word `ab`. A *paragraph*, by contrast, keeps its raw HTML, because `CMARK_NODE_HTML_INLINE` is passed straight through to the `TextEdit`. So a well-formed block is joined onto one line and wrapped in a `<span>`, which is not on CommonMark's list of block tags, and it arrives as the paragraph the rich text engine can read. Two rules keep that rewrite from doing damage: markup that does not balance is left to the block path, which at least keeps the words, and a block holding an `<img>` is left alone entirely, because the extractor is the one place an image written as HTML is loaded for real.
|
||||
|
||||
It is also a hole, because the same route carries whatever else the model writes, so `html.ts` filters it. The tags and style declarations the renderer acts on are kept, `script`, `style` and the other element types whose body is not content are removed with it, and an event handler, a `url()` in a style value and a `javascript:` destination are dropped. An `<a href>` is rewritten as a markdown link, because that is the only form Vicinae makes clickable. An `<img>` keeps its tag and has its source judged instead, since the block extractor reads the attribute rather than any markdown this pass could write, and `data:` counts only when it carries an image.
|
||||
|
||||
What the renderer cannot draw is kept as text rather than dropped, which is the deliberate choice in there. A tag that is not on the list, `<foo>` say, is shown literally: ugly, but honest, and better than a mystery about where the markup went.
|
||||
|
||||
### Everything else
|
||||
|
||||
The rest of the pass is defensive, and stays away from guessing what the model meant. A diagram drawn with box characters is fenced so it keeps its alignment, because `MdParagraph.qml` wraps and a directory tree reflowed is a directory tree scrambled. A fence label KSyntaxHighlighting would print but never resolve is renamed (`console` and `terminal` become `bash`) and one that carries no information at all (`text`, `plaintext`) is dropped, which is what stops a header naming a language nobody highlighted. A fence whose body is all URLs still becomes links, a real GFM table is left to the table renderer, and markup inside a code span is shown rather than applied.
|
||||
|
||||
The whole pass is plain TypeScript with no Vicinae imports, so it unit tests without a runtime, and copying a conversation to the clipboard uses the source text rather than the rendering.
|
||||
|
||||
### Sources
|
||||
|
||||
Footnotes deserve a note of their own, because the window cannot draw them. Vicinae parses markdown with `CMARK_OPT_DEFAULT` and attaches only the table and strikethrough extensions, so a `[^1]` arrives as literal text. The citation pass therefore does the whole job: it links the marker, drops a footnote definition that would only repeat an entry the tools already registered, and builds the list from those entries, which is also what stops the list from being something a model can quietly invent.
|
||||
|
||||
In a sentence the marker is raised, so a claim resting on a search result reads as `rain is likely¹` rather than with the brackets at full size. It is a Unicode superscript digit rather than a `<sup>` tag, because a link label is plain text that nothing downstream can break, and a digit is raised by design in every font. The entry under the answer keeps the plain number, `[1] Rain in Cebu`, where being able to scan a list matters more than the convention.
|
||||
|
||||
Markers the model wrote touching are separated as they are drawn, because `¹²` reads as twelve, or as twenty one, and either way as one number. The prompt asks for exactly that touching shape, so `[1][2]` and the same numbers written apart both arrive as two raised digits with a space between them. A space rather than a comma: a comma sits at the baseline, so between raised digits it drops away from them and reads as a stray mark.
|
||||
|
||||
The pair is ordered ascending and each page is named once, because a model lists sources by rank rather than by number. `[2][1]` is what a real answer sent for a sentence resting on both, and drawn as `¹ ²` it reads as the answer's first two sources rather than as "two, one". The numbers themselves are never renumbered: `[3]` is still the third source the turn found.
|
||||
|
||||
Numbers are handed out where the tools run, once per turn, and printed in the tool result the model reads. So `[3]` means one page for the model and for the reader, three searches in one turn do not each start again at `1`, and a page found by a search and read again later keeps the number it already had. A marker stays as written unless every number in it is a source with a destination this window should open: half a link reads as a citation that works, and a `javascript:` address out of a search result is a real vulnerability rather than a rendering quirk.
|
||||
|
||||
The list shows the sources the answer cited, falling back to everything the turn consulted when it cited nothing, since that is the one case where the list is the only evidence of what was read. It waits for a citation before appearing at all, so it never arrives in full and then shrinks.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -150,7 +352,8 @@ Web search results and fetched pages can be large. Lam truncates them before the
|
||||
src/
|
||||
chat.tsx # "chat" command entrypoint, thin wrapper
|
||||
snip.tsx # "snip" command entrypoint, no-view: captures then hands over
|
||||
sessions.tsx # browse, reopen, delete conversations
|
||||
manage-models.tsx # "manage-models" command: browse, pick, pull, delete
|
||||
chat-history.tsx # "chat-history" command: browse, reopen, delete conversations
|
||||
setup.tsx # connection check + live model list
|
||||
lib/
|
||||
ollama.ts # the only file that talks HTTP
|
||||
@@ -160,13 +363,21 @@ src/
|
||||
attachments.ts # image files -> base64 payloads
|
||||
vision.ts # cached "can this model see?" answer
|
||||
sessions.ts # LocalStorage persistence
|
||||
retention.ts # what gets deleted, and when
|
||||
cleanup.ts # the sweep + its notification
|
||||
history.ts # turns -> API messages
|
||||
files.ts # the path policy + every file operation
|
||||
approval.ts # the approval dialog + its per-answer ledger
|
||||
location.ts # time/place, derived locally
|
||||
thread.ts # turns -> flat message list
|
||||
markdown.ts # messages -> detail-panel markdown
|
||||
code.ts # code fences and links, repaired
|
||||
render.ts # the rendering pass: fences, maths, markup
|
||||
latex.ts # TeX -> readable inline markup
|
||||
html.ts # raw HTML -> the subset the renderer draws
|
||||
code.ts # code fences, diagrams and links, repaired
|
||||
preferences.ts # preferences, normalised
|
||||
hooks/useChat.ts # the chat state machine
|
||||
hooks/usePreferences.ts # preferences + the model pick, loaded once
|
||||
hooks/useVision.ts # capability check, for gating the snip action
|
||||
snip.ts # the snip -> chat handoff contract
|
||||
components/ChatView.tsx # the chat surface
|
||||
@@ -188,7 +399,9 @@ A few decisions worth knowing about before you change things:
|
||||
|
||||
**The transcript reads newest message first.** The search bar sits at the top of the window, so the most recent message, an answer once one has been written, is the first line under it rather than below the fold. Within a single exchange that means the answer appears above the question it responds to. `buildThread` produces that order, and `buildThreadMarkdown` regroups messages back into exchanges so the horizontal rule separates one exchange from the next instead of dividing every message.
|
||||
|
||||
**The transcript is text plus the attached images, and nothing in it is bold except an error.** The answer is what gets read, so it is plain body text carrying no label; the question is a blockquote, which the renderer draws dimmed, italic, and with a left rule. A `**You**` / `**Lam**` header on each exchange gave both speakers identical weight and made the answer sit behind chrome. Tool calls collapse to one italic line (_Tools: `web_search`_) rather than a bold header plus a list, so they stay below the answer too. Raycast solves the same problem with alignment, but markdown offers no alignment control.
|
||||
**The transcript is text plus the attached images, and nothing in it is bold except an error.** The answer is what gets read, so it is plain body text carrying no label; the question is a blockquote, which the renderer draws dimmed, italic, and with a left rule. A `**You**` / `**Lam**` header on each exchange gave both speakers identical weight and made the answer sit behind chrome. Tool calls collapse to one italic line naming each tool once, with what went wrong counted rather than repeated, so they stay below the answer too. Raycast solves the same problem with alignment, but markdown offers no alignment control.
|
||||
|
||||
**The reasoning is live while a turn is in flight, and a preference only governs where it settles.** A model that is thinking rather than writing has nothing else on screen, so the trace streams into the transcript whatever `Show Reasoning` says; hiding it would leave a spinner and no way to tell thinking from hanging. When the turn ends the trace either stays as an appendix under the answer (on) or disappears (off), which is what the preference actually controls. `useChat` keeps two views of the stream for the same reason: the full trace is what gets stored on the turn, and only the segment since the last tool call is shown live, cleared each time a tool starts. Otherwise a research turn that thinks before each of a dozen calls stacks every round's reasoning on screen at once, and the tool it just asked for runs underneath a wall of text that is no longer about anything.
|
||||
|
||||
**An attached screenshot is rendered, not described.** This is a correction: the transcript used to say `_Screenshot attached_` and leave it at that, on the belief that Vicinae could not resolve a local image. It can. `imageProviderUrl` in `markdown-model.cpp` routes `file://` (and a scheme-less string) through `ImageURL::local`, and `MdImage.qml` draws it block-level and centred with the alt text as an italic caption underneath.
|
||||
|
||||
@@ -196,7 +409,7 @@ A few decisions worth knowing about before you change things:
|
||||
|
||||
The rendered height stays capped at 200px whatever is asked for (`Math.min(_rawH, 200, maxImageHeight)`), so this is a thumbnail rather than something readable, which is why the action panel still offers Open Screenshot for the full image. The alt text is short for the same reason it is not empty: whatever is there is drawn centred under the image, so a sentence reads as a stray line of prose, and it is the only thing left if the capture file has since been deleted. A preview of an unsent attachment is shown the same way, so a snip is visible before the question about it exists.
|
||||
|
||||
**Code fences are labelled so Vicinae can colour them.** `MdCodeBlock.qml` already provides the dark panel, a header naming the language, and a Copy button, so none of that is built here. The language string is what turns on highlighting: `syntax::highlight` resolves it with `definitionForName(language)` and then `definitionForFileName("file." + language)`. So `bash` and `ts` colour correctly, while an unrecognised string such as `terminal` still renders a label but produces no highlighting at all, so a wrong label is worse than none. `code.ts` therefore labels bare fences conservatively, only when a signal is clear, and the default prompt asks for a language on every fence.
|
||||
**Fences are labelled so Vicinae can colour them, and a diagram is fenced so it stays readable.** `MdCodeBlock.qml` already provides the dark panel, a header naming the language, and a Copy button, so none of that is built here. The language string is what turns on highlighting: `syntax::highlight` resolves it with `definitionForName(language)` and then `definitionForFileName("file." + language)`. So `bash` and `ts` colour correctly, while an unrecognised string such as `terminal` still renders a label but produces no highlighting at all, so a wrong label is worse than none. `code.ts` therefore labels bare fences conservatively, only when a signal is clear, the default prompt asks for a language on every fence, and an alias that resolves to nothing (`console`, `terminal`) is renamed to one that does. The same module fences a paragraph drawn with box characters, because a paragraph wraps and a boxed diagram that reflows is a boxed diagram that no longer says anything.
|
||||
|
||||
**A URL is only clickable as a markdown link, so `code.ts` takes them back out of code.** Vicinae's parser attaches the `table` and `strikethrough` GFM extensions and nothing else, so the `autolink` extension is off: a bare URL is plain dead text. Clickability comes from `MarkdownModel` turning `CMARK_NODE_LINK` into an `<a href>`, which the selection controller opens on release. A URL the model fenced is therefore the worst possible presentation, a dark panel with a Copy button and no way to open the address. `restoreUrlLinks` recovers three narrow shapes (a fence whose entire body is URLs, a lone `` `https://...` `` span, and a line that is only a URL) into `[url](url)` links, and the default prompt now says never to fence a URL in the first place. Anything that is not one of those three shapes is copied through, so a `curl` invocation containing an address is never rewritten.
|
||||
|
||||
@@ -206,8 +419,12 @@ The rendered height stays capped at 200px whatever is asked for (`Math.min(_rawH
|
||||
|
||||
**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.
|
||||
|
||||
**Stopping a turn is an action, not a keybinding.** The stop action is labelled Force Stop and stays the primary action for the whole turn, because the failure it exists for is a model spending a long time on a question you have given up on. It aborts the request, which propagates through the tool loop and into the tool that is running, so it works mid-search rather than only between rounds. There is no separate stop command: an extension cannot reach into a view running in another command, so a stop command would have to poll a flag in `LocalStorage` and could only stop at round boundaries, which is both slower and less reliable than the thing it would replace.
|
||||
|
||||
**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.
|
||||
|
||||
**Preferences have exactly one reader.** `loadPreferences` applies the model chosen in **Manage Models** on top of what Vicinae holds, and it is asynchronous because that pick lives in LocalStorage. `getLamPreferences` is deliberately not exported, because a second, synchronous reader would look correct and silently ignore the pick. Views take preferences through `hooks/usePreferences`, and the chat view is split in two so that `useChat` is only ever created once the model is known, rather than being pinned to whatever the preference said a moment earlier.
|
||||
|
||||
**Sessions are capped twice over**, once per turn and once per session, because `LocalStorage` has no documented ceiling.
|
||||
|
||||
**Screen capture is owned, not borrowed.** There is no screenshot call anywhere in `@vicinae/api`, and `Clipboard` moves text, html, urls and a file path and nothing binary, so a bitmap can never be read back out of the clipboard either. The only way to get a region of the screen is to shell out, which is fair game because extensions run under Node. The useful consequence is that the extension picks the output path and reads the file it wrote, rather than guessing what some other application left on the clipboard.
|
||||
@@ -224,6 +441,8 @@ The hand-off goes through `snip` specifically rather than straight to `chat` on
|
||||
|
||||
**An attached image is sent once and never replayed.** It is the largest thing a request can carry, so `buildMessages` attaches it to the question it belongs to and drops it afterwards, exactly as it already drops tool output. The cost is that a follow-up about a screenshot needs the image attached again; the alternative is re-sending every screenshot in the conversation on every turn. Only the path and mime type go into storage: `MAX_STORED_CHARS_PER_TURN` exists so one turn cannot dominate `LocalStorage`, and base64 would blow past it on a single 1080p capture.
|
||||
|
||||
**Whether the model can see is a tri-state, and the prompt gets the whole answer.** `canSeeImages` stays fail-closed, because offering the snip action to a model that cannot read would waste the selection gesture. But the system prompt needs `"unknown"` kept apart from `"no"`: telling a model it cannot see when the check merely failed is a false claim about the user's setup, and telling it nothing is what made it reason about its own capability instead of describing the image it had been sent. A model name that does not exist on the server is the common way to end up at `"unknown"`, which is one more reason the Manage Models command picks names from the list rather than taking them typed.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
@@ -237,7 +456,7 @@ npm test
|
||||
|
||||
## 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.
|
||||
- **Actions.** Opening links, clipboard access, launching apps. This release ships the approval step they were waiting for, with the file tools, so the remaining work is the actions themselves rather than the permission model under them.
|
||||
- **`@` mentions** for files and clipboard content.
|
||||
- **Per-conversation model** overrides.
|
||||
- **Annotations on a snip.** The capture backends are there, but drawing on the result before sending it needs a second tool and a wait for the editor to save.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,406 @@
|
||||
# File system access for Lam
|
||||
|
||||
Status: draft for review
|
||||
|
||||
Date: 2026-10-07
|
||||
|
||||
## Problem
|
||||
|
||||
Lam is a chat window that cannot look at anything on the machine unless the user
|
||||
attaches a screenshot to a question. Every question about a file is therefore
|
||||
answered from memory of a description the user typed, and the README says so in
|
||||
as many words: "Chat only. Lam answers questions. It does not touch your files or
|
||||
run commands."
|
||||
|
||||
The capability is not the problem. `src/lib/attachments.ts` already imports
|
||||
`readFile` from `node:fs/promises` to turn an attached screenshot into base64, and
|
||||
`src/lib/capture.ts` writes capture files. The problem is that nothing here has a
|
||||
permission model, and an assistant that can read and write without one is a
|
||||
liability rather than a feature.
|
||||
|
||||
### What the platform gives us, and what it does not
|
||||
|
||||
Established by reading the Vicinae source in `/tmp/vicinae-src`
|
||||
(`vicinaehq/vicinae`, `7733e95`):
|
||||
|
||||
- **Extensions are unsandboxed Node.** Each command runs in a worker thread of the
|
||||
extension manager (`src/typescript/extension-manager/src/index.ts`), and the only
|
||||
`require` patch is a lookup table for React and `@vicinae/api`
|
||||
(`src/typescript/extension-manager/src/patch-require.ts`). Everything else falls
|
||||
through to the real loader, so `node:fs` and `node:path` work. Vicinae's own
|
||||
boilerplate extension uses them (`extra/extension-boilerplate/src/wallpaper.tsx`
|
||||
imports `readdir` from `node:fs/promises`).
|
||||
- **There is no OS-level permission to lean on**, no manifest permission block
|
||||
(`schemas/manifest.ts` has none), and `environment.canAccess` only covers
|
||||
`FileSearch`, `BrowserExtension`, `Wallpaper`, `AI` and `WindowManagement`. If
|
||||
Lam is to be bounded at all, this repository has to do the bounding.
|
||||
- **`confirmAlert` works.** `src/server/src/extension/api/ui-service.hpp` implements
|
||||
it with a `CallbackAlertWidget`, so an approval step can be shown from a running
|
||||
command.
|
||||
- **`rememberUserChoice` does not.** `Alert.Options` declares it, and no C++ file
|
||||
mentions it, so "do not ask again" would have to be our own bookkeeping and must
|
||||
not claim to be the platform's.
|
||||
- **`environment.supportPath`** is documented as the directory an extension may read
|
||||
and write, and is a sensible default write root.
|
||||
- **`FileSearch`** exposes Vicinae's file index read only. It returns indexed
|
||||
filename matches, needs the index to be built, and yields no file contents, so it
|
||||
is not the shape the model needs. Not used.
|
||||
|
||||
### The risk this feature creates
|
||||
|
||||
Everything Lam reads is sent to the model, which is Ollama Cloud unless the user
|
||||
self hosts. So reading a file is a disclosure even though it changes nothing on
|
||||
disk. That is why the read boundary in this spec is as considered as the write
|
||||
boundary, and why the README has to say it out loud.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Lam can list a directory, find a file by name or content, and read a file.
|
||||
2. Lam can create, overwrite and edit files, and can delete and move them.
|
||||
3. Nothing outside the configured paths is readable or writable, and a symlink or a
|
||||
`..` segment cannot change that.
|
||||
4. Every operation that changes the disk is approved by the user, with the exact
|
||||
affected path on screen, and a refusal is a normal outcome rather than a failure.
|
||||
5. Credential stores stay unreadable by default, because their contents would end up
|
||||
in a model request.
|
||||
6. A refusal or a policy failure tells the model what happened and what it may do
|
||||
instead, so the turn continues and produces an answer.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Running commands. There is no shell tool here, and adding one would make every
|
||||
other boundary in this spec decorative.
|
||||
- Watching files, tailing logs, or any long lived handle.
|
||||
- Recursive delete. See A4.
|
||||
- Editing in place with a patch format. Replacement is exact string substitution.
|
||||
- A directory creation tool. The only way a directory comes into existence here is
|
||||
the parent chain `write_file` creates for the file it was asked to write, which is
|
||||
disclosed in the alert.
|
||||
- A file picker or a browse view. The tools take paths the model writes.
|
||||
- Reading or writing outside `$HOME`, except through a preference the user sets
|
||||
themselves.
|
||||
- Copying. `move_path` covers the useful case and copying doubles the files a
|
||||
mistake can create.
|
||||
|
||||
## Assumptions
|
||||
|
||||
The four decisions this cycle rested on were put to the user directly; the smaller
|
||||
ones that follow from them are recorded here.
|
||||
|
||||
- **A1. An approval covers a path for one answer.** One `runAgent` call is one
|
||||
answer, so the ledger lives for exactly that turn. The first write to a resolved
|
||||
path raises one alert; further writes to that same path in that answer go through
|
||||
without one; the next question starts empty. Chosen over "ask every time" because
|
||||
a model that needs three passes at one file would otherwise interrupt three times,
|
||||
and over "the roots are the permission" because a path mistake inside a write root
|
||||
would then destroy a file silently.
|
||||
- **A2. Delete and move always ask, even for a path already approved for writing.**
|
||||
The one deliberate exception to A1, and the deviation from the user's answer that
|
||||
was put back to them and confirmed. Every other operation leaves something
|
||||
recoverable behind: an overwrite is a change the user watched, an edit is a
|
||||
replacement, but a delete is the end of the file. An approval granted for an edit
|
||||
ten seconds earlier is not consent to remove the thing that was just edited.
|
||||
`delete_path` and `move_path` therefore skip the ledger entirely.
|
||||
- **A3. Reads are bounded by `$HOME` minus a credential denylist.** Anything under
|
||||
the home directory is readable except the locations in the denylist below. Chosen
|
||||
over an unrestricted filesystem, which would put `/etc` and mounted volumes in a
|
||||
model request, and over an allowlist that starts empty, which would make the
|
||||
feature useless until it was configured.
|
||||
- **A4. `delete_path` refuses a non-empty directory and never recurses.** A recursive
|
||||
delete is the one call where a misread path destroys an unbounded amount of work,
|
||||
and no confirmation dialog makes it recoverable. A single file, or an empty
|
||||
directory, is the whole scope. The refusal names what is in the directory so the
|
||||
model can ask for the contents one at a time.
|
||||
- **A5. `move_path` refuses to overwrite an existing destination.** The alternative is
|
||||
a move that silently destroys the file it lands on. The model can delete the
|
||||
destination first, which asks again under A2.
|
||||
- **A6. `write_file` overwrites without a separate flag, and discloses which it is.**
|
||||
The approval alert says whether the file exists, how big it is and when it last
|
||||
changed, so an overwrite is never a surprise. The old line count is deliberately not
|
||||
among them: counting it would mean reading a file Lam is about to replace, and stat
|
||||
gives the size and the age for free. A `mustNotExist` flag would add a
|
||||
round trip and a second approval to the common case to guard against something the
|
||||
alert already shows.
|
||||
- **A7. Relative paths resolve against `$HOME`, not the working directory.** A launcher
|
||||
command has no meaningful working directory, and `~` is expanded, because both are
|
||||
what the model will write by habit and a rejection would only cost a round trip.
|
||||
- **A8. `search_files` searches names and optionally content.** Content search is
|
||||
"which file mentions this string", which is the question that actually motivates
|
||||
searching at all. It reads files, so it discloses what it reads exactly as
|
||||
`read_file` does, and it is capped in depth and results.
|
||||
- **A9. If the approval dialog cannot be shown, the call is refused.** Fail closed.
|
||||
An unverified path in this repository is the confirmation rendering while a turn
|
||||
is streaming, which cannot be tested locally; refusing is the only safe default.
|
||||
- **A10. The ledger is keyed by resolved real path, not by the string the model
|
||||
wrote.** `~/Downloads/x.txt`, `/Users/me/Downloads/x.txt` and a symlink to it are
|
||||
one approval, because they are one file. Keying on the written form would let a
|
||||
second spelling of the same path slip past an approval the user already gave for
|
||||
it, and would show the same file's change twice as two permissions.
|
||||
- **A11. File access is off until it is switched on, and when it is off the tools do
|
||||
not exist for the model.** This is a third preference beyond the two the user asked
|
||||
for, added because the read side is the silent one: a write always raises a dialog,
|
||||
but a read goes straight into a model request, and the model may choose to read
|
||||
without the user having asked about any file. `ipGeolocation` in this repository is
|
||||
already off by default for the weaker version of the same reason (it discloses one
|
||||
thing about the user to a third party), so the defaults here follow that precedent
|
||||
rather than the `webSearchEnabled` one. Off means absent from `toolsFor`, not merely
|
||||
refusing: the model never learns the tools exist, and the prompt paragraph is not
|
||||
added. One checkbox turns it on.
|
||||
|
||||
## Design
|
||||
|
||||
### Module layout
|
||||
|
||||
| file | job |
|
||||
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/lib/files.ts` (new) | Path policy and every file operation. No Vicinae import, so the whole boundary unit tests headlessly. |
|
||||
| `src/lib/approval.ts` (new) | The only Vicinae facing part: the `confirmAlert` wording, and the per-answer ledger. |
|
||||
| `src/lib/tools.ts` (edit) | Seven definitions and thin handlers, plus a `FILE_TOOL_NAMES` set so `toolsFor` can leave them out entirely when the feature is off (A11). Approval arrives as an injected callback, the way `ipPlace` already does. |
|
||||
| `src/lib/preferences.ts` (edit) | `readRoots` and `writeRoots`, parsed by a pure `splitRoots()`. |
|
||||
| `src/lib/constants.ts` (edit) | The caps, and the prompt paragraph. |
|
||||
| `src/lib/hooks/useChat.ts` (edit) | Creates the ledger per turn and injects the confirm callback. |
|
||||
| `package.json` (edit) | The two new preferences in the manifest. |
|
||||
| `README.md` (edit) | The promise that Lam does not touch files, replaced by what it now does. |
|
||||
|
||||
`files.ts` owns no UI and `approval.ts` owns no path logic, so the policy can be
|
||||
tested without a dialog and the dialog without a disk.
|
||||
|
||||
### Path model
|
||||
|
||||
```
|
||||
read roots: $HOME minus the denylist
|
||||
write roots: ~/Downloads
|
||||
~/Documents/Lam Agent
|
||||
environment.supportPath
|
||||
+ whatever readRoots / writeRoots preferences add
|
||||
```
|
||||
|
||||
Resolution, in order, for every operation that takes a path:
|
||||
|
||||
1. Reject a non-string, an empty string, or a path containing a NUL byte.
|
||||
2. Expand a leading `~` or `~/`; resolve a relative path against `$HOME` (A7).
|
||||
3. `path.resolve` to a normal form, so `..` segments are gone.
|
||||
4. `realpath` the path. If it does not exist (the create case), `realpath` the
|
||||
nearest existing ancestor and re-append the remainder.
|
||||
5. Check containment against the roots **after** step 4, so a symlink pointing
|
||||
outside a root is judged by where it lands rather than by where it is written.
|
||||
6. For a read, check the denylist against the resolved path.
|
||||
|
||||
Containment is a component-wise comparison, not a string prefix:
|
||||
`/Users/me/Downloads-evil` must not match the root `/Users/me/Downloads`.
|
||||
|
||||
The denylist, matched on the resolved path, either exactly or as a directory
|
||||
prefix:
|
||||
|
||||
| rule | why |
|
||||
| ----------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.config/gh`, `~/.docker/config.json`, `~/.netrc` | Credential stores and key material |
|
||||
| basename `.env` or starting `.env.` | Application secrets, the common shape |
|
||||
| basename `id_rsa`, `id_dsa`, `id_ecdsa`, `id_ed25519`, `credentials.json` | Private keys and cloud credential files |
|
||||
| extension `.pem`, `.key`, `.p12` | Key material under a name no other rule catches |
|
||||
|
||||
The denylist is a guard, not a proof: a credential file with a name nobody thought
|
||||
of is still readable. That is stated in the risks rather than papered over.
|
||||
|
||||
### Operations
|
||||
|
||||
Every function returns a result object rather than throwing, so a policy refusal
|
||||
and an I/O failure reach the model in the same shape.
|
||||
|
||||
```ts
|
||||
type FileResult =
|
||||
| { ok: true; content: string; path: string; bytes?: number }
|
||||
| { ok: false; content: string; refused: "policy" | "approval" | "io" };
|
||||
```
|
||||
|
||||
The distinction matters in one place only: a policy refusal means "there is
|
||||
nothing here you are allowed to touch", while an approval refusal means "the user
|
||||
said no to this one", and the model is told to stop trying in the first case and to
|
||||
ask rather than retry in the second.
|
||||
|
||||
| tool | parameters | notes |
|
||||
| ---------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `list_directory` | `path`, `hidden?` | Directories first, then files, each sorted. Capped at `MAX_LIST_ENTRIES` (200), with a count of what was left out. |
|
||||
| `search_files` | `path`, `pattern`, `contains?` | `pattern` is a glob (`*`, `?`, `**`) matched against the basename; `contains` is a case-insensitive substring test on the file's first `MAX_SEARCH_BYTES` (64 KB). Capped at `MAX_SEARCH_RESULTS` (50) and `MAX_SEARCH_DEPTH` (8). A path that fails the read check is skipped, contents and all, so the denylist holds for search results as well as for direct reads. |
|
||||
| `read_file` | `path`, `offset?`, `limit?` | Numbered lines, stopping at whichever comes first of the default 200 lines and `MAX_READ_CHARS` (8000) characters, with the notice naming that cap so it is clear which one stopped it. Binary content (a NUL byte in the first 8 KB) is refused with its size and detected type rather than returned as mojibake. |
|
||||
| `write_file` | `path`, `content` | Creates the parent directory chain when it is missing, disclosed in the alert, since `~/Downloads/reports/may.md` is a normal thing to ask for and there is no separate mkdir tool. Reports `Created` or `Overwrote (...)` with the size and line count of what it wrote. |
|
||||
| `edit_file` | `path`, `old_string`, `new_string`, `all?` | Exact substitution. Refuses when `old_string` occurs zero times or more than once without `all`, naming the count so the model can add context instead of guessing. |
|
||||
| `delete_path` | `path` | One file, or an empty directory. A non-empty directory is refused with its entry count (A4). |
|
||||
| `move_path` | `from`, `to` | Both the source and the destination are policy checked and both are approved. Refuses an existing destination (A5). |
|
||||
|
||||
`toolLabel` gains "Reading a file", "Writing a file", "Editing a file", "Deleting a
|
||||
file", "Moving a file", "Listing a folder", "Searching your files" for the activity
|
||||
line.
|
||||
|
||||
### Approval
|
||||
|
||||
`approval.ts` exports `createApprovalLedger(confirm)` and the wording.
|
||||
|
||||
```ts
|
||||
type FileApproval = {
|
||||
/** What is about to happen, in the user's terms. */
|
||||
title: string;
|
||||
/** The concrete path, tilde shortened, plus the size and shape of what changes. */
|
||||
body: string;
|
||||
/** Whether this one is irreversible, which decides the button style. */
|
||||
destructive: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
`confirm` is `(approval: FileApproval) => Promise<boolean>`, implemented in
|
||||
`useChat.ts` with `confirmAlert`. The ledger remembers resolved paths approved
|
||||
during the turn (A1, A10) and is consulted only by the five mutating operations
|
||||
other than delete and move, which always ask (A2).
|
||||
|
||||
The alert content is the point of the whole design, so it is specified rather than
|
||||
left to the implementation:
|
||||
|
||||
```
|
||||
Title: Overwrite report.md?
|
||||
Body: /Users/me/Downloads/report.md
|
||||
Replaces 1,240 bytes, last changed 3 days ago.
|
||||
New content is 2,180 bytes, 61 lines.
|
||||
[Allow] [Decline]
|
||||
```
|
||||
|
||||
When the parent directory does not exist, the body gains a line before the rest:
|
||||
`Creates the folder ~/Downloads/reports as well.` Nothing else appears in the
|
||||
body that the user cannot act on, and the exact path is always there, because the
|
||||
path is the thing being approved.
|
||||
|
||||
- `edit_file` shows the substitution itself, both sides truncated to 200
|
||||
characters: `old_string → new_string`, which is the one alert where showing the
|
||||
change is better than showing the size of the file.
|
||||
- `delete_path` uses `Alert.ActionStyle.Destructive` on the primary action and adds
|
||||
"This cannot be undone."
|
||||
- `move_path` shows both paths and says whether the destination exists.
|
||||
- A policy refusal never reaches the user as a dialog at all, because there is
|
||||
nothing to approve.
|
||||
- The tool record keeps the refusal text, so a transcript with Show Tool Calls on
|
||||
shows what was declined next to what was allowed. No new field is needed for that,
|
||||
which is why `ToolCallRecord` does not change in this spec.
|
||||
|
||||
### Refusals the model reads
|
||||
|
||||
Short, factual, and actionable, in the same voice as the rest of the tool output:
|
||||
|
||||
```
|
||||
Refused: /etc/passwd is outside the paths Lam may read. Readable: your home
|
||||
directory, except credential stores. Nothing was read.
|
||||
|
||||
Refused: ~/.ssh/id_ed25519 is a credential store, so Lam does not read it.
|
||||
|
||||
Refused: the user declined to write /Users/me/Downloads/draft.md. Nothing was
|
||||
written. Ask before trying again.
|
||||
|
||||
Refused: ~/Downloads/archive is a directory with 42 entries, and Lam does not
|
||||
delete directories recursively. List it and delete what you actually need.
|
||||
```
|
||||
|
||||
### Prompt
|
||||
|
||||
The system prompt gains a paragraph naming the write roots, saying that every
|
||||
change is approved by the user and that a refusal is final, that paths outside the
|
||||
roots are refused outright, and that a large file should be read in parts. It also
|
||||
gains one line of disclosure: file contents go to the model provider, so Lam reads
|
||||
what the question needs and nothing more.
|
||||
|
||||
### Preferences
|
||||
|
||||
Two new text fields in `package.json`, mirrored in `LamPreferences`, plus the switch
|
||||
from A11:
|
||||
|
||||
| preference | shape |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `fileAccess` | Checkbox. Off by default, and off means the seven tools are absent from `toolsFor` and the prompt paragraph is not added. |
|
||||
| `readRoots` | Extra read roots, one absolute path per line |
|
||||
| `writeRoots` | Extra write roots, one absolute path per line |
|
||||
|
||||
`splitRoots(value)` in `files.ts` is pure and tested: it splits on newlines and
|
||||
commas, trims, expands `~`, drops empty entries, keeps a path that does not exist
|
||||
yet (a write root may be created later), and reports entries that are not absolute
|
||||
rather than silently ignoring them. A bad entry does not disable the others, and
|
||||
the defaults above always apply, so an empty preference is the same as no
|
||||
preference.
|
||||
|
||||
## Error handling
|
||||
|
||||
| Situation | Behaviour |
|
||||
| ------------------------------- | ----------------------------------------------------------------------- |
|
||||
| Path outside every root | Refused, naming the roots. The user is not prompted. |
|
||||
| Path inside the denylist | Refused, naming the rule. |
|
||||
| Symlink or `..` escaping a root | Resolved first, then refused, and the refusal names the real location. |
|
||||
| Path does not exist (read) | `ENOENT` reported with the path, not a stack. |
|
||||
| Path exists (create) | Reported as an overwrite with the current size. |
|
||||
| Permission denied by the OS | Refused with the OS message, which is already specific. |
|
||||
| File larger than the cap | The cap is applied and stated; nothing is silently cut. |
|
||||
| Binary file | Refused with size and detected type. |
|
||||
| Non-empty directory delete | Refused with the entry count (A4). |
|
||||
| Move onto an existing path | Refused, naming both paths (A5). |
|
||||
| User declines | Refusal returned as a tool result with `isError: true`, turn continues. |
|
||||
| Approval dialog cannot be shown | Refused (A9). |
|
||||
| Ledger already holds the path | No dialog for a write or an edit; a delete or a move asks anyway (A2). |
|
||||
| Feature off (A11) | The tools are not offered, so the model cannot call them |
|
||||
|
||||
## Testing
|
||||
|
||||
- `files.test.ts`, new, the bulk of the suite: a table of resolution cases
|
||||
(`~`, relative, `..`, symlink out of a root, `/Users/me/Downloads-evil`,
|
||||
denylist hits and near misses, non-existent parent chains), plus operation tests
|
||||
against a real temporary directory built with `mkdtemp`, including the delete and
|
||||
move refusals, the binary guard, line offsets, and the caps.
|
||||
- `approval.test.ts`, new: the ledger approves once per resolved path per turn,
|
||||
treats two spellings of one path as one entry (A10), and never suppresses a
|
||||
delete or a move (A2); the wording for each of the five operations.
|
||||
- `tools.test.ts`: each tool dispatches to the right operation; a declined call
|
||||
returns `isError` with the refusal text; a policy refusal never calls the confirm
|
||||
callback; the tools stay out of the list when the feature is off.
|
||||
- **`preferences.test.ts`**: `splitRoots` for empty, comma-separated, newline-separated,
|
||||
padded, `~`, relative and repeated input.
|
||||
- Mutation checks on the policy table: inverting a containment comparison, checking
|
||||
before `realpath`, and keying the ledger on the written path must each fail a test.
|
||||
|
||||
## Approaches considered
|
||||
|
||||
- **One implementation in `tools.ts`, importing `confirmAlert` directly.** Fewer
|
||||
files, but it makes the tool layer Vicinae dependent, against the deliberate split
|
||||
that lets `tools.ts` and `agent.ts` run in a plain unit test, and it puts the
|
||||
policy somewhere a test cannot reach it without the runtime.
|
||||
- **A separate process holding the file access, with the extension talking to it.**
|
||||
A real boundary, and the only one an attacker could not walk around, but it needs
|
||||
the toolchain to spawn and supervise a process, and it moves the approval dialog
|
||||
somewhere it cannot be shown.
|
||||
- **Vicinae's `FileSearch` for discovery.** Rejected: indexed names only, no
|
||||
contents, and it needs the user's index to be enabled and current.
|
||||
- **An allowlist that starts empty.** Rejected in A3: the feature would do nothing
|
||||
until configured, and the first experience of a new capability should not be a
|
||||
refusal.
|
||||
- **Asking on every mutating call (the "every write asks" option).** Rejected in A1
|
||||
in favour of one approval per path per answer.
|
||||
- **Recursive delete with a stronger confirmation.** Rejected in A4.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Reading is disclosure.** Every byte read leaves the machine in a model request.
|
||||
The denylist and the home boundary limit the blast radius; they cannot make a file
|
||||
safe to read. The README will say this plainly, and the prompt will tell the model
|
||||
to read only what the question needs.
|
||||
- **The denylist is a list of guesses.** A key file with an unusual name under an
|
||||
allowed path is readable. This is a denylist by construction, so it is bounded by
|
||||
what was thought of.
|
||||
- **The confirmation has not been seen rendering.** Vicinae cannot be run in this
|
||||
environment, so `confirmAlert` during a streaming turn is reasoned from the C++
|
||||
implementation. A9 fails closed, and the first real use will tell us.
|
||||
- **`rememberUserChoice` is not implemented**, so nothing here may offer "do not ask
|
||||
again" as though the platform provided it. The ledger is per answer, which is a
|
||||
defensible unit and not a promise about the future.
|
||||
- **A model with write access can still do damage inside a write root.** Once
|
||||
`~/Downloads` is approved, a wrong path inside it is real damage. That is why the
|
||||
alert shows the concrete path, size and line count rather than the tool name.
|
||||
- **A long turn can spend many approvals.** The ledger limits this to one per
|
||||
distinct path, and the tool round cap bounds it further.
|
||||
- **The check and the write are not atomic.** Between resolving a path and acting on
|
||||
it, a symlink could be swapped so that the write lands somewhere the policy did not
|
||||
clear. Defending that needs `O_NOFOLLOW` and an open file handle per operation,
|
||||
which is a different design, and the threat it guards against is another process
|
||||
already running as the user. Out of scope, stated rather than implied.
|
||||
@@ -0,0 +1,266 @@
|
||||
# Source citations in the transcript
|
||||
|
||||
Status: draft for review
|
||||
|
||||
Date: 2026-10-07
|
||||
|
||||
## Problem
|
||||
|
||||
A turn that searches the web leaves no verifiable provenance behind.
|
||||
|
||||
- `web_search` numbers its own results from 1 on every call. Three searches in
|
||||
one turn produce three separate runs of `1.`, `2.`, `3.`, so a number in the
|
||||
answer cannot identify a source across the turn.
|
||||
- `web_fetch` prints no number at all, so a page the model actually read cannot
|
||||
be cited by number.
|
||||
- Nothing tells the model to cite, so it either pastes bare URLs into prose
|
||||
(which the window draws as dead text if they land in a code span) or says
|
||||
"according to the search results" with nothing to click.
|
||||
- Vicinae cannot render markdown footnotes. `markdown-model.cpp` calls
|
||||
`cmark_parser_new(CMARK_OPT_DEFAULT)` and attaches only the `table` and
|
||||
`strikethrough` extensions (`src/server/src/ui/quick/markdown-model.cpp:330-340`),
|
||||
so `[^1]` reaches the pane as literal text. Any footnote behaviour has to be
|
||||
produced on our side of the boundary.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Every source a turn consults gets one number, stable for that turn and the
|
||||
same number for the model and for the reader.
|
||||
2. The model is told to cite with that number, and the number it writes is the
|
||||
number the reader sees.
|
||||
3. The transcript turns a citation marker into a link to that source, and lists
|
||||
the sources underneath the answer it belongs to.
|
||||
4. Nothing is invented. A marker that does not match a known source is left
|
||||
exactly as written.
|
||||
5. A reopened session shows the same sources, because they are stored with the
|
||||
turn rather than derived at render time from a tool result string.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Native markdown footnotes. The renderer has no such mode, and this spec does
|
||||
not wait for one.
|
||||
- Renumbering the model's citations to be contiguous.
|
||||
- Favicons, snippets, timestamps or reading time in the list.
|
||||
- Sources for any tool other than the web tools. Nothing else returns sources.
|
||||
- Detecting and deleting a sources list the model wrote by hand. The prompt
|
||||
forbids it; a heuristic that strips prose is a worse failure than a duplicate.
|
||||
|
||||
## Assumptions
|
||||
|
||||
The clarifying questions were delegated for this cycle, so the decisions that
|
||||
would have been asked are recorded here instead.
|
||||
|
||||
- **A1. The list shows what the answer cites, falling back to everything the
|
||||
turn gathered.** An uncited entry is noise in a narrow pane, and a long
|
||||
research turn can gather twenty. Listing only cited entries also keeps a
|
||||
twenty five result turn from ending in a twenty five line list. When the
|
||||
answer cites nothing while sources exist, the list shows all of them, because
|
||||
then it is the only evidence of what was consulted. Rejected alternatives are
|
||||
in "Approaches considered".
|
||||
- **A2. Numbers are never renumbered, though a run is ordered ascending.** Contiguous
|
||||
numbering from 1 in citation order is the academic convention, but renumbering the
|
||||
whole answer would make the transcript disagree with the model's own reasoning text
|
||||
and with the tool output a curious reader can inspect. Order inside a single run is
|
||||
normalised instead, because a model lists sources by rank rather than by number, so
|
||||
`[2][1]` reads as "two, one" once the digits sit next to each other. `[3]` therefore
|
||||
still means the third source the turn found, whether or not the answer cited the two
|
||||
before it.
|
||||
- **A3. The list is answer content, not a tool log.** It is shown whether or not
|
||||
the Show Tool Calls preference is on. Hiding tool calls is about the activity
|
||||
line; a citation list is part of the answer's credibility.
|
||||
- **A4. The copied conversation includes the list**, in the same form, so
|
||||
provenance survives a paste into a document.
|
||||
- **A5. The marker in a sentence is a superscript, and the list keeps the plain
|
||||
number.** The model still writes `[1]`; the window draws it raised, as `[¹]`, and
|
||||
the entry under the answer reads `[1] Title` at normal size, where scanning a list
|
||||
matters more than convention. This reverses an earlier decision that markers stay
|
||||
at baseline, taken on the grounds that a raised marker is a smaller target; the
|
||||
user asked for the raised form after reading the first version, and the target is
|
||||
the same width in practice because only the digits are raised.
|
||||
- **A6. Superscripts are Unicode digits, not a `<sup>` tag.** The label of a markdown
|
||||
link is plain text that no pass can break, while inline HTML inside a label asks
|
||||
the sanitizer, the parser and Qt to agree about something this repository cannot
|
||||
test. Digits are raised by design in every font, so the mark still reads correctly
|
||||
when a font lacks the rarer glyphs and Qt falls back for them.
|
||||
|
||||
## Design
|
||||
|
||||
### Module layout
|
||||
|
||||
One new module, `src/lib/citations.ts`, with no Vicinae imports so it unit tests
|
||||
without a runtime. It exports:
|
||||
|
||||
- `interface Source { index: number; title: string; url: string }`, the shape
|
||||
stored on a record and rendered in the list.
|
||||
- `createSourceList()`, returning an object with `add(title, url)`, which gives
|
||||
back the number to print plus the `Source` when that URL was new and nothing
|
||||
when it was already known.
|
||||
- `collectSources(calls)`, flattening a turn's records into one list ordered by
|
||||
number, which is what the transcript renders from.
|
||||
- `linkCitations(markdown, sources)`, returning the rewritten text and the set of
|
||||
numbers the answer actually cited, since the list needs that set (A1).
|
||||
- `renderSources(sources)`, the list block itself.
|
||||
|
||||
### Numbering
|
||||
|
||||
One source list per turn, created in `runAgent` and threaded through
|
||||
`ToolContext`. It assigns the next free integer the first time it sees a URL,
|
||||
and reuses the existing number afterwards, so a page found by a search and read
|
||||
again with `web_fetch` keeps one number. A turn is the whole scope: numbers
|
||||
restart at 1 on the next question, which is what makes them safe to renumber
|
||||
never (A2).
|
||||
|
||||
Deduplication is an exact match on the trimmed URL. Normalising further (case,
|
||||
trailing slash, tracking parameters) risks merging two genuinely different
|
||||
pages, and the cost of a miss is only a duplicate entry.
|
||||
|
||||
### Tool output
|
||||
|
||||
Both web tools print the number so the model can cite it.
|
||||
|
||||
```
|
||||
[1] Result title
|
||||
https://example.com/page
|
||||
Snippet text …
|
||||
```
|
||||
|
||||
A fetched page keeps its labelled header, because the body after it can run for
|
||||
thousands of characters, and gains the same numbered first line:
|
||||
|
||||
```
|
||||
[2] Page title
|
||||
URL: https://example.com/page
|
||||
|
||||
<body>
|
||||
|
||||
Links on page:
|
||||
- https://example.com/other
|
||||
```
|
||||
|
||||
`executeTool` returns the new entries with the result (`ToolResult.sources`), and
|
||||
`runAgent` puts them on the `ToolCallRecord` it already creates. Nothing else
|
||||
about the tool protocol changes, and a caller that supplies no list (a unit
|
||||
test, say) gets a throwaway one so the output format is identical either way.
|
||||
|
||||
### Storage
|
||||
|
||||
`ToolCallRecord` gains an optional `sources?: Source[]` holding only the entries
|
||||
that call added, so a turn's full list is the flattening of its records. The
|
||||
zod schema gains the same optional field: zod strips unknown keys by default, so
|
||||
without it the field would be silently lost on the first reload and a reopened
|
||||
conversation would lose every link.
|
||||
|
||||
A turn stored before this change has no field, so its markers stay literal and
|
||||
it shows no list. That is the honest outcome: those turns never had numbered
|
||||
sources.
|
||||
|
||||
### Rendering
|
||||
|
||||
`renderModelOutput` takes an optional `{ sources }` and runs a citation pass
|
||||
first, before the maths and HTML passes, so a marker that the maths pass or the
|
||||
HTML allowlist then walks over is already a plain markdown link.
|
||||
|
||||
The pass follows the rules the rest of the pipeline already follows.
|
||||
|
||||
- **Prose only.** Fenced segments and code spans are skipped, using the same
|
||||
machinery the maths pass uses. A `[1]` inside a snippet is text.
|
||||
- **`[n]`, `[^n]`, comma lists, and runs.** `[1, 3]` is two sources inside one
|
||||
marker, and `[1][2]` is two markers written touching. Both are drawn as raised
|
||||
digits separated by a space, because `¹²` reads as a single number and leaves the
|
||||
reader no way to tell where one citation ends, which is a defect the first version
|
||||
of this shipped with. A space rather than a comma, because a comma sits at the
|
||||
baseline and so drops away from digits that are raised above it.
|
||||
- **All or nothing.** A marker is linked only when every index it names is a
|
||||
known source. `[1, 9]` where 9 was never seen stays exactly as written, rather
|
||||
than becoming half a link.
|
||||
- **`http` and `https` only.** A destination is linked only when its scheme is
|
||||
`http` or `https` and it contains no whitespace, no angle bracket, no
|
||||
backslash and no control character, since any of those would break the link
|
||||
form or smuggle a second one. Anything else is left as literal text. This is
|
||||
the same reasoning as `SAFE_URL` in `html.ts`: a search result is untrusted
|
||||
input, and a clickable `javascript:` link would be a real vulnerability.
|
||||
- **Markdown structure wins.** A marker followed by `(` or `[` is part of
|
||||
something the model wrote, such as a link, so it is left alone.
|
||||
- **Duplicate definitions are dropped.** A `[^n]:` definition line for a known
|
||||
index is removed, because the authoritative entry is in our list and the
|
||||
model's copy would be a second line saying the same thing. An unknown index
|
||||
keeps its line, since there is nothing to replace it with.
|
||||
|
||||
The answer then gains a list, after the body and before the `_Tools:_` line:
|
||||
|
||||
```
|
||||
_Sources_
|
||||
|
||||
- [\[1\]](https://example.com/page) Result title
|
||||
- [\[2\]](https://example.com/page-two) Page title
|
||||
```
|
||||
|
||||
The marker is the link, matching the inline marker character for character, so
|
||||
the same token means the same thing in both places. The brackets are
|
||||
escaped because `markdownLink` escapes them: an unescaped `]` would end the link
|
||||
text early, and the renderer draws the escaped form as a plain `[1]`. An entry
|
||||
whose URL cannot be linked keeps its title and shows no marker link.
|
||||
|
||||
### Prompt
|
||||
|
||||
The system prompt gains a paragraph next to the existing link rule, because both
|
||||
are about the same thing: what a pointer in this window does. It asks for a
|
||||
number the tool actually printed, one number at a time, no footnote definitions,
|
||||
and no sources list of its own.
|
||||
|
||||
## Error handling
|
||||
|
||||
| Situation | Behaviour |
|
||||
| ---------------------------------------- | ------------------------------------------------------ |
|
||||
| No sources gathered | The pass is the identity function and no list is added |
|
||||
| Tool failed, or returned no results | Registers nothing |
|
||||
| Same URL found twice | One number, reused |
|
||||
| Model cited a number that does not exist | Left as literal text |
|
||||
| Model wrote `[1](https://…)` | Left alone, the `(` marks it as structure |
|
||||
| Answer cites nothing while sources exist | Every source is listed (A1) |
|
||||
| Session stored before this change | No links, no list |
|
||||
| Unsafe or malformed destination | Marker and entry stay text, nothing is clickable |
|
||||
|
||||
## Testing
|
||||
|
||||
- `citations.test.ts`, new: numbering, deduplication, order, and reuse across
|
||||
calls; linkification of `[1]`, `[^1]` and `[1, 3]`; a marker left alone for an
|
||||
unknown index, an unsafe scheme, a fenced block, a code span, and a following
|
||||
`(`; definition line removal; the cited set; list rendering; record
|
||||
flattening.
|
||||
- `tools.test.ts`: numbering continues across two calls in one turn; `web_fetch`
|
||||
registers its page; the same URL keeps its number; the format with no list
|
||||
supplied.
|
||||
- `render.test.ts`: a citation marker survives the maths and HTML passes intact.
|
||||
- `markdown.test.ts`: an answer with sources renders markers and a list; an
|
||||
answer without sources is byte for byte what it was before; the list is shown
|
||||
with Show Tool Calls off; the plain text export carries the list.
|
||||
- Existing tool format assertions are updated for the new first line.
|
||||
|
||||
## Approaches considered
|
||||
|
||||
- **Parse the stored tool result strings at render time.** Rejected. It needs no
|
||||
schema change and would work on old sessions, but it means re-deriving
|
||||
numbering from formatted prose, and it breaks the moment the tool output
|
||||
format changes for any other reason.
|
||||
- **Use native markdown footnotes.** Rejected, and now known to be impossible:
|
||||
see the Problem section.
|
||||
- **Always list every source gathered.** Rejected as the default because a long
|
||||
research turn ends in a wall of uncited links, though it is the fallback when
|
||||
the answer cites nothing (A1).
|
||||
- **Renumber citations to be contiguous.** Rejected in A2.
|
||||
- **Link only the first citation of each source.** Rejected: every marker should
|
||||
be clickable, since the reader may arrive at any one of them.
|
||||
- **Superscript markers.** Rejected in A5.
|
||||
|
||||
## Risks
|
||||
|
||||
- Linkification cannot know which sentence a number supports. It makes the
|
||||
number clickable and lists it; the accuracy of the mapping is the model's job.
|
||||
This is why the prompt is specific about citing the number the tool printed
|
||||
rather than a number chosen by the model.
|
||||
- A model that ignores the instruction and writes its own sources list produces
|
||||
two lists. The alternative, guessing at a section to delete, risks deleting an
|
||||
answer.
|
||||
- A turn with many results produces a long list. It is capped only by the tool
|
||||
round budget, and nothing is silently truncated.
|
||||
+83
-10
@@ -11,27 +11,31 @@
|
||||
{
|
||||
"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": "snip",
|
||||
"title": "Snip to Lam",
|
||||
"subtitle": "Lam Agent",
|
||||
"description": "Capture a region of the screen and start a new chat about it. The launcher window closes first so it cannot end up in the shot. Offered only when the model in preferences supports image input.",
|
||||
"mode": "no-view"
|
||||
},
|
||||
{
|
||||
"name": "sessions",
|
||||
"title": "Chat Sessions",
|
||||
"name": "chat-history",
|
||||
"title": "Chat History",
|
||||
"description": "Browse, reopen, and clean up your past conversations with Lam.",
|
||||
"mode": "view"
|
||||
},
|
||||
{
|
||||
"name": "manage-models",
|
||||
"title": "Manage Models",
|
||||
"description": "Browse the models your Ollama host can reach, pick the one Lam should use, unload one that is holding memory, and pull or delete models on a server you run. A pick takes effect immediately; a pull keeps downloading until it finishes, so leave the launcher open.",
|
||||
"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.",
|
||||
"title": "Setup & Status",
|
||||
"description": "Check your Ollama connection, pick the model Lam should use, tune how Lam answers, and clear out old chats or screenshots you never sent.",
|
||||
"mode": "view"
|
||||
}
|
||||
],
|
||||
@@ -46,7 +50,7 @@
|
||||
{
|
||||
"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.",
|
||||
"description": "The model Lam should use, for example deepseek-v4.1-flash:cloud or gpt-oss:120b. Run \"Setup & Status\" to pick from the models your key can access.",
|
||||
"type": "textfield",
|
||||
"required": true
|
||||
},
|
||||
@@ -65,6 +69,22 @@
|
||||
"type": "textfield",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "location",
|
||||
"title": "Location",
|
||||
"description": "The place Lam should treat as yours, for example Cebu City, Philippines. Optional. Without it Lam falls back to your device timezone and locale, which are accurate to a region and not to a city, so anything that depends on exactly where you are (weather, opening hours) is worth filling in.",
|
||||
"type": "textfield",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "ipGeolocation",
|
||||
"title": "IP Geolocation",
|
||||
"label": "Look up my city from my IP address",
|
||||
"description": "Off by default. When this is on, and the Location preference above is empty, Lam sends your IP address to ipwho.is to work out roughly where you are. That is a third party learning approximately where you are, which is why it is opt-in, and why setting Location makes it unnecessary. It is coarser than a city: an IP address locates the point where traffic left your ISP, which can be hundreds of kilometres away.",
|
||||
"type": "checkbox",
|
||||
"required": false,
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"name": "webSearchEnabled",
|
||||
"title": "Web Tools",
|
||||
@@ -109,19 +129,72 @@
|
||||
{
|
||||
"name": "resumeTimeout",
|
||||
"title": "Start New Chat After",
|
||||
"description": "When the command opens, resume the last conversation only if it was used recently. Past this much idle time it starts a fresh chat instead. The old conversation is never deleted — it stays in the sidebar.",
|
||||
"description": "When the command opens, resume the last conversation only if it was used recently. Past this much idle time it starts a fresh chat instead. The old conversation is never deleted. It stays in the sidebar.",
|
||||
"type": "dropdown",
|
||||
"required": false,
|
||||
"default": "1h",
|
||||
"data": [
|
||||
{ "title": "Never — always reopen the last chat", "value": "never" },
|
||||
{ "title": "Never (always reopen the last chat)", "value": "never" },
|
||||
{ "title": "After 15 minutes", "value": "15m" },
|
||||
{ "title": "After 1 hour", "value": "1h" },
|
||||
{ "title": "After 6 hours", "value": "6h" },
|
||||
{ "title": "After 1 day", "value": "1d" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "autoDeleteAfter",
|
||||
"title": "Delete Old Chats After",
|
||||
"description": "Permanently delete a conversation once you have not opened it for this long, along with any screenshots it captured. The check runs when a Lam command opens and after each answer, so conversations go when you next use Lam. Off by default: nothing is ever deleted.",
|
||||
"type": "dropdown",
|
||||
"required": false,
|
||||
"default": "disabled",
|
||||
"data": [
|
||||
{ "title": "Disabled (keep conversations forever)", "value": "disabled" },
|
||||
{ "title": "After 1 day", "value": "1d" },
|
||||
{ "title": "After 7 days", "value": "7d" },
|
||||
{ "title": "After 30 days", "value": "30d" },
|
||||
{ "title": "After 90 days", "value": "90d" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "maxToolRounds",
|
||||
"title": "Tool Call Limit",
|
||||
"description": "How many rounds of tool calls Lam may make before it has to answer with what it has gathered. More rounds mean slower but more thorough answers. Unlimited removes the cap; a call that repeats itself with the same arguments three times ends the turn either way, so a stuck model cannot loop forever.",
|
||||
"type": "dropdown",
|
||||
"required": false,
|
||||
"default": "8",
|
||||
"data": [
|
||||
{ "title": "4 rounds", "value": "4" },
|
||||
{ "title": "8 rounds (default)", "value": "8" },
|
||||
{ "title": "16 rounds", "value": "16" },
|
||||
{ "title": "Unlimited (no cap)", "value": "unlimited" }
|
||||
]
|
||||
}
|
||||
],
|
||||
,
|
||||
{
|
||||
"name": "fileAccess",
|
||||
"title": "File Access",
|
||||
"label": "Let Lam read and change files",
|
||||
"description": "Off by default. When this is on, Lam can list, search and read files under your home directory, and can create, change, move and delete files in Downloads, Documents/Lam Agent and its own folder. Every change is shown to you for approval first, credential stores are never readable, and anything Lam reads is sent to the model provider with the conversation.",
|
||||
"type": "checkbox",
|
||||
"required": false,
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"name": "readRoots",
|
||||
"title": "Extra Readable Folders",
|
||||
"description": "Additional folders Lam may read, one absolute path per line. Your home directory is always readable, except for credential stores. Useful for a project folder or an external drive.",
|
||||
"type": "textfield",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "writeRoots",
|
||||
"title": "Extra Writable Folders",
|
||||
"description": "Additional folders Lam may change, one absolute path per line. Downloads and Documents/Lam Agent are always writable when File Access is on, and everything here is still approved change by change.",
|
||||
"type": "textfield",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@vicinae/api": "^0.29.0",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -47,6 +47,27 @@ export function getPreferenceValues<T = Record<string, unknown>>(): T {
|
||||
return preferenceOverride as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast stubs.
|
||||
*
|
||||
* Recorded rather than discarded, so a test can assert that a sweep only speaks
|
||||
* up when it actually deleted something.
|
||||
*/
|
||||
export const __toasts: { title: string; message?: string }[] = [];
|
||||
|
||||
export const Toast = {
|
||||
Style: { Success: "success", Failure: "failure", Animated: "animated" },
|
||||
};
|
||||
|
||||
export async function showToast(options: { title: string; message?: string }): Promise<void> {
|
||||
__toasts.push({ title: options.title, message: options.message });
|
||||
}
|
||||
|
||||
/** Test-only helper: forgets the toasts shown so far. */
|
||||
export function __resetToasts(): void {
|
||||
__toasts.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The runtime `environment`. Only `supportPath` is exercised, by the snip
|
||||
* directory, and a stable fake path keeps those assertions independent of where
|
||||
|
||||
+280
-6
@@ -2,6 +2,7 @@ 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 { DEFAULT_MAX_TOOL_ROUNDS } from "../lib/constants";
|
||||
import { OllamaError } from "../lib/ollama";
|
||||
import { toolsFor } from "../lib/tools";
|
||||
import type { ApiMessage, ToolCall } from "../lib/types";
|
||||
@@ -20,13 +21,37 @@ function ndjsonResponse(events: unknown[], status = 200): Response {
|
||||
}
|
||||
|
||||
function toolCall(name: string, args: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return { message: { role: "assistant", content: "", tool_calls: [{ function: { name, arguments: args } }] }, done: true };
|
||||
return {
|
||||
message: { role: "assistant", content: "", tool_calls: [{ function: { name, arguments: args } }] },
|
||||
done: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** The shape the two Ollama web endpoints answer with. */
|
||||
function jsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function answer(text: string): Record<string, unknown>[] {
|
||||
return [{ message: { role: "assistant", content: text }, done: true, done_reason: "stop" }];
|
||||
}
|
||||
|
||||
/**
|
||||
* One tool call that is distinct every round.
|
||||
*
|
||||
* The arguments have to differ, or the repeat guard in `agent.ts` ends the turn
|
||||
* before the round cap the tests are about ever gets there. `get_datetime` is the
|
||||
* tool to use because it never touches the network, so the only `fetch` calls a
|
||||
* test sees are the ones to the chat endpoint. (`web_search` with a real `query`
|
||||
* fetches, which quietly ate the queued mock responses.)
|
||||
*/
|
||||
function distinctCall(index: number): Record<string, unknown> {
|
||||
return toolCall("get_datetime", { attempt: index });
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -122,6 +147,33 @@ describe("runAgent", () => {
|
||||
expect(toolMessage?.content).toContain("2026");
|
||||
});
|
||||
|
||||
it("numbers a turn's sources once, and keeps each call's own on its record", async () => {
|
||||
// Two searches in one turn. The second has to continue the first's numbering,
|
||||
// or one marker in the answer could mean two different pages; and each record
|
||||
// has to hold only what its own call found, or the list would repeat a page.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(ndjsonResponse([toolCall("web_search", { query: "one" })]))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ results: [{ title: "First", url: "https://first.example", content: "A" }] }),
|
||||
)
|
||||
.mockResolvedValueOnce(ndjsonResponse([toolCall("web_search", { query: "two" })]))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
results: [{ title: "Second", url: "https://second.example", content: "B" }],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(ndjsonResponse(answer("Both [1][2] agree.")));
|
||||
|
||||
const result = await runAgent({
|
||||
...baseOptions([{ role: "user", content: "hi" }]),
|
||||
tools: toolsFor({ webEnabled: true }),
|
||||
});
|
||||
|
||||
expect(result.toolCalls.map((call) => call.sources?.map((source) => source.index))).toEqual([[1], [2]]);
|
||||
expect(result.toolCalls[0].result).toContain("[1] First");
|
||||
expect(result.toolCalls[1].result).toContain("[2] Second");
|
||||
});
|
||||
|
||||
it("reports an unknown tool as an error without aborting the turn", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(ndjsonResponse([toolCall("no_such_tool")]))
|
||||
@@ -136,15 +188,104 @@ describe("runAgent", () => {
|
||||
|
||||
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")]));
|
||||
for (let i = 0; i < DEFAULT_MAX_TOOL_ROUNDS + 2; i++) {
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
|
||||
}
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
expect(result.content).toContain("Stopped after");
|
||||
expect(result.toolCalls).toHaveLength(5);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
expect(result.toolCalls).toHaveLength(DEFAULT_MAX_TOOL_ROUNDS);
|
||||
// Every tool round, plus one closing round with the tools taken away.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(DEFAULT_MAX_TOOL_ROUNDS + 1);
|
||||
// The closing round produced no text either, so the notice is the last resort.
|
||||
expect(result.content).toContain("Ran out of tool calls");
|
||||
});
|
||||
|
||||
it("gives the model a closing round so a spent budget still ends in an answer", async () => {
|
||||
// The reported failure: a broad research question used up the budget and the
|
||||
// turn ended with a notice about the loop instead of an answer.
|
||||
for (let i = 0; i < DEFAULT_MAX_TOOL_ROUNDS; i++) {
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
|
||||
}
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
ndjsonResponse(answer("Meadows have Perch, at 2 fish each. I could not confirm the others.")),
|
||||
);
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "what fish are in valheim" }]));
|
||||
|
||||
expect(result.content).toContain("Meadows have Perch");
|
||||
expect(result.content).not.toContain("Ran out of tool calls");
|
||||
});
|
||||
|
||||
it("takes the tools away for the closing round, so it writes rather than searches again", async () => {
|
||||
for (let i = 0; i < DEFAULT_MAX_TOOL_ROUNDS; i++) {
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
|
||||
}
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("Here is what I found.")));
|
||||
|
||||
await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
const first = JSON.parse(fetchMock.mock.calls[0][1].body as string) as { tools?: unknown };
|
||||
const last = JSON.parse(fetchMock.mock.calls[DEFAULT_MAX_TOOL_ROUNDS][1].body as string) as {
|
||||
tools?: unknown;
|
||||
};
|
||||
|
||||
expect(first.tools).toBeDefined();
|
||||
expect(last.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it("asks the closing round to name what it could not confirm", async () => {
|
||||
for (let i = 0; i < DEFAULT_MAX_TOOL_ROUNDS; i++) {
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
|
||||
}
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("ok")));
|
||||
|
||||
await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
const last = JSON.parse(fetchMock.mock.calls[DEFAULT_MAX_TOOL_ROUNDS][1].body as string) as {
|
||||
messages: ApiMessage[];
|
||||
};
|
||||
const nudge = last.messages[last.messages.length - 1];
|
||||
|
||||
expect(nudge.role).toBe("user");
|
||||
expect(nudge.content).toContain("no more tools are available");
|
||||
expect(nudge.content).toContain("could not confirm");
|
||||
});
|
||||
|
||||
it("falls back to the last preamble when the closing round comes back empty", async () => {
|
||||
// A preamble is not normally the answer, but a turn that gathered things
|
||||
// should still say more than a notice about its own loop.
|
||||
const preamble = (i: number) => ({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Let me look that up.",
|
||||
tool_calls: [{ function: { name: "get_datetime", arguments: { attempt: i } } }],
|
||||
},
|
||||
done: true,
|
||||
});
|
||||
|
||||
for (let i = 0; i < DEFAULT_MAX_TOOL_ROUNDS; i++) {
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([preamble(i)]));
|
||||
}
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
ndjsonResponse([{ message: { role: "assistant", content: "" }, done: true }]),
|
||||
);
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
expect(result.content).toBe("Let me look that up.");
|
||||
});
|
||||
|
||||
it("still reports the tools it used when the budget runs out", async () => {
|
||||
for (let i = 0; i < DEFAULT_MAX_TOOL_ROUNDS; i++) {
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
|
||||
}
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
expect(result.toolCalls).toHaveLength(DEFAULT_MAX_TOOL_ROUNDS);
|
||||
expect(result.toolCalls.every((call) => call.name === "get_datetime")).toBe(true);
|
||||
});
|
||||
|
||||
it("surfaces an actionable hint when the API key is rejected", async () => {
|
||||
@@ -160,6 +301,138 @@ describe("runAgent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Queues `count` rounds that all ask for the identical call. */
|
||||
function queueRepeats(count: number): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
|
||||
}
|
||||
}
|
||||
|
||||
it("stops when the same call comes round a third time instead of running it again", async () => {
|
||||
// The rut this guards against: the model decides the first search did not
|
||||
// answer the question and asks the identical question again, and again.
|
||||
// Two executions is a retry; a third is not going to learn anything new.
|
||||
queueRepeats(3);
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("It is 03:04 UTC.")));
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
// Two executions, plus the refusal that ended the turn.
|
||||
expect(result.toolCalls).toHaveLength(3);
|
||||
expect(result.toolCalls.filter((call) => call.repeated)).toHaveLength(1);
|
||||
expect(result.content).toBe("It is 03:04 UTC.");
|
||||
// Two tool rounds, the refusal round, and the closing round.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("does not count a different call with the same tool as a repeat", async () => {
|
||||
// Searching for three different things is a model doing its job, not a loop.
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "CET" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "JST" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("Three zones.")));
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
expect(result.toolCalls).toHaveLength(3);
|
||||
expect(result.toolCalls.every((call) => !call.repeated)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats reordered arguments as the same call", async () => {
|
||||
// Models are not consistent about key order, and a guard that only catches
|
||||
// byte-identical calls would miss the ordinary shape of a repeat.
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC", unit: "h" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { unit: "h", tz: "UTC" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC", unit: "h" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
expect(result.toolCalls.filter((call) => call.repeated)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("tells the model the call was repeating rather than that a budget ran out", async () => {
|
||||
queueRepeats(3);
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("ok")));
|
||||
|
||||
await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
const last = JSON.parse(fetchMock.mock.calls[3][1].body as string) as { messages: ApiMessage[] };
|
||||
const nudge = last.messages[last.messages.length - 1];
|
||||
|
||||
expect(nudge.role).toBe("user");
|
||||
expect(nudge.content).toContain("identical arguments 3 times");
|
||||
expect(nudge.content).not.toContain("reached the limit");
|
||||
});
|
||||
|
||||
it("answers from what it gathered when a repeat ends the turn", async () => {
|
||||
queueRepeats(3);
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("I could not get past the time check.")));
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
expect(result.content).toBe("I could not get past the time check.");
|
||||
expect(result.content).not.toContain("kept repeating");
|
||||
});
|
||||
|
||||
it("says the turn was cut short when a repeat leaves the closing round speechless", async () => {
|
||||
queueRepeats(3);
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
ndjsonResponse([{ message: { role: "assistant", content: "" }, done: true }]),
|
||||
);
|
||||
|
||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||
|
||||
expect(result.content).toContain("kept repeating the same call");
|
||||
});
|
||||
|
||||
it("honours a caller's round cap in place of the default", async () => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
|
||||
}
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
|
||||
|
||||
const result = await runAgent({ ...baseOptions([{ role: "user", content: "hi" }]), maxToolRounds: 4 });
|
||||
|
||||
expect(result.toolCalls).toHaveLength(4);
|
||||
expect(result.content).toBe("done");
|
||||
});
|
||||
|
||||
it("runs past the default cap when the caller asks for no cap at all", async () => {
|
||||
// The preference can be switched to Unlimited, and the only difference that
|
||||
// should make is that the cap stops arriving.
|
||||
const rounds = DEFAULT_MAX_TOOL_ROUNDS + 3;
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([distinctCall(i)]));
|
||||
}
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
|
||||
|
||||
const result = await runAgent({ ...baseOptions([{ role: "user", content: "hi" }]), maxToolRounds: null });
|
||||
|
||||
expect(result.toolCalls).toHaveLength(rounds);
|
||||
expect(result.content).toBe("done");
|
||||
});
|
||||
|
||||
it("reports each tool start with how many times that call has come round", async () => {
|
||||
// This is what the transcript shows while a turn is in flight, so a repeat
|
||||
// is visible as it happens and not only in the record afterwards.
|
||||
const started: { name: string; label: string; count: number }[] = [];
|
||||
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime", { tz: "UTC" })]));
|
||||
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
|
||||
|
||||
await runAgent({
|
||||
...baseOptions([{ role: "user", content: "hi" }]),
|
||||
events: { onToolStart: (name, label, count) => started.push({ name, label, count }) },
|
||||
});
|
||||
|
||||
expect(started).toEqual([
|
||||
{ name: "get_datetime", label: "Checking the time", count: 1 },
|
||||
{ name: "get_datetime", label: "Checking the time", count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
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
|
||||
@@ -175,6 +448,7 @@ describe("module wiring", () => {
|
||||
"capture.ts",
|
||||
"attachments.ts",
|
||||
"startup.ts",
|
||||
"retention.ts",
|
||||
]) {
|
||||
const source = readFileSync(path.resolve(__dirname, `../lib/${file}`), "utf8");
|
||||
expect(source, `${file} should not import @vicinae/api`).not.toMatch(/from\s+"@vicinae\/api"/);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createApprovalLedger, describeApproval, type FileApproval } from "../lib/approval";
|
||||
import type { ApprovalRequest } from "../lib/files";
|
||||
|
||||
const request = (overrides: Partial<ApprovalRequest> = {}): ApprovalRequest => ({
|
||||
operation: "overwrite",
|
||||
path: "/Users/me/Downloads/report.md",
|
||||
display: "~/Downloads/report.md",
|
||||
subject: "report.md",
|
||||
details: ["Replaces 14 bytes, last changed 3 days ago."],
|
||||
irreversible: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("describeApproval", () => {
|
||||
it("names the operation and the file", () => {
|
||||
expect(describeApproval(request()).title).toBe("Overwrite report.md?");
|
||||
expect(describeApproval(request({ operation: "create" })).title).toBe("Create report.md?");
|
||||
expect(describeApproval(request({ operation: "edit" })).title).toBe("Edit report.md?");
|
||||
});
|
||||
|
||||
it("shows the concrete path and every detail line", () => {
|
||||
const approval = describeApproval(request());
|
||||
expect(approval.body).toContain("~/Downloads/report.md");
|
||||
expect(approval.body).toContain("Replaces 14 bytes");
|
||||
});
|
||||
|
||||
it("puts the path first, because it is the thing being approved", () => {
|
||||
expect(describeApproval(request()).body.split("\n")[0]).toBe("~/Downloads/report.md");
|
||||
});
|
||||
|
||||
it("marks a delete as destructive, and says it cannot be undone", () => {
|
||||
const approval = describeApproval(
|
||||
request({ operation: "delete", details: ["This cannot be undone."], irreversible: true }),
|
||||
);
|
||||
expect(approval.destructive).toBe(true);
|
||||
expect(approval.body).toContain("cannot be undone");
|
||||
});
|
||||
|
||||
it("names both ends of a move", () => {
|
||||
const approval = describeApproval(
|
||||
request({
|
||||
operation: "move",
|
||||
display: "~/a.md to ~/Documents/b.md",
|
||||
subject: "a.md to b.md",
|
||||
}),
|
||||
);
|
||||
expect(approval.title).toBe("Move a.md to b.md?");
|
||||
expect(approval.body).toContain("~/a.md to ~/Documents/b.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createApprovalLedger", () => {
|
||||
it("asks once for a path and remembers the answer", async () => {
|
||||
const asked: FileApproval[] = [];
|
||||
const ledger = createApprovalLedger(async (approval) => {
|
||||
asked.push(approval);
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(await ledger.approve(request())).toBe(true);
|
||||
expect(await ledger.approve(request())).toBe(true);
|
||||
expect(asked).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows the dialog the same words the ledger keyed on", async () => {
|
||||
// A ledger that approved one thing while the dialog described another is the
|
||||
// failure this pair exists to make impossible.
|
||||
const asked: FileApproval[] = [];
|
||||
const ledger = createApprovalLedger(async (approval) => {
|
||||
asked.push(approval);
|
||||
return true;
|
||||
});
|
||||
|
||||
await ledger.approve(request({ operation: "create", subject: "notes.md" }));
|
||||
expect(asked[0].title).toBe("Create notes.md?");
|
||||
});
|
||||
|
||||
it("remembers nothing when the user declines, so the next attempt asks", async () => {
|
||||
let calls = 0;
|
||||
const ledger = createApprovalLedger(async () => {
|
||||
calls++;
|
||||
return false;
|
||||
});
|
||||
|
||||
expect(await ledger.approve(request())).toBe(false);
|
||||
expect(await ledger.approve(request())).toBe(false);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("treats two spellings of one path as one path", async () => {
|
||||
// The ledger keys on the resolved path, so an edit approved for
|
||||
// ~/Downloads/report.md also covers /Users/me/Downloads/report.md.
|
||||
let calls = 0;
|
||||
const ledger = createApprovalLedger(async () => {
|
||||
calls++;
|
||||
return true;
|
||||
});
|
||||
|
||||
await ledger.approve(request());
|
||||
await ledger.approve(request({ display: "/Users/me/Downloads/report.md" }));
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("asks every time for a delete or a move, even after an approval for the same path", async () => {
|
||||
let calls = 0;
|
||||
const ledger = createApprovalLedger(async () => {
|
||||
calls++;
|
||||
return true;
|
||||
});
|
||||
|
||||
await ledger.approve(request());
|
||||
await ledger.approve(request({ operation: "delete", irreversible: true }));
|
||||
await ledger.approve(request({ operation: "delete", irreversible: true }));
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
it("refuses when the dialog itself fails, rather than assuming consent", async () => {
|
||||
// Fail closed: an unshowable dialog is not permission.
|
||||
const ledger = createApprovalLedger(async () => {
|
||||
throw new Error("no such widget");
|
||||
});
|
||||
expect(await ledger.approve(request())).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
citedIndices,
|
||||
collectSources,
|
||||
createSourceList,
|
||||
linkCitations,
|
||||
renderSources,
|
||||
} from "../lib/citations";
|
||||
import type { ToolCallRecord } from "../lib/types";
|
||||
|
||||
/** One record, with only the fields the source helpers read. */
|
||||
function call(sources?: ToolCallRecord["sources"]): ToolCallRecord {
|
||||
return { name: "web_search", arguments: {}, result: "", isError: false, sources };
|
||||
}
|
||||
|
||||
describe("createSourceList", () => {
|
||||
it("numbers sources from one in the order they were found", () => {
|
||||
const list = createSourceList();
|
||||
expect(list.add("First", "https://first.example").index).toBe(1);
|
||||
expect(list.add("Second", "https://second.example").index).toBe(2);
|
||||
});
|
||||
|
||||
it("gives a URL the number it already has, so a re-fetch cites the same page", () => {
|
||||
const list = createSourceList();
|
||||
list.add("First", "https://first.example");
|
||||
const again = list.add("First again", "https://first.example");
|
||||
|
||||
expect(again.index).toBe(1);
|
||||
// Nothing new was added, which is how the caller knows not to store it twice.
|
||||
expect(again.source).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the entry only the first time, so a turn stores it once", () => {
|
||||
const list = createSourceList();
|
||||
expect(list.add("First", "https://first.example").source).toEqual({
|
||||
index: 1,
|
||||
title: "First",
|
||||
url: "https://first.example",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores surrounding whitespace when matching a URL", () => {
|
||||
const list = createSourceList();
|
||||
list.add("First", "https://first.example");
|
||||
expect(list.add("First", " https://first.example ").index).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to the URL as the title when there is none", () => {
|
||||
const list = createSourceList();
|
||||
expect(list.add(" ", "https://first.example").source?.title).toBe("https://first.example");
|
||||
});
|
||||
|
||||
it("gives a result with no usable URL no number at all", () => {
|
||||
// Zero rather than the next number: there is nothing to link to, so the tool
|
||||
// prints no marker and the model cannot cite a page that has no address.
|
||||
const list = createSourceList();
|
||||
expect(list.add("Title", " ").index).toBe(0);
|
||||
expect(list.add("Next", "https://next.example").index).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectSources", () => {
|
||||
it("flattens a turn's records into one list in number order", () => {
|
||||
const collected = collectSources([
|
||||
call([{ index: 1, title: "First", url: "https://first.example" }]),
|
||||
call([{ index: 2, title: "Second", url: "https://second.example" }]),
|
||||
]);
|
||||
|
||||
expect(collected.map((source) => source.index)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("keeps the first record's version of a number", () => {
|
||||
// A number is assigned once, so two records carrying it describe the same
|
||||
// page, and the first one seen is the one that named it.
|
||||
const collected = collectSources([
|
||||
call([{ index: 1, title: "First", url: "https://first.example" }]),
|
||||
call([{ index: 1, title: "Duplicate", url: "https://first.example" }]),
|
||||
]);
|
||||
|
||||
expect(collected).toEqual([{ index: 1, title: "First", url: "https://first.example" }]);
|
||||
});
|
||||
|
||||
it("sorts by number even when the records arrive out of order", () => {
|
||||
const collected = collectSources([
|
||||
call([{ index: 3, title: "Third", url: "https://third.example" }]),
|
||||
call([{ index: 1, title: "First", url: "https://first.example" }]),
|
||||
]);
|
||||
|
||||
expect(collected.map((source) => source.index)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("is empty for a turn that consulted nothing", () => {
|
||||
expect(collectSources([])).toEqual([]);
|
||||
expect(collectSources([call()])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("citedIndices", () => {
|
||||
const sources = [
|
||||
{ index: 1, title: "First", url: "https://first.example" },
|
||||
{ index: 2, title: "Second", url: "https://second.example" },
|
||||
];
|
||||
|
||||
it("finds the numbers an answer cites, in either marker shape", () => {
|
||||
expect([...citedIndices("See [1] and [^2].", sources)].sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("ignores a number that is not a source", () => {
|
||||
expect([...citedIndices("See [9].", sources)]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("linkCitations", () => {
|
||||
const sources = [
|
||||
{ index: 1, title: "First", url: "https://first.example" },
|
||||
{ index: 3, title: "Third", url: "https://third.example" },
|
||||
];
|
||||
|
||||
it("raises a marker into a superscript link", () => {
|
||||
const result = linkCitations("Rain is likely [1].", sources);
|
||||
|
||||
expect(result.text).toBe("Rain is likely [¹](https://first.example).");
|
||||
expect([...result.cited]).toEqual([1]);
|
||||
});
|
||||
|
||||
it("reads the footnote shape as the same thing", () => {
|
||||
expect(linkCitations("Rain [^1].", sources).text).toBe("Rain [¹](https://first.example).");
|
||||
});
|
||||
|
||||
it("separates markers written as one list", () => {
|
||||
const result = linkCitations("Both [1, 3] agree.", sources);
|
||||
|
||||
expect(result.text).toBe("Both [¹](https://first.example) [³](https://third.example) agree.");
|
||||
expect([...result.cited].sort()).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("leaves a marker alone when any number in it is not a source", () => {
|
||||
// Half a link reads as a citation that works, which is worse than the marker
|
||||
// the model wrote: the reader cannot tell which half is missing.
|
||||
const result = linkCitations("See [1, 9].", sources);
|
||||
|
||||
expect(result.text).toBe("See [1, 9].");
|
||||
expect([...result.cited]).toEqual([]);
|
||||
});
|
||||
|
||||
it("separates markers written touching, which would otherwise read as one number", () => {
|
||||
// The prompt asks for exactly this shape, and `¹³` is a number: nothing about it
|
||||
// says it is two sources, or where the first one stops and the second begins.
|
||||
const result = linkCitations("Both [1][3] agree.", sources);
|
||||
|
||||
expect(result.text).toBe("Both [¹](https://first.example) [³](https://third.example) agree.");
|
||||
expect([...result.cited].sort()).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("treats a space between two markers the same way", () => {
|
||||
expect(linkCitations("Both [1] [3] agree.", sources).text).toBe(
|
||||
"Both [¹](https://first.example) [³](https://third.example) agree.",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a whole run alone when one number in it is not a source", () => {
|
||||
expect(linkCitations("See [1][9].", sources).text).toBe("See [1][9].");
|
||||
});
|
||||
|
||||
it("does not join markers across a line break", () => {
|
||||
// A run belongs to one line. Crossing a newline would pull two paragraphs of
|
||||
// citations into one sentence.
|
||||
expect(linkCitations("See [1]\n[3].", sources).text).toBe(
|
||||
"See [¹](https://first.example)\n[³](https://third.example).",
|
||||
);
|
||||
});
|
||||
|
||||
it("orders a run ascending, because a model lists by rank and a reader reads by number", () => {
|
||||
// A real answer sent [2][1] for a sentence resting on both, which drew as
|
||||
// "two, one" and left the reader to work out that it meant the first two sources.
|
||||
expect(linkCitations("Both [3][1] agree.", sources).text).toBe(
|
||||
"Both [¹](https://first.example) [³](https://third.example) agree.",
|
||||
);
|
||||
});
|
||||
|
||||
it("cites one page once, however often a run names it", () => {
|
||||
expect(linkCitations("Both [3][1][3] agree.", sources).text).toBe(
|
||||
"Both [¹](https://first.example) [³](https://third.example) agree.",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a marker alone when the destination is not one this window should open", () => {
|
||||
const unsafe = [{ index: 1, title: "Nasty", url: "javascript:alert(1)" }];
|
||||
const result = linkCitations("See [1].", unsafe);
|
||||
|
||||
expect(result.text).toBe("See [1].");
|
||||
expect([...result.cited]).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves a destination containing whitespace as text", () => {
|
||||
const odd = [{ index: 1, title: "Odd", url: "https://first.example/a b" }];
|
||||
expect(linkCitations("See [1].", odd).text).toBe("See [1].");
|
||||
});
|
||||
|
||||
it("does not touch a link the model wrote", () => {
|
||||
const result = linkCitations("See [1](https://elsewhere.example).", sources);
|
||||
expect(result.text).toBe("See [1](https://elsewhere.example).");
|
||||
});
|
||||
|
||||
it("does not touch a reference definition line", () => {
|
||||
expect(linkCitations("[1]: https://elsewhere.example", sources).text).toBe(
|
||||
"[1]: https://elsewhere.example",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a footnote definition for a source it already lists", () => {
|
||||
const answer = "Rain [^1].\n\n[^1]: First, https://first.example";
|
||||
expect(linkCitations(answer, sources).text).toBe("Rain [¹](https://first.example).\n");
|
||||
});
|
||||
|
||||
it("keeps a markdown reference definition, which backs a link the model wrote", () => {
|
||||
// `[1]:` with no caret is reference syntax, not a footnote. Only the footnote
|
||||
// shape is unambiguously a duplicate of an entry we render ourselves.
|
||||
const answer = "Rain [^1].\n\n[1]: https://elsewhere.example";
|
||||
expect(linkCitations(answer, sources).text).toBe(
|
||||
"Rain [¹](https://first.example).\n\n[1]: https://elsewhere.example",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a definition for a number it knows nothing about", () => {
|
||||
const answer = "Rain [^1].\n\n[^7]: Something else";
|
||||
expect(linkCitations(answer, sources).text).toBe(
|
||||
"Rain [¹](https://first.example).\n\n[^7]: Something else",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a marker inside a code span as the text it is", () => {
|
||||
expect(linkCitations("Type `curl [1]` now.", sources).text).toBe("Type `curl [1]` now.");
|
||||
});
|
||||
|
||||
it("changes nothing when there are no sources", () => {
|
||||
expect(linkCitations("See [1].", []).text).toBe("See [1].");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderSources", () => {
|
||||
const sources = [
|
||||
{ index: 1, title: "First", url: "https://first.example" },
|
||||
{ index: 3, title: "Third", url: "https://third.example" },
|
||||
];
|
||||
|
||||
it("lists the sources the answer cited, numbered as the model numbered them", () => {
|
||||
const list = renderSources(sources, new Set([3]), false);
|
||||
|
||||
expect(list).toBe("_Sources_\n\n- [\\[3\\]](https://third.example) Third");
|
||||
});
|
||||
|
||||
it("lists everything when the answer cited nothing, once the turn has ended", () => {
|
||||
// Nothing to filter on, so the only thing left to show is what was consulted.
|
||||
expect(renderSources(sources, new Set(), false)).toBe(
|
||||
"_Sources_\n\n- [\\[1\\]](https://first.example) First\n- [\\[3\\]](https://third.example) Third",
|
||||
);
|
||||
});
|
||||
|
||||
it("stays away while an answer is still arriving", () => {
|
||||
// Otherwise the list appears in full at the first token and then shrinks as
|
||||
// citations arrive, which reads as a glitch rather than as settling.
|
||||
expect(renderSources(sources, new Set(), true)).toBe("");
|
||||
});
|
||||
|
||||
it("lists nothing when there was nothing to list", () => {
|
||||
expect(renderSources([], new Set(), false)).toBe("");
|
||||
});
|
||||
|
||||
it("keeps an entry whose destination cannot be linked", () => {
|
||||
const unsafe = [{ index: 1, title: "Nasty", url: "javascript:alert(1)" }];
|
||||
expect(renderSources(unsafe, new Set(), false)).toBe("_Sources_\n\n- [1] Nasty");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { access, mkdir, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { __resetLocalStorage, __resetToasts, __toasts } from "../__mocks__/@vicinae/api";
|
||||
import { clearAllChats, clearOrphanedSnips, runAutoDelete } from "../lib/cleanup";
|
||||
import { createSession, createTurn, listSessions, saveSession } from "../lib/sessions";
|
||||
|
||||
/**
|
||||
* The one thing the sweep does that the pure decision in `retention.ts` cannot:
|
||||
* tell the user about it. What matters here is that it stays quiet otherwise,
|
||||
* because a notification on every launch is one people stop reading.
|
||||
*/
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const DIRECTORY = "/tmp/lam-agent-support/snips";
|
||||
|
||||
beforeEach(async () => {
|
||||
__resetLocalStorage();
|
||||
__resetToasts();
|
||||
await rm(DIRECTORY, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(DIRECTORY, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
/** A turn carrying one attached capture. */
|
||||
function snipTurn(path: string) {
|
||||
return { ...createTurn("what is this", "m", Date.now()), attachments: [{ path, mime: "image/png" }] };
|
||||
}
|
||||
|
||||
/** Saves a conversation that refers to a fresh capture of the given name. */
|
||||
async function referenceSnip(name: string, ageMs: number): Promise<string> {
|
||||
await mkdir(DIRECTORY, { recursive: true });
|
||||
const path = `${DIRECTORY}/${name}`;
|
||||
await writeFile(path, "png");
|
||||
const when = new Date(Date.now() - ageMs);
|
||||
await utimes(path, when, when);
|
||||
|
||||
await saveSession({
|
||||
...createSession("m", Date.now()),
|
||||
turns: [snipTurn(path)],
|
||||
});
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
describe("runAutoDelete", () => {
|
||||
it("deletes nothing and says nothing when auto-deletion is off", async () => {
|
||||
await saveSession({ ...createSession("m", 1), updatedAt: 1 });
|
||||
|
||||
await runAutoDelete({ maxAgeMs: undefined });
|
||||
|
||||
expect(await listSessions()).toHaveLength(1);
|
||||
expect(__toasts).toEqual([]);
|
||||
});
|
||||
|
||||
it("stays quiet when nothing was old enough", async () => {
|
||||
await saveSession({ ...createSession("m", Date.now()) });
|
||||
|
||||
await runAutoDelete({ maxAgeMs: 7 * DAY });
|
||||
|
||||
expect(__toasts).toEqual([]);
|
||||
expect(await listSessions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports what it deleted", async () => {
|
||||
const now = Date.now();
|
||||
await saveSession({ ...createSession("m", now - 60 * DAY), updatedAt: now - 60 * DAY });
|
||||
await saveSession({ ...createSession("m", now), updatedAt: now });
|
||||
|
||||
await runAutoDelete({ maxAgeMs: 7 * DAY });
|
||||
|
||||
expect(await listSessions()).toHaveLength(1);
|
||||
expect(__toasts).toHaveLength(1);
|
||||
expect(__toasts[0].title).toBe("Cleared out old conversations");
|
||||
expect(__toasts[0].message).toContain("1 conversation");
|
||||
});
|
||||
|
||||
it("stays quiet when the only old conversation is the one being rendered", async () => {
|
||||
const now = Date.now();
|
||||
const open = { ...createSession("m", now), updatedAt: now - 60 * DAY };
|
||||
await saveSession(open);
|
||||
|
||||
await runAutoDelete({ maxAgeMs: 7 * DAY, keepId: open.id });
|
||||
|
||||
expect(__toasts).toEqual([]);
|
||||
expect(await listSessions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("survives a corrupt index rather than failing the command that called it", async () => {
|
||||
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
|
||||
await LocalStorage.setItem("lam.sessions.index", "{not json");
|
||||
|
||||
await expect(runAutoDelete({ maxAgeMs: 7 * DAY })).resolves.toBeUndefined();
|
||||
expect(__toasts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the maintenance actions", () => {
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
async function makeSnip(name: string, ageMs: number): Promise<string> {
|
||||
await mkdir(`${DIRECTORY}`, { recursive: true });
|
||||
const path = `${DIRECTORY}/${name}`;
|
||||
await writeFile(path, "png");
|
||||
const when = new Date(Date.now() - ageMs);
|
||||
await utimes(path, when, when);
|
||||
return path;
|
||||
}
|
||||
|
||||
it("deletes every conversation and its screenshots, and reports both counts", async () => {
|
||||
const path = await makeSnip("one.png", 0);
|
||||
await saveSession({
|
||||
...createSession("m", Date.now()),
|
||||
turns: [snipTurn(path)],
|
||||
});
|
||||
await saveSession({ ...createSession("m", Date.now()), turns: [createTurn("hi", "m", Date.now())] });
|
||||
|
||||
await clearAllChats();
|
||||
|
||||
expect(await listSessions()).toHaveLength(0);
|
||||
await expect(access(path)).rejects.toThrow();
|
||||
expect(__toasts).toEqual([
|
||||
{
|
||||
title: "Cleared out all conversations",
|
||||
message: "2 conversations and 1 screenshot",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers an explicit clear even when there was nothing to delete", async () => {
|
||||
// The user pressed a button, so silence would read as a failure. The
|
||||
// background sweep stays quiet in the same situation, on purpose.
|
||||
await clearAllChats();
|
||||
|
||||
expect(__toasts).toEqual([
|
||||
{ title: "Cleared out all conversations", message: "there was nothing to delete" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("collects unattached photos and reports the count", async () => {
|
||||
await referenceSnip("kept.png", 30 * DAY);
|
||||
await makeSnip("orphan.png", 30 * DAY);
|
||||
|
||||
await clearOrphanedSnips();
|
||||
|
||||
expect(__toasts).toEqual([{ title: "Cleared out unattached photos", message: "1 screenshot" }]);
|
||||
});
|
||||
|
||||
it("says there was nothing to delete when every photo is spoken for", async () => {
|
||||
await referenceSnip("kept.png", 30 * DAY);
|
||||
|
||||
await clearOrphanedSnips();
|
||||
|
||||
expect(__toasts).toEqual([
|
||||
{ title: "Cleared out unattached photos", message: "there was nothing to delete" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses rather than guessing when a conversation cannot be read", async () => {
|
||||
await makeSnip("orphan.png", 30 * DAY);
|
||||
await saveSession({ ...createSession("m", Date.now()) });
|
||||
|
||||
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
|
||||
const [summary] = await listSessions();
|
||||
const legacy = { ...summary };
|
||||
delete legacy.attachmentPaths;
|
||||
await LocalStorage.setItem("lam.sessions.index", JSON.stringify([legacy]));
|
||||
await LocalStorage.removeItem(`lam.session.${summary.id}`);
|
||||
|
||||
await clearOrphanedSnips();
|
||||
|
||||
expect(__toasts).toHaveLength(1);
|
||||
expect(__toasts[0].title).toContain("Could not check");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
fenceAsciiArt,
|
||||
inferLanguage,
|
||||
labelCodeFences,
|
||||
mapOutsideCodeSpans,
|
||||
normalizeFenceLabels,
|
||||
promoteStandaloneCodeSpans,
|
||||
repairCodeBlocks,
|
||||
restoreUrlLinks,
|
||||
@@ -416,3 +419,60 @@ describe("labelCodeFences", () => {
|
||||
expect(labelCodeFences(" ```\n ls\n ```")).toContain(" ```bash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapOutsideCodeSpans", () => {
|
||||
it("applies the transform everywhere a span is not", () => {
|
||||
expect(mapOutsideCodeSpans("a `b` c", (text) => text.toUpperCase())).toBe("A `b` C");
|
||||
});
|
||||
|
||||
it("matches a delimiter of the same length only", () => {
|
||||
// A single-backtick span inside a double-backtick span is content, not a
|
||||
// closer, so the inner one is left exactly as written.
|
||||
expect(mapOutsideCodeSpans("`` `a` ``", (text) => text.toUpperCase())).toBe("`` `a` ``");
|
||||
});
|
||||
|
||||
it("treats an unclosed run as text and carries on after it", () => {
|
||||
expect(mapOutsideCodeSpans("a ` b", (text) => text.toUpperCase())).toBe("A ` B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeFenceLabels", () => {
|
||||
it("names a language that resolves in place of one that does not", () => {
|
||||
expect(normalizeFenceLabels("```console\nls\n```")).toBe("```bash\nls\n```");
|
||||
});
|
||||
|
||||
it("drops a label that carries no information", () => {
|
||||
expect(normalizeFenceLabels("```text\nplain\n```")).toBe("```\nplain\n```");
|
||||
});
|
||||
|
||||
it("leaves a language the model chose alone", () => {
|
||||
const input = "```rust\nfn main() {}\n```";
|
||||
expect(normalizeFenceLabels(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fenceAsciiArt", () => {
|
||||
it("fences a tree so its columns stay monospaced", () => {
|
||||
expect(fenceAsciiArt("src/\n├── a.ts\n└── b.ts")).toBe("```\nsrc/\n├── a.ts\n└── b.ts\n```");
|
||||
});
|
||||
|
||||
it("fences an ASCII table drawn with borders", () => {
|
||||
const art = "+---+---+\n| a | b |\n+---+---+";
|
||||
expect(fenceAsciiArt(art)).toBe("```\n" + art + "\n```");
|
||||
});
|
||||
|
||||
it("leaves a real GFM table to the table renderer", () => {
|
||||
const table = "| a | b |\n| --- | --- |\n| 1 | 2 |";
|
||||
expect(fenceAsciiArt(table)).toBe(table);
|
||||
});
|
||||
|
||||
it("leaves prose that contains one pipe alone", () => {
|
||||
const prose = "Here is what it does:\n| a | b |";
|
||||
expect(fenceAsciiArt(prose)).toBe(prose);
|
||||
});
|
||||
|
||||
it("leaves a diagram that is already fenced alone", () => {
|
||||
const fenced = "```\n├── a.ts\n└── b.ts\n```";
|
||||
expect(fenceAsciiArt(fenced)).toBe(fenced);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
createPolicy,
|
||||
deletePath,
|
||||
denialFor,
|
||||
editTextFile,
|
||||
isWithin,
|
||||
listDirectory,
|
||||
movePath,
|
||||
readDenial,
|
||||
readTextFile,
|
||||
resolvePath,
|
||||
searchFiles,
|
||||
splitRoots,
|
||||
writeDenial,
|
||||
writeTextFile,
|
||||
type ApprovalRequest,
|
||||
type PathPolicy,
|
||||
} from "../lib/files";
|
||||
|
||||
/**
|
||||
* A temporary home, with its symlinks resolved.
|
||||
*
|
||||
* `mkdtemp` on macOS hands back a path under `/var`, which is itself a symlink to
|
||||
* `/private/var`, so a policy built on the raw value and a resolved path would
|
||||
* disagree about every single path in these tests. Resolving here is what keeps the
|
||||
* tests about the policy rather than about the platform.
|
||||
*/
|
||||
async function makeHome(prefix: string): Promise<string> {
|
||||
return realpath(await mkdtemp(path.join(tmpdir(), prefix)));
|
||||
}
|
||||
|
||||
describe("isWithin", () => {
|
||||
it("accepts the root itself and a path under it", () => {
|
||||
expect(isWithin("/Users/me/Downloads", "/Users/me/Downloads")).toBe(true);
|
||||
expect(isWithin("/Users/me/Downloads/a/b.md", "/Users/me/Downloads")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a sibling whose name merely starts the same way", () => {
|
||||
// The whole reason containment is component-wise: a string prefix would let this
|
||||
// through, and it is not inside the root.
|
||||
expect(isWithin("/Users/me/Downloads-evil/x", "/Users/me/Downloads")).toBe(false);
|
||||
expect(isWithin("/Users/me", "/Users/me/Downloads")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitRoots", () => {
|
||||
const home = "/Users/me";
|
||||
|
||||
it("splits on newlines and commas, trimming as it goes", () => {
|
||||
expect(splitRoots("/a\n/b, /c", home).roots).toEqual(["/a", "/b", "/c"]);
|
||||
});
|
||||
|
||||
it("is empty for empty or missing input", () => {
|
||||
expect(splitRoots(undefined, home).roots).toEqual([]);
|
||||
expect(splitRoots(" \n , ", home).roots).toEqual([]);
|
||||
});
|
||||
|
||||
it("expands a home shorthand", () => {
|
||||
expect(splitRoots("~/Code\n~", home).roots).toEqual(["/Users/me/Code", "/Users/me"]);
|
||||
});
|
||||
|
||||
it("rejects a relative path instead of guessing what it means", () => {
|
||||
const result = splitRoots("/a\nrelative/path\n/b", home);
|
||||
expect(result.roots).toEqual(["/a", "/b"]);
|
||||
expect(result.rejected).toEqual(["relative/path"]);
|
||||
});
|
||||
|
||||
it("keeps one copy of a repeated root", () => {
|
||||
expect(splitRoots("/a\n/a", home).roots).toEqual(["/a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePath", () => {
|
||||
let home: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
home = await makeHome("lam-files-");
|
||||
await mkdir(path.join(home, "Downloads", "reports"), { recursive: true });
|
||||
await writeFile(path.join(home, "Downloads", "report.md"), "hello\n");
|
||||
await mkdir(path.join(home, "outside"), { recursive: true });
|
||||
// A symlink that points out of the write roots, which the policy must judge by
|
||||
// where it lands rather than by where it is written.
|
||||
await symlink(path.join(home, "outside"), path.join(home, "Downloads", "escape"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const policy = (): PathPolicy => createPolicy({ home, supportPath: path.join(home, "support") });
|
||||
|
||||
it("returns the real path of a file that exists", async () => {
|
||||
const result = await resolvePath(path.join(home, "Downloads", "report.md"), policy());
|
||||
expect(result).toMatchObject({ ok: true, path: path.join(home, "Downloads", "report.md") });
|
||||
});
|
||||
|
||||
it("resolves a relative path against home", async () => {
|
||||
const result = await resolvePath("Downloads/report.md", policy());
|
||||
expect(result).toMatchObject({ ok: true, path: path.join(home, "Downloads", "report.md") });
|
||||
});
|
||||
|
||||
it("expands a leading tilde", async () => {
|
||||
const result = await resolvePath("~/Downloads/report.md", policy());
|
||||
expect(result).toMatchObject({ ok: true, path: path.join(home, "Downloads", "report.md") });
|
||||
});
|
||||
|
||||
it("names the nearest existing ancestor for a path that does not exist yet", async () => {
|
||||
const result = await resolvePath(path.join(home, "Downloads", "new", "file.md"), policy());
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
path: path.join(home, "Downloads", "new", "file.md"),
|
||||
missing: true,
|
||||
anchor: path.join(home, "Downloads"),
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves .. before deciding, so it cannot walk out of a root", async () => {
|
||||
const result = await resolvePath(path.join(home, "Downloads", "..", "..", "etc"), policy());
|
||||
expect(result).toMatchObject({ ok: true, path: path.resolve(home, "..", "etc") });
|
||||
});
|
||||
|
||||
it("follows a symlink to where it lands", async () => {
|
||||
const result = await resolvePath(path.join(home, "Downloads", "escape", "x.md"), policy());
|
||||
expect(result).toMatchObject({ ok: true, path: path.join(home, "outside", "x.md") });
|
||||
});
|
||||
|
||||
it("refuses an empty path or one with a null byte", async () => {
|
||||
expect((await resolvePath("", policy())).ok).toBe(false);
|
||||
expect((await resolvePath("a\0b", policy())).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readable and writable", () => {
|
||||
const home = "/Users/me";
|
||||
const policy = createPolicy({ home, supportPath: "/Users/me/support" });
|
||||
|
||||
it("reads anything under home", () => {
|
||||
expect(readDenial(path.join(home, "Projects", "a.ts"), policy)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses anything outside home", () => {
|
||||
expect(readDenial("/etc/passwd", policy)).toContain("outside the paths");
|
||||
});
|
||||
|
||||
it("refuses each credential store by name", () => {
|
||||
for (const denied of [
|
||||
path.join(home, ".ssh", "id_rsa"),
|
||||
path.join(home, ".gnupg", "secring.gpg"),
|
||||
path.join(home, ".aws", "credentials"),
|
||||
path.join(home, ".config", "gh", "hosts.yml"),
|
||||
path.join(home, ".netrc"),
|
||||
path.join(home, ".docker", "config.json"),
|
||||
path.join(home, "Projects", ".env"),
|
||||
path.join(home, "Projects", ".env.local"),
|
||||
path.join(home, "keys", "server.pem"),
|
||||
path.join(home, "keys", "server.key"),
|
||||
path.join(home, "keys", "cert.p12"),
|
||||
path.join(home, "creds", "credentials.json"),
|
||||
path.join(home, "keys", "id_ed25519"),
|
||||
]) {
|
||||
expect(denialFor(denied, policy), denied).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a near miss alone", () => {
|
||||
// Names that merely resemble the rules: a denylist that over-matches teaches the
|
||||
// user that the feature is broken rather than careful.
|
||||
for (const allowed of [
|
||||
path.join(home, "Projects", "environment.md"),
|
||||
path.join(home, "Projects", ".envrc.example"),
|
||||
path.join(home, "Documents", "keys.keynote"),
|
||||
path.join(home, "Documents", "id_ed25519.pub"),
|
||||
path.join(home, "Downloads", "pem"),
|
||||
]) {
|
||||
expect(denialFor(allowed, policy), allowed).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("writes only inside the write roots", () => {
|
||||
expect(writeDenial(path.join(home, "Downloads", "a.md"), policy)).toBeUndefined();
|
||||
expect(writeDenial(path.join(home, "Documents", "Lam Agent", "a.md"), policy)).toBeUndefined();
|
||||
expect(writeDenial("/Users/me/support/a.md", policy)).toBeUndefined();
|
||||
expect(writeDenial(path.join(home, "Documents", "taxes.md"), policy)).toContain("outside the paths");
|
||||
});
|
||||
|
||||
it("adds the roots the preferences name", () => {
|
||||
const extended = createPolicy({
|
||||
home,
|
||||
readRoots: ["/Volumes/Archive"],
|
||||
writeRoots: ["/Volumes/Archive/scratch"],
|
||||
});
|
||||
|
||||
expect(readDenial("/Volumes/Archive/db.sqlite", extended)).toBeUndefined();
|
||||
expect(writeDenial("/Volumes/Archive/scratch/a.md", extended)).toBeUndefined();
|
||||
expect(writeDenial("/Volumes/Archive/db.sqlite", extended)).toContain("outside the paths");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listDirectory", () => {
|
||||
let home: string;
|
||||
let policy: PathPolicy;
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await makeHome("lam-list-");
|
||||
policy = createPolicy({ home });
|
||||
await mkdir(path.join(home, "Downloads", "reports"), { recursive: true });
|
||||
await writeFile(path.join(home, "Downloads", "report.md"), "hello\n");
|
||||
await writeFile(path.join(home, "Downloads", ".hidden"), "x\n");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("lists directories first, then files, hiding dotfiles by default", async () => {
|
||||
const result = await listDirectory(path.join(home, "Downloads"), policy);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
expect(result.content).toContain("reports/ (directory)");
|
||||
expect(result.content).toContain("report.md (");
|
||||
expect(result.content).not.toContain(".hidden");
|
||||
expect(result.content.indexOf("reports/")).toBeLessThan(result.content.indexOf("report.md"));
|
||||
});
|
||||
|
||||
it("includes dotfiles when asked", async () => {
|
||||
const result = await listDirectory(path.join(home, "Downloads"), policy, { hidden: true });
|
||||
expect(result.ok && result.content).toContain(".hidden");
|
||||
});
|
||||
|
||||
it("refuses a directory outside the read roots", async () => {
|
||||
const result = await listDirectory("/etc", policy);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.refused).toBe("policy");
|
||||
expect(result.content).toContain("outside the paths");
|
||||
});
|
||||
|
||||
it("refuses a credential directory", async () => {
|
||||
const result = await listDirectory(path.join(home, ".ssh"), policy);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.content).toContain("credentials");
|
||||
});
|
||||
|
||||
it("says so when the path is a file", async () => {
|
||||
const result = await listDirectory(path.join(home, "Downloads", "report.md"), policy);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.content).toContain("read_file");
|
||||
});
|
||||
|
||||
it("states the cap instead of truncating silently", async () => {
|
||||
const many = path.join(home, "Downloads", "many");
|
||||
await mkdir(many, { recursive: true });
|
||||
await Promise.all(
|
||||
Array.from({ length: 210 }, (_, index) => writeFile(path.join(many, `f${index}.txt`), "x")),
|
||||
);
|
||||
|
||||
const result = await listDirectory(many, policy);
|
||||
expect(result.ok && result.content).toContain("and 10 more");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readTextFile", () => {
|
||||
let home: string;
|
||||
let policy: PathPolicy;
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await makeHome("lam-read-");
|
||||
policy = createPolicy({ home });
|
||||
await writeFile(
|
||||
path.join(home, "lines.txt"),
|
||||
Array.from({ length: 300 }, (_, index) => `line ${index + 1}`).join("\n"),
|
||||
);
|
||||
await writeFile(path.join(home, "binary.bin"), Buffer.from([0x00, 0x01, 0x02, 0x00]));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("numbers the lines it returns", async () => {
|
||||
const result = await readTextFile({ path: path.join(home, "lines.txt") }, policy);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
expect(result.content).toContain("1\tline 1");
|
||||
expect(result.content).toContain("200\tline 200");
|
||||
expect(result.content).not.toContain("line 201");
|
||||
// The reader has to be able to tell there is more, and where to continue.
|
||||
expect(result.content).toContain("of 300");
|
||||
expect(result.content).toContain("continue with offset 201");
|
||||
});
|
||||
|
||||
it("continues from an offset", async () => {
|
||||
const result = await readTextFile({ path: path.join(home, "lines.txt"), offset: 201, limit: 10 }, policy);
|
||||
expect(result.ok && result.content).toContain("201\tline 201");
|
||||
expect(result.ok && result.content).not.toContain("211\tline 211");
|
||||
});
|
||||
|
||||
it("refuses a binary file with its size rather than returning mojibake", async () => {
|
||||
const result = await readTextFile({ path: path.join(home, "binary.bin") }, policy);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.content).toContain("binary");
|
||||
expect(result.content).toContain("bytes");
|
||||
});
|
||||
|
||||
it("reports a missing file as a missing file", async () => {
|
||||
const result = await readTextFile({ path: path.join(home, "nope.txt") }, policy);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.refused).toBe("io");
|
||||
expect(result.content).toContain("nope.txt");
|
||||
});
|
||||
|
||||
it("refuses a credential file", async () => {
|
||||
const result = await readTextFile({ path: path.join(home, ".ssh", "id_rsa") }, policy);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.refused).toBe("policy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchFiles", () => {
|
||||
let home: string;
|
||||
let policy: PathPolicy;
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await makeHome("lam-search-");
|
||||
policy = createPolicy({ home });
|
||||
await mkdir(path.join(home, "Downloads", "deep", "deeper"), { recursive: true });
|
||||
await writeFile(path.join(home, "Downloads", "notes.md"), "the deploy token is here\n");
|
||||
await writeFile(path.join(home, "Downloads", "other.txt"), "nothing to see\n");
|
||||
await writeFile(path.join(home, "Downloads", "deep", "deeper", "notes.md"), "x\n");
|
||||
await mkdir(path.join(home, ".ssh"), { recursive: true });
|
||||
await writeFile(path.join(home, ".ssh", "notes.md"), "id_rsa\n");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("finds files by a name pattern", async () => {
|
||||
const result = await searchFiles({ path: path.join(home, "Downloads"), pattern: "*.md" }, policy);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
expect(result.content).toContain("notes.md");
|
||||
expect(result.content).not.toContain("other.txt");
|
||||
});
|
||||
|
||||
it("matches content when asked, and only returns the files that match", async () => {
|
||||
const result = await searchFiles(
|
||||
{ path: path.join(home, "Downloads"), pattern: "*", contains: "deploy token" },
|
||||
policy,
|
||||
);
|
||||
expect(result.ok && result.content).toContain("notes.md");
|
||||
expect(result.ok && result.content).not.toContain("other.txt");
|
||||
});
|
||||
|
||||
it("never returns a credential store, even by name", async () => {
|
||||
const result = await searchFiles({ path: home, pattern: "notes.md" }, policy);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
expect(result.content).not.toContain(".ssh");
|
||||
expect(result.content).toContain("Downloads/notes.md");
|
||||
});
|
||||
|
||||
it("treats a pattern with no regular-expression meaning as a literal", async () => {
|
||||
const result = await searchFiles({ path: path.join(home, "Downloads"), pattern: "notes.md" }, policy);
|
||||
expect(result.ok && result.content).toContain("notes.md");
|
||||
});
|
||||
|
||||
it("says nothing was found rather than failing", async () => {
|
||||
const result = await searchFiles({ path: path.join(home, "Downloads"), pattern: "*.pdf" }, policy);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.ok && result.content).toContain("No files");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the mutating operations", () => {
|
||||
let home: string;
|
||||
let policy: PathPolicy;
|
||||
let asked: ApprovalRequest[];
|
||||
const allow = async (request: ApprovalRequest) => {
|
||||
asked.push(request);
|
||||
return true;
|
||||
};
|
||||
const deny = async (request: ApprovalRequest) => {
|
||||
asked.push(request);
|
||||
return false;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await makeHome("lam-write-");
|
||||
policy = createPolicy({ home });
|
||||
asked = [];
|
||||
await mkdir(path.join(home, "Downloads"), { recursive: true });
|
||||
await writeFile(path.join(home, "Downloads", "report.md"), "one\ntwo\nthree\n");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates a file after approval and says what it wrote", async () => {
|
||||
const result = await writeTextFile(
|
||||
{ path: path.join(home, "Downloads", "new.md"), content: "hello\n" },
|
||||
policy,
|
||||
allow,
|
||||
);
|
||||
|
||||
expect(result.ok && result.content).toContain("Wrote");
|
||||
expect(asked).toHaveLength(1);
|
||||
expect(asked[0].operation).toBe("create");
|
||||
expect(asked[0].details.join(" ")).toContain("New content is 6 bytes, 2 lines");
|
||||
expect(await readFile(path.join(home, "Downloads", "new.md"), "utf8")).toBe("hello\n");
|
||||
});
|
||||
|
||||
it("discloses what an overwrite replaces", async () => {
|
||||
await writeTextFile(
|
||||
{ path: path.join(home, "Downloads", "report.md"), content: "replaced\n" },
|
||||
policy,
|
||||
allow,
|
||||
);
|
||||
expect(asked[0].operation).toBe("overwrite");
|
||||
expect(asked[0].details.join(" ")).toContain("Replaces 14 bytes");
|
||||
});
|
||||
|
||||
it("creates missing folders, and says so before it does", async () => {
|
||||
await writeTextFile(
|
||||
{ path: path.join(home, "Downloads", "reports", "may.md"), content: "x\n" },
|
||||
policy,
|
||||
allow,
|
||||
);
|
||||
expect(asked[0].details.join(" ")).toContain("Creates the folder");
|
||||
});
|
||||
|
||||
it("writes nothing when the user declines", async () => {
|
||||
const result = await writeTextFile(
|
||||
{ path: path.join(home, "Downloads", "new.md"), content: "hello\n" },
|
||||
policy,
|
||||
deny,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.refused).toBe("approval");
|
||||
expect(result.content).toContain("declined");
|
||||
await expect(readFile(path.join(home, "Downloads", "new.md"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("never asks about a path outside the write roots", async () => {
|
||||
const result = await writeTextFile(
|
||||
{ path: path.join(home, "Documents", "taxes.md"), content: "x" },
|
||||
policy,
|
||||
allow,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(asked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("edits a unique occurrence and reports the count", async () => {
|
||||
const result = await editTextFile(
|
||||
{ path: path.join(home, "Downloads", "report.md"), oldString: "two", newString: "TWO" },
|
||||
policy,
|
||||
allow,
|
||||
);
|
||||
|
||||
expect(result.ok && result.content).toContain("1 occurrence");
|
||||
expect(await readFile(path.join(home, "Downloads", "report.md"), "utf8")).toContain("TWO");
|
||||
});
|
||||
|
||||
it("shows the substitution in the approval", async () => {
|
||||
await editTextFile(
|
||||
{ path: path.join(home, "Downloads", "report.md"), oldString: "two", newString: "TWO" },
|
||||
policy,
|
||||
allow,
|
||||
);
|
||||
|
||||
expect(asked[0].details.join(" ")).toContain("- two");
|
||||
expect(asked[0].details.join(" ")).toContain("+ TWO");
|
||||
});
|
||||
|
||||
it("refuses an ambiguous edit rather than guessing which one", async () => {
|
||||
await writeFile(path.join(home, "Downloads", "twice.md"), "same\nsame\n");
|
||||
const result = await editTextFile(
|
||||
{ path: path.join(home, "Downloads", "twice.md"), oldString: "same", newString: "other" },
|
||||
policy,
|
||||
allow,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.content).toContain("2 times");
|
||||
expect(result.content).toContain("all");
|
||||
expect(asked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("replaces every occurrence when asked to", async () => {
|
||||
await writeFile(path.join(home, "Downloads", "twice.md"), "same\nsame\n");
|
||||
const result = await editTextFile(
|
||||
{
|
||||
path: path.join(home, "Downloads", "twice.md"),
|
||||
oldString: "same",
|
||||
newString: "other",
|
||||
all: true,
|
||||
},
|
||||
policy,
|
||||
allow,
|
||||
);
|
||||
expect(result.ok && result.content).toContain("2 occurrences");
|
||||
});
|
||||
|
||||
it("refuses an edit whose text is not there", async () => {
|
||||
const result = await editTextFile(
|
||||
{ path: path.join(home, "Downloads", "report.md"), oldString: "absent", newString: "x" },
|
||||
policy,
|
||||
allow,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.content).toContain("does not contain");
|
||||
});
|
||||
|
||||
it("deletes a file after approval, and says it cannot be undone", async () => {
|
||||
const result = await deletePath(path.join(home, "Downloads", "report.md"), policy, allow);
|
||||
|
||||
expect(result.ok && result.content).toContain("Deleted");
|
||||
expect(asked[0].operation).toBe("delete");
|
||||
expect(asked[0].irreversible).toBe(true);
|
||||
expect(asked[0].details.join(" ")).toContain("cannot be undone");
|
||||
await expect(readFile(path.join(home, "Downloads", "report.md"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("refuses a directory that has anything in it", async () => {
|
||||
await mkdir(path.join(home, "Downloads", "stuff"));
|
||||
await writeFile(path.join(home, "Downloads", "stuff", "a.md"), "x");
|
||||
|
||||
const result = await deletePath(path.join(home, "Downloads", "stuff"), policy, allow);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.content).toContain("1 entry");
|
||||
expect(result.content).toContain("recursively");
|
||||
expect(asked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("deletes an empty directory", async () => {
|
||||
await mkdir(path.join(home, "Downloads", "empty"));
|
||||
const result = await deletePath(path.join(home, "Downloads", "empty"), policy, allow);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("moves a file and names both ends in the request", async () => {
|
||||
const result = await movePath(
|
||||
{
|
||||
from: path.join(home, "Downloads", "report.md"),
|
||||
to: path.join(home, "Downloads", "renamed.md"),
|
||||
},
|
||||
policy,
|
||||
allow,
|
||||
);
|
||||
|
||||
expect(result.ok && result.content).toContain("Moved");
|
||||
expect(asked[0].subject).toBe("report.md to renamed.md");
|
||||
expect(asked[0].irreversible).toBe(true);
|
||||
expect(await readFile(path.join(home, "Downloads", "renamed.md"), "utf8")).toContain("one");
|
||||
});
|
||||
|
||||
it("refuses to move onto an existing path", async () => {
|
||||
await writeFile(path.join(home, "Downloads", "other.md"), "keep me\n");
|
||||
const result = await movePath(
|
||||
{
|
||||
from: path.join(home, "Downloads", "report.md"),
|
||||
to: path.join(home, "Downloads", "other.md"),
|
||||
},
|
||||
policy,
|
||||
allow,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.content).toContain("already exists");
|
||||
expect(await readFile(path.join(home, "Downloads", "other.md"), "utf8")).toBe("keep me\n");
|
||||
});
|
||||
|
||||
it("refuses to move into a path it may not write", async () => {
|
||||
const result = await movePath(
|
||||
{
|
||||
from: path.join(home, "Downloads", "report.md"),
|
||||
to: path.join(home, "Documents", "report.md"),
|
||||
},
|
||||
policy,
|
||||
allow,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(asked).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __resetLocalStorage } from "../__mocks__/@vicinae/api";
|
||||
import { GEO_TTL_MS, buildPlace, forgetGeoPlace, ipPlace } from "../lib/geo";
|
||||
|
||||
/**
|
||||
* The only code in the extension that sends anything about the user to a third
|
||||
* party, so the parts worth pinning down are that it caches (rather than leaking
|
||||
* the address on every question), that it never throws, and that a failure is
|
||||
* reported instead of being served as a coarser answer.
|
||||
*/
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function answer(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** A real ipwho.is payload, trimmed. Note the nested timezone and `country`. */
|
||||
const CEBU = {
|
||||
success: true,
|
||||
city: "Cebu City",
|
||||
region: "Central Visayas",
|
||||
country: "Philippines",
|
||||
latitude: 10.3167,
|
||||
longitude: 123.8907,
|
||||
timezone: { id: "Asia/Manila", utc: "+08:00" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
__resetLocalStorage();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("buildPlace", () => {
|
||||
it("joins the parts that exist", () => {
|
||||
expect(buildPlace({ city: "Cebu City", region: "Central Visayas", country: "Philippines" })).toBe(
|
||||
"Cebu City, Central Visayas, Philippines",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops repeats, which providers return for city-states", () => {
|
||||
// "Singapore, Singapore, Singapore" reads like a bug rather than a place.
|
||||
expect(buildPlace({ city: "Singapore", region: "Singapore", country: "Singapore" })).toBe("Singapore");
|
||||
});
|
||||
|
||||
it("ignores repeats that differ only in case", () => {
|
||||
expect(buildPlace({ city: "Manila", region: "manila" })).toBe("Manila");
|
||||
});
|
||||
|
||||
it("drops empty and whitespace parts", () => {
|
||||
expect(buildPlace({ city: " ", region: "", country: "Philippines" })).toBe("Philippines");
|
||||
});
|
||||
|
||||
it("returns nothing when there is nothing", () => {
|
||||
expect(buildPlace({})).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ipPlace", () => {
|
||||
it("resolves a place from a provider answer", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status !== "ok") return;
|
||||
|
||||
expect(result.place.place).toBe("Cebu City, Central Visayas, Philippines");
|
||||
expect(result.place.latitude).toBe(10.3167);
|
||||
expect(result.place.timezone).toBe("Asia/Manila");
|
||||
expect(result.place.source).toBe("ipwho.is");
|
||||
});
|
||||
|
||||
it("names itself to the provider rather than arriving anonymously", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://ipwho.is/");
|
||||
expect(init.headers.Accept).toBe("application/json");
|
||||
expect(init.headers["User-Agent"]).toContain("lam-agent");
|
||||
});
|
||||
|
||||
it("reads a timezone handed back as a plain string too", async () => {
|
||||
// Providers differ here: ipwho.is nests it, others return a bare zone name.
|
||||
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, timezone: "Asia/Manila" }));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result.status === "ok" && result.place.timezone).toBe("Asia/Manila");
|
||||
});
|
||||
|
||||
it("serves a repeat from the cache instead of re-sending the address", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
|
||||
await ipPlace({ now: 1_000 });
|
||||
const second = await ipPlace({ now: 2_000 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(second.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("re-fetches once the cached place is stale", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" }));
|
||||
const later = await ipPlace({ now: 1_000 + GEO_TTL_MS + 1 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(later.status === "ok" && later.place.city).toBe("Davao City");
|
||||
});
|
||||
|
||||
it("treats exactly the TTL as still fresh", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
await ipPlace({ now: 1_000 + GEO_TTL_MS });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores the cache when asked to refresh", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" }));
|
||||
const refreshed = await ipPlace({ now: 1_001, refresh: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(refreshed.status === "ok" && refreshed.place.city).toBe("Davao City");
|
||||
});
|
||||
|
||||
it("reports a rate limit by status rather than pretending to know the place", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("slow down", { status: 429 }));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result).toEqual({ status: "failed", message: "ipwho.is answered HTTP 429" });
|
||||
});
|
||||
|
||||
it("reports a provider that refuses in the body while answering 200", async () => {
|
||||
// ipwho.is signals failure with success: false rather than a status code,
|
||||
// so checking the status alone would read a refusal as a success.
|
||||
fetchMock.mockResolvedValueOnce(answer({ success: false, message: "Reserved range" }));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result).toEqual({ status: "failed", message: "Reserved range" });
|
||||
});
|
||||
|
||||
it("reports a body that is not JSON", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("<html>nope</html>", { status: 200 }));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("reports an answer with no usable place", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer({ success: true, city: null, region: null, country: null }));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result).toEqual({ status: "failed", message: "ipwho.is returned no usable place" });
|
||||
});
|
||||
|
||||
it("survives the network being down without throwing", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("offline"));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("does not cache a failure", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("offline"));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
const second = await ipPlace({ now: 1_001 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(second.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("ignores a corrupt cache entry rather than failing", async () => {
|
||||
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
|
||||
await LocalStorage.setItem("lam.geo", "{not json");
|
||||
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
|
||||
await expect(ipPlace({ now: 1_000 })).resolves.toMatchObject({ status: "ok" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("forgetGeoPlace", () => {
|
||||
it("drops the cached place so a move is not pinned for hours", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
await forgetGeoPlace();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" }));
|
||||
const after = await ipPlace({ now: 1_001 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(after.status === "ok" && after.place.city).toBe("Davao City");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SYSTEM_PROMPT, RECENCY_HINT } from "../lib/constants";
|
||||
import { buildMessages, composeSystemPrompt } from "../lib/history";
|
||||
import { buildMessages, composeSystemPrompt, describeRuntime, type RuntimeContext } 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: [], attachments: [] };
|
||||
return {
|
||||
id: prompt,
|
||||
prompt,
|
||||
response,
|
||||
thinking: "",
|
||||
model: "m",
|
||||
createdAt: 0,
|
||||
toolCalls: [],
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildMessages", () => {
|
||||
@@ -52,18 +61,97 @@ describe("buildMessages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** The setup a test is composing for: the user's own machine, which is the case that exposed the gaps. */
|
||||
function context(overrides: Partial<RuntimeContext> = {}): RuntimeContext {
|
||||
return {
|
||||
model: "ornith-1.5:9b",
|
||||
host: "http://localhost:11434",
|
||||
selfHosted: true,
|
||||
webTools: true,
|
||||
vision: "yes",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("composeSystemPrompt", () => {
|
||||
it("uses the default prompt when the user has not set one", () => {
|
||||
expect(composeSystemPrompt("", false)).toBe(DEFAULT_SYSTEM_PROMPT);
|
||||
expect(composeSystemPrompt("", context())).toContain(DEFAULT_SYSTEM_PROMPT);
|
||||
});
|
||||
|
||||
it("prefers the user's prompt when set", () => {
|
||||
expect(composeSystemPrompt("Be terse.", false)).toBe("Be terse.");
|
||||
it("prefers the user's prompt when set, and still says what the model is", () => {
|
||||
// The runtime facts are not a matter of style, so a custom prompt must not
|
||||
// drop them. This is the case that would otherwise silently lose them.
|
||||
const composed = composeSystemPrompt("Be terse.", context());
|
||||
|
||||
expect(composed).toContain("Be terse.");
|
||||
expect(composed).not.toContain(DEFAULT_SYSTEM_PROMPT);
|
||||
expect(composed).toContain("You are Lam");
|
||||
});
|
||||
|
||||
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);
|
||||
expect(composeSystemPrompt("Be terse.", context({ webTools: true }))).toContain(RECENCY_HINT);
|
||||
expect(composeSystemPrompt("Be terse.", context({ webTools: false }))).not.toContain(RECENCY_HINT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeRuntime", () => {
|
||||
it("names the model and the server for a local setup", () => {
|
||||
const text = describeRuntime(context());
|
||||
|
||||
expect(text).toContain("`ornith-1.5:9b`");
|
||||
expect(text).toContain("http://localhost:11434");
|
||||
expect(text).toContain("reachable on this machine");
|
||||
});
|
||||
|
||||
it("says Ollama Cloud when that is the host", () => {
|
||||
const text = describeRuntime(
|
||||
context({ host: "https://ollama.com", selfHosted: false, model: "gpt-oss:120b" }),
|
||||
);
|
||||
|
||||
expect(text).toContain("Ollama Cloud");
|
||||
expect(text).not.toContain("reachable on this machine");
|
||||
});
|
||||
|
||||
it("explains the :cloud convention only where it applies", () => {
|
||||
// A local daemon proxying a cloud model is the case where a model that
|
||||
// assumed it was local would tell the user their conversation stays put.
|
||||
expect(describeRuntime(context({ selfHosted: true }))).toContain("`:cloud`");
|
||||
expect(describeRuntime(context({ selfHosted: false }))).not.toContain("leaves this machine");
|
||||
});
|
||||
|
||||
it("tells it to say so when there are no web tools, rather than offering", () => {
|
||||
expect(describeRuntime(context({ webTools: true }))).toContain("You have `web_search`");
|
||||
expect(describeRuntime(context({ webTools: false }))).toContain("no web tools in this session");
|
||||
});
|
||||
|
||||
it("uses the confirmed answer about images when there is one", () => {
|
||||
expect(describeRuntime(context({ vision: "yes" }))).toContain("You can read images");
|
||||
expect(describeRuntime(context({ vision: "no" }))).toContain("You cannot read images");
|
||||
});
|
||||
|
||||
it("tells a model whose eyes are unconfirmed to describe what it sees rather than reason about it", () => {
|
||||
// The reported failure: handed a screenshot and an unconfirmed answer, the
|
||||
// model wrote two paragraphs about whether it could see instead of looking.
|
||||
const text = describeRuntime(context({ vision: "unknown" }));
|
||||
|
||||
expect(text).toContain("has not been confirmed");
|
||||
expect(text).toContain("describe what you see");
|
||||
});
|
||||
|
||||
it("always denies access it does not have", () => {
|
||||
const text = describeRuntime(context());
|
||||
|
||||
expect(text).toContain("You see only what the user types or attaches");
|
||||
expect(text).toContain("cannot read their screen, files, clipboard");
|
||||
});
|
||||
|
||||
it("never claims the model is Vicinae, which was the actual confusion", () => {
|
||||
// The reported answer: "Vicinae is a desktop launcher for Ollama". Vicinae is
|
||||
// the launcher; Ollama is what serves the model, and they are not the same.
|
||||
const text = describeRuntime(context());
|
||||
|
||||
expect(text).toContain("running inside the Vicinae launcher");
|
||||
expect(text).not.toMatch(/You are Vicinae/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,3 +188,46 @@ describe("buildMessages images", () => {
|
||||
expect(messages.some((message) => message.images !== undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the runtime paragraph and file access", () => {
|
||||
const context = (overrides: Partial<RuntimeContext> = {}): RuntimeContext => ({
|
||||
model: "gpt-oss:120b",
|
||||
host: "https://ollama.com",
|
||||
selfHosted: false,
|
||||
webTools: true,
|
||||
vision: "yes",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("still says Lam cannot read files when file access is off", () => {
|
||||
expect(describeRuntime(context())).toContain("You cannot read their screen, files");
|
||||
});
|
||||
|
||||
it("names the tools, the roots and the approval rule when it is on", () => {
|
||||
const paragraph = describeRuntime(
|
||||
context({
|
||||
fileTools: true,
|
||||
fileRoots: {
|
||||
read: ["~", "/Volumes/Archive"],
|
||||
write: ["~/Downloads", "~/Documents/Lam Agent"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(paragraph).toContain("read_file");
|
||||
expect(paragraph).toContain("/Volumes/Archive");
|
||||
expect(paragraph).toContain("~/Documents/Lam Agent");
|
||||
expect(paragraph).toContain("~/Downloads and ~/Documents/Lam Agent");
|
||||
expect(paragraph).toContain("approved");
|
||||
// The one thing the dialog cannot tell the user on the model's behalf.
|
||||
expect(paragraph).toContain("sent to the model provider");
|
||||
expect(paragraph).not.toContain("You cannot read their screen, files");
|
||||
});
|
||||
|
||||
it("reads two roots as a sentence rather than as a list", () => {
|
||||
const paragraph = describeRuntime(
|
||||
context({ fileTools: true, fileRoots: { read: ["~"], write: ["~/Downloads"] } }),
|
||||
);
|
||||
expect(paragraph).toContain("You may read ~ and change ~/Downloads.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeInlineHtml } from "../lib/html";
|
||||
|
||||
describe("sanitizeInlineHtml", () => {
|
||||
it("keeps the tags a rich text paragraph draws", () => {
|
||||
expect(sanitizeInlineHtml("<b>bold</b> and <i>italic</i>")).toBe("<b>bold</b> and <i>italic</i>");
|
||||
expect(sanitizeInlineHtml("x<sup>2</sup> and a<sub>i</sub>")).toBe("x<sup>2</sup> and a<sub>i</sub>");
|
||||
});
|
||||
|
||||
it("keeps a style declaration it can use and drops the rest", () => {
|
||||
const input = '<span style="color:#ff0000;font-weight:bold;position:absolute">x</span>';
|
||||
expect(sanitizeInlineHtml(input)).toBe('<span style="color:#ff0000;font-weight:bold">x</span>');
|
||||
});
|
||||
|
||||
it("drops a style value that reaches outside the document", () => {
|
||||
expect(sanitizeInlineHtml('<span style="background:url(https://x/y.png)">x</span>')).toBe(
|
||||
"<span>x</span>",
|
||||
);
|
||||
expect(sanitizeInlineHtml('<span style="background:#fff">x</span>')).toBe(
|
||||
'<span style="background:#fff">x</span>',
|
||||
);
|
||||
});
|
||||
|
||||
it("drops an event handler", () => {
|
||||
expect(sanitizeInlineHtml('<span onclick="steal()">x</span>')).toBe("<span>x</span>");
|
||||
});
|
||||
|
||||
it("removes a script with the code inside it", () => {
|
||||
expect(sanitizeInlineHtml("<script>alert(1)</script>ok")).toBe("ok");
|
||||
expect(sanitizeInlineHtml("<style>p{color:red}</style>ok")).toBe("ok");
|
||||
expect(sanitizeInlineHtml('<iframe src="https://evil.example"></iframe>ok')).toBe("ok");
|
||||
expect(sanitizeInlineHtml("<script>alert(1)")).toBe("");
|
||||
});
|
||||
|
||||
it("removes an element with no content of its own, and only the tag", () => {
|
||||
expect(sanitizeInlineHtml("<input type=text>still here")).toBe("still here");
|
||||
expect(sanitizeInlineHtml('<meta charset="utf-8">still here')).toBe("still here");
|
||||
expect(sanitizeInlineHtml("a<br>b")).toBe("a<br>b");
|
||||
});
|
||||
|
||||
it("shows a tag it cannot draw rather than swallowing it", () => {
|
||||
expect(sanitizeInlineHtml("<foo>bar</foo>")).toBe("<foo>bar</foo>");
|
||||
});
|
||||
|
||||
it("turns an anchor into the one form that is clickable", () => {
|
||||
expect(sanitizeInlineHtml('See <a href="https://example.com">the docs</a>.')).toBe(
|
||||
"See [the docs](https://example.com).",
|
||||
);
|
||||
// The destination keeps its query string rather than arriving as `&`.
|
||||
expect(sanitizeInlineHtml('<a href="https://x/?a=1&b=2">q</a>')).toBe("[q](https://x/?a=1&b=2)");
|
||||
});
|
||||
|
||||
it("keeps the text of an anchor that points somewhere unsafe", () => {
|
||||
expect(sanitizeInlineHtml('<a href="javascript:alert(1)">click</a>')).toBe("click");
|
||||
});
|
||||
|
||||
it("filters an image source and leaves the tag itself where it is", () => {
|
||||
// The one place an image written as HTML is loaded for real is the extractor
|
||||
// for an HTML block, so the tag stays and only its source is judged.
|
||||
expect(sanitizeInlineHtml('<img src="https://x/y.png" alt="a chart">')).toBe(
|
||||
'<img src="https://x/y.png" alt="a chart">',
|
||||
);
|
||||
expect(sanitizeInlineHtml('<img src="file:///tmp/a.png">')).toBe('<img src="file:///tmp/a.png">');
|
||||
expect(sanitizeInlineHtml('<img src="javascript:alert(1)">')).toBe("<img>");
|
||||
expect(sanitizeInlineHtml('<img src="data:image/svg+xml;base64,PHN2Zy8+">')).toBe(
|
||||
'<img src="data:image/svg+xml;base64,PHN2Zy8+">',
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a backtick span exactly as written", () => {
|
||||
expect(sanitizeInlineHtml("Use `<b>bold</b>` for that.")).toBe("Use `<b>bold</b>` for that.");
|
||||
});
|
||||
|
||||
it("removes a comment", () => {
|
||||
expect(sanitizeInlineHtml("a<!-- hidden -->b")).toBe("ab");
|
||||
});
|
||||
|
||||
it("leaves a less-than sign that is not a tag alone", () => {
|
||||
expect(sanitizeInlineHtml("a < b and 3>2")).toBe("a < b and 3>2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeMath, renderLatex } from "../lib/latex";
|
||||
|
||||
describe("renderLatex", () => {
|
||||
it("prints a command as the character it names", () => {
|
||||
expect(renderLatex("\\alpha + \\beta \\leq \\infty")).toBe("α + β ≤ ∞");
|
||||
expect(renderLatex("\\times \\pm \\cdot \\to")).toBe("× ± · →");
|
||||
});
|
||||
|
||||
it("uses a Unicode script where one exists", () => {
|
||||
expect(renderLatex("x^2")).toBe("x²");
|
||||
expect(renderLatex("x^{n+1}")).toBe("xⁿ⁺¹");
|
||||
expect(renderLatex("a_1")).toBe("a₁");
|
||||
expect(renderLatex("\\sum_{i=1}^{n}")).toBe("∑ᵢ₌₁ⁿ");
|
||||
});
|
||||
|
||||
it("escapes a vertical bar, which would otherwise end a table cell", () => {
|
||||
expect(renderLatex("\\{x \\mid x > 0\\}")).toBe("{x \\| x \\> 0}");
|
||||
});
|
||||
|
||||
it("falls back to a tag where Unicode has no script form", () => {
|
||||
expect(renderLatex("x^{a/b}")).toBe("x<sup>a/b</sup>");
|
||||
expect(renderLatex("a_{i,j}")).toBe("a<sub>i,j</sub>");
|
||||
expect(renderLatex("\\int_{0}^{\\infty}")).toBe("∫₀<sup>∞</sup>");
|
||||
});
|
||||
|
||||
it("linearises a fraction and brackets only what the slash would split", () => {
|
||||
expect(renderLatex("\\frac{1}{2}")).toBe("1/2");
|
||||
expect(renderLatex("\\frac{a}{b}")).toBe("a/b");
|
||||
expect(renderLatex("\\frac{a+b}{c}")).toBe("(a+b) / c");
|
||||
});
|
||||
|
||||
it("leaves a numerator that is one product unbracketed", () => {
|
||||
// `n(n+1)/2` is unambiguous. Asking whether the operand was a single token
|
||||
// instead produced `(n(n+1))/2`, which is a wall of parentheses.
|
||||
expect(renderLatex("\\frac{n(n+1)}{2}")).toBe("n(n+1)/2");
|
||||
expect(renderLatex("\\frac{\\sqrt{\\pi}}{2}")).toBe("√π/2");
|
||||
expect(renderLatex("E = \\frac{mc^2}{\\sqrt{1 - v^2/c^2}}")).toBe("E = mc²/√(1 − v²/c²)");
|
||||
});
|
||||
|
||||
it("brackets a denominator of more than one term, because 1/2a is read two ways", () => {
|
||||
expect(renderLatex("\\frac{1}{2a}")).toBe("1 / (2a)");
|
||||
expect(renderLatex("\\frac{\\pi}{2}")).toBe("π/2");
|
||||
});
|
||||
|
||||
it("brackets a sum so the slash cannot divide only its last term", () => {
|
||||
expect(renderLatex("\\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}")).toBe("(−b ± √(b² − 4ac)) / (2a)");
|
||||
expect(renderLatex("\\frac{\\sin x}{x}")).toBe("(sin x) / x");
|
||||
});
|
||||
|
||||
it("reads through a script rather than counting it as a split", () => {
|
||||
// The sum is above the line by construction, so it needs no brackets. A
|
||||
// script with no Unicode form is what puts a tag in the way of the scan.
|
||||
expect(renderLatex("\\frac{x^{a/b}}{y}")).toBe("x<sup>a/b</sup>/y");
|
||||
expect(renderLatex("\\frac{x^{a+b}}{y}")).toBe("xᵃ⁺ᵇ/y");
|
||||
});
|
||||
|
||||
it("renders a root, using the root sign for the degree it has", () => {
|
||||
expect(renderLatex("\\sqrt{x}")).toBe("√x");
|
||||
expect(renderLatex("\\sqrt{x+1}")).toBe("√(x+1)");
|
||||
expect(renderLatex("\\sqrt[3]{x}")).toBe("∛x");
|
||||
});
|
||||
|
||||
it("applies an accent as a combining mark", () => {
|
||||
expect(renderLatex("\\hat{x}")).toBe("x\u0302");
|
||||
expect(renderLatex("\\bar{y}")).toBe("y\u0304");
|
||||
expect(renderLatex("\\vec{v}")).toBe("v\u20d7");
|
||||
});
|
||||
|
||||
it("keeps prose inside a text command as prose", () => {
|
||||
expect(renderLatex("\\text{hello world}")).toBe("hello world");
|
||||
// `_` is markdown emphasis, so it is escaped even in the middle of prose.
|
||||
expect(renderLatex("\\text{a_b}")).toBe("a\\_b");
|
||||
});
|
||||
|
||||
it("uses the blackboard and script capitals Unicode has", () => {
|
||||
expect(renderLatex("\\mathbb{R}")).toBe("ℝ");
|
||||
expect(renderLatex("\\mathcal{L}")).toBe("ℒ");
|
||||
expect(renderLatex("\\mathbf{v}")).toBe("<b>v</b>");
|
||||
});
|
||||
|
||||
it("keeps an unknown command readable rather than dropping it", () => {
|
||||
expect(renderLatex("\\foo{bar}")).toBe("foobar");
|
||||
});
|
||||
|
||||
it("escapes what the markdown parser would otherwise read", () => {
|
||||
expect(renderLatex("a < b")).toBe("a \\< b");
|
||||
expect(renderLatex("|x|")).toBe("\\|x\\|");
|
||||
});
|
||||
|
||||
it("breaks a line only in display maths", () => {
|
||||
expect(renderLatex("a\\\\b", { display: true })).toBe("a<br/>b");
|
||||
expect(renderLatex("a\\\\b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("drops an alignment rather than printing the ampersand", () => {
|
||||
expect(renderLatex("\\begin{align} a &= b \\\\ c &= d \\end{align}", { display: true })).toBe(
|
||||
"a = b <br/> c = d",
|
||||
);
|
||||
});
|
||||
|
||||
it("guards a formula that would otherwise start a block", () => {
|
||||
expect(renderLatex("= x", { display: true })).toBe("\\= x");
|
||||
expect(renderLatex("+ 1", { display: true })).toBe("\\+ 1");
|
||||
expect(renderLatex("1. x", { display: true })).toBe("1\\. x");
|
||||
expect(renderLatex("===", { display: true })).toBe("\\===");
|
||||
});
|
||||
|
||||
it("leaves a number that only looks like a marker alone", () => {
|
||||
// A minus sign becomes the maths minus, so it can never be read as a list,
|
||||
// and `1.5` is a number rather than an ordered list.
|
||||
expect(renderLatex("-1", { display: true })).toBe("−1");
|
||||
expect(renderLatex("- 1", { display: true })).toBe("− 1");
|
||||
expect(renderLatex("1.5", { display: true })).toBe("1.5");
|
||||
expect(renderLatex("---", { display: true })).toBe("−−−");
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeMath", () => {
|
||||
it("accepts the shapes a model actually writes", () => {
|
||||
for (const body of ["x^2", "a_1", "\\alpha", "E = mc^2", "\\frac{1}{2}", "2+2", "O(n \\log n)"]) {
|
||||
expect(looksLikeMath(body)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects money and a shell variable", () => {
|
||||
for (const body of ["5", "HOME", "PATH", "A; echo B", "a"]) {
|
||||
expect(looksLikeMath(body)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeTimezone, getLocationContext, utcOffsetLabel, weekdayName } from "../lib/location";
|
||||
import {
|
||||
describeTimezoneArea,
|
||||
getLocationContext,
|
||||
localeRegion,
|
||||
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" });
|
||||
describe("describeTimezoneArea", () => {
|
||||
it("keeps the zone's own label", () => {
|
||||
expect(describeTimezoneArea("Asia/Manila")).toEqual({ area: "Manila" });
|
||||
});
|
||||
|
||||
it("handles zones with more than two segments", () => {
|
||||
expect(describeTimezone("America/Argentina/Salta")).toEqual({ region: "America", city: "Salta" });
|
||||
expect(describeTimezoneArea("America/Argentina/Salta")).toEqual({ area: "Salta" });
|
||||
});
|
||||
|
||||
it("underscores become spaces", () => {
|
||||
expect(describeTimezone("America/New_York")).toEqual({ region: "America", city: "New York" });
|
||||
it("turns underscores into spaces", () => {
|
||||
expect(describeTimezoneArea("America/New_York")).toEqual({ area: "New York" });
|
||||
});
|
||||
|
||||
it("drops the continent grouping, which answers a question nobody asked", () => {
|
||||
// "Asia" beside a locale region of "Philippines" reads as two answers to
|
||||
// the same question.
|
||||
expect(describeTimezoneArea("Asia/Manila")).not.toHaveProperty("region");
|
||||
});
|
||||
|
||||
it("returns nothing for a bare zone like UTC", () => {
|
||||
expect(describeTimezone("UTC")).toEqual({});
|
||||
expect(describeTimezoneArea("UTC")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("localeRegion", () => {
|
||||
it("reads the region the user configured, rather than inferring one", () => {
|
||||
expect(localeRegion("en-PH")).toEqual({ code: "PH", name: "Philippines" });
|
||||
});
|
||||
|
||||
it("handles other regions", () => {
|
||||
expect(localeRegion("de-DE")?.name).toBe("Germany");
|
||||
expect(localeRegion("en-US")?.code).toBe("US");
|
||||
});
|
||||
|
||||
it("has nothing to say for a locale with no region", () => {
|
||||
expect(localeRegion("en")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not throw on a malformed locale", () => {
|
||||
expect(localeRegion("not a locale")).toBeUndefined();
|
||||
expect(localeRegion("")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,10 +86,11 @@ describe("getLocationContext", () => {
|
||||
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);
|
||||
// The zone's label is reported as such, never as the user's city.
|
||||
expect(context.timezoneArea).toBe("Manila");
|
||||
expect(context).not.toHaveProperty("city");
|
||||
});
|
||||
|
||||
it("still returns a usable context for an unknown zone", () => {
|
||||
@@ -65,4 +98,15 @@ describe("getLocationContext", () => {
|
||||
expect(context.timezone).toBe("Not/AReal_Zone");
|
||||
expect(context.utcOffset).toBe("UTC");
|
||||
});
|
||||
|
||||
it("carries the configured place through verbatim", () => {
|
||||
const context = getLocationContext(new Date(), "Asia/Manila", " Cebu City, Philippines ");
|
||||
expect(context.place).toBe("Cebu City, Philippines");
|
||||
});
|
||||
|
||||
it("treats an empty or whitespace place as unset", () => {
|
||||
expect(getLocationContext(new Date(), "Asia/Manila", "").place).toBeUndefined();
|
||||
expect(getLocationContext(new Date(), "Asia/Manila", " ").place).toBeUndefined();
|
||||
expect(getLocationContext(new Date(), "Asia/Manila").place).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+377
-11
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
STREAMING_CURSOR,
|
||||
buildThreadMarkdown,
|
||||
quote,
|
||||
sessionToPlainText,
|
||||
} from "../lib/markdown";
|
||||
import { STREAMING_CURSOR, buildThreadMarkdown, quote, sessionToPlainText } from "../lib/markdown";
|
||||
import type { ChatTurn, ThreadMessage } from "../lib/types";
|
||||
|
||||
function message(overrides: Partial<ThreadMessage> & Pick<ThreadMessage, "role">): ThreadMessage {
|
||||
@@ -60,6 +55,16 @@ describe("buildThreadMarkdown", () => {
|
||||
expect(markdown.trim()).toBe("> just a question");
|
||||
});
|
||||
|
||||
it("renders a question through the same pass as an answer", () => {
|
||||
// A person pastes LaTeX into a question as readily as a model writes it, and
|
||||
// a question full of raw HTML deserves the same fence.
|
||||
const math = buildThreadMarkdown([message({ role: "user", text: "what is $x^2$?" })]);
|
||||
expect(math.trim()).toBe("> what is x²?");
|
||||
|
||||
const html = buildThreadMarkdown([message({ role: "user", text: "hi <script>alert(1)</script>" })]);
|
||||
expect(html.trim()).toBe("> hi");
|
||||
});
|
||||
|
||||
it("groups an answer with its question, dividing only between exchanges", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({ id: "t2:assistant", role: "assistant", text: "second answer" }),
|
||||
@@ -116,6 +121,64 @@ describe("buildThreadMarkdown", () => {
|
||||
expect(markdown).not.toContain("Thinking");
|
||||
});
|
||||
|
||||
it("says what a running tool is doing exactly once, before any text arrives", () => {
|
||||
// The reported bug, from a screenshot: the activity line and the answer
|
||||
// placeholder both printed "Reading a page", one directly above the other.
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({
|
||||
role: "assistant",
|
||||
isStreaming: true,
|
||||
text: "",
|
||||
activity: { label: "Reading a page", count: 1 },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(markdown.match(/Reading a page/g)).toHaveLength(1);
|
||||
// With nothing to read yet, the activity line is the stand-in, so the cursor
|
||||
// rides on it rather than sitting on a line of its own.
|
||||
expect(markdown).toContain(`_Reading a page…_ ${STREAMING_CURSOR}`);
|
||||
});
|
||||
|
||||
it("keeps the activity line once the answer has started arriving", () => {
|
||||
// The other half of the fix: a tool that starts mid-answer has to stay on
|
||||
// screen, which is why the line is not just a stand-in for missing text.
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({
|
||||
role: "assistant",
|
||||
isStreaming: true,
|
||||
text: "Here is what I found.",
|
||||
activity: { label: "Reading a page", count: 1 },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(markdown).toContain("_Reading a page…_");
|
||||
expect(markdown).toContain("Here is what I found.");
|
||||
// And the cursor follows the answer, not the activity line.
|
||||
expect(markdown).toContain(`Here is what I found. ${STREAMING_CURSOR}`);
|
||||
expect(markdown).not.toContain(`_Reading a page…_ ${STREAMING_CURSOR}`);
|
||||
});
|
||||
|
||||
it("names a repeat rather than leaving a bare count to be interpreted", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({
|
||||
role: "assistant",
|
||||
isStreaming: true,
|
||||
activity: { label: "Searching the web", count: 2 },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(markdown).toContain("the same call again (2)");
|
||||
});
|
||||
|
||||
it("drops the activity line when the message is no longer streaming", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({ role: "assistant", text: "done", activity: { label: "Searching the web", count: 1 } }),
|
||||
]);
|
||||
|
||||
expect(markdown).not.toContain("Searching the web");
|
||||
expect(markdown).not.toContain(STREAMING_CURSOR);
|
||||
});
|
||||
|
||||
it("shows a placeholder while an answer has no text yet", () => {
|
||||
expect(buildThreadMarkdown([message({ role: "assistant", isStreaming: true })])).toContain("Thinking");
|
||||
expect(buildThreadMarkdown([message({ role: "assistant" })])).toContain("No response");
|
||||
@@ -167,15 +230,13 @@ describe("buildThreadMarkdown", () => {
|
||||
});
|
||||
|
||||
it("links a lone URL the model put in inline code", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({ role: "assistant", text: "`https://example.com`" }),
|
||||
]);
|
||||
const markdown = buildThreadMarkdown([message({ role: "assistant", text: "`https://example.com`" })]);
|
||||
|
||||
expect(markdown).not.toContain("```");
|
||||
expect(markdown).toContain("[https://example.com](https://example.com)");
|
||||
});
|
||||
|
||||
it("lists tools on one quiet line, flagging failures", () => {
|
||||
it("lists tools on one quiet line, each named once, flagging failures", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({
|
||||
role: "assistant",
|
||||
@@ -187,11 +248,50 @@ describe("buildThreadMarkdown", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(markdown).toContain("_Tools: `web_search`, `web_fetch` (failed)_");
|
||||
expect(markdown).toContain("_Tools: `web_search`, `web_fetch` (1 failed)_");
|
||||
// Italic, not a bold header, so it stays below the answer.
|
||||
expect(markdown).not.toContain("**Tools");
|
||||
});
|
||||
|
||||
it("names a tool once however many times it was called", () => {
|
||||
// The reported case: a research turn listed the same two tools twenty times
|
||||
// over three lines, which is a wall of text that says nothing a name does
|
||||
// not. Names in order of first use, no repetition.
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({
|
||||
role: "assistant",
|
||||
text: "a",
|
||||
toolCalls: [
|
||||
{ name: "web_search", arguments: {}, result: "ok", isError: false },
|
||||
{ name: "web_fetch", arguments: {}, result: "ok", isError: false },
|
||||
{ name: "web_search", arguments: {}, result: "ok", isError: false },
|
||||
{ name: "web_search", arguments: {}, result: "ok", isError: false },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(markdown).toContain("_Tools: `web_search`, `web_fetch`_");
|
||||
expect(markdown.match(/web_search/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("counts every failed call, not just the failed tools", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({
|
||||
role: "assistant",
|
||||
text: "a",
|
||||
toolCalls: [
|
||||
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
|
||||
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
|
||||
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
|
||||
{ name: "web_search", arguments: {}, result: "ok", isError: false },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(markdown).toContain("_Tools: `web_fetch`, `web_search` (3 failed)_");
|
||||
expect(markdown.match(/web_fetch/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("omits tools and reasoning when the preferences are off", () => {
|
||||
const messages = [
|
||||
message({
|
||||
@@ -231,12 +331,208 @@ describe("buildThreadMarkdown", () => {
|
||||
expect(reasoningOnly).toContain("Reasoning");
|
||||
});
|
||||
|
||||
it("names a refused repeat, and does not call it a failure", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({
|
||||
role: "assistant",
|
||||
text: "a",
|
||||
toolCalls: [
|
||||
{ name: "web_search", arguments: {}, result: "ok", isError: false },
|
||||
{
|
||||
name: "web_search",
|
||||
arguments: {},
|
||||
result: "Refused",
|
||||
isError: true,
|
||||
repeated: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(markdown).toContain("_Tools: `web_search` (stopped: the same call kept repeating)_");
|
||||
expect(markdown).not.toContain("failed");
|
||||
});
|
||||
|
||||
it("says a call failed and a call repeated, when both happened", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({
|
||||
role: "assistant",
|
||||
text: "a",
|
||||
toolCalls: [
|
||||
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
|
||||
{ name: "web_search", arguments: {}, result: "Refused", isError: true, repeated: true },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(markdown).toContain(
|
||||
"_Tools: `web_fetch`, `web_search` (1 failed, stopped: the same call kept repeating)_",
|
||||
);
|
||||
});
|
||||
|
||||
it("hides a failed call's name, but still says that a call failed", () => {
|
||||
// Off means the list is off, not that a failure goes unmentioned: the answer
|
||||
// alone would not account for a tool that did not work.
|
||||
const markdown = buildThreadMarkdown(
|
||||
[
|
||||
message({
|
||||
role: "assistant",
|
||||
text: "a",
|
||||
toolCalls: [{ name: "web_fetch", arguments: {}, result: "boom", isError: true }],
|
||||
}),
|
||||
],
|
||||
{ showToolCalls: false, showReasoning: false },
|
||||
);
|
||||
|
||||
expect(markdown).toContain("_1 tool call failed._");
|
||||
expect(markdown).not.toContain("web_fetch");
|
||||
expect(markdown).not.toContain("_Tools:");
|
||||
});
|
||||
|
||||
it("counts the failures rather than naming every failed call when hidden", () => {
|
||||
const markdown = buildThreadMarkdown(
|
||||
[
|
||||
message({
|
||||
role: "assistant",
|
||||
text: "a",
|
||||
toolCalls: [
|
||||
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
|
||||
{ name: "web_search", arguments: {}, result: "boom", isError: true },
|
||||
],
|
||||
}),
|
||||
],
|
||||
{ showToolCalls: false, showReasoning: false },
|
||||
);
|
||||
|
||||
expect(markdown).toContain("_2 tool calls failed._");
|
||||
});
|
||||
|
||||
it("says a turn was cut short by a repeat, without naming the call", () => {
|
||||
// Still the one the user most needs to know about: a model going in circles
|
||||
// is exactly the thing a hidden tool list would conceal.
|
||||
const markdown = buildThreadMarkdown(
|
||||
[
|
||||
message({
|
||||
role: "assistant",
|
||||
text: "a",
|
||||
toolCalls: [
|
||||
{ name: "web_search", arguments: {}, result: "Refused", isError: true, repeated: true },
|
||||
],
|
||||
}),
|
||||
],
|
||||
{ showToolCalls: false, showReasoning: false },
|
||||
);
|
||||
|
||||
expect(markdown).toContain("_a call kept repeating, so the turn was cut short._");
|
||||
expect(markdown).not.toContain("web_search");
|
||||
});
|
||||
|
||||
it("says both when a call failed and another kept repeating, with tools hidden", () => {
|
||||
const markdown = buildThreadMarkdown(
|
||||
[
|
||||
message({
|
||||
role: "assistant",
|
||||
text: "a",
|
||||
toolCalls: [
|
||||
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
|
||||
{ name: "web_search", arguments: {}, result: "Refused", isError: true, repeated: true },
|
||||
],
|
||||
}),
|
||||
],
|
||||
{ showToolCalls: false, showReasoning: false },
|
||||
);
|
||||
|
||||
expect(markdown).toContain("_1 tool call failed and a call kept repeating, so the turn was cut short._");
|
||||
});
|
||||
|
||||
it("still hides the ordinary calls when tool calls are off", () => {
|
||||
const markdown = buildThreadMarkdown(
|
||||
[
|
||||
message({
|
||||
role: "assistant",
|
||||
text: "a",
|
||||
toolCalls: [{ name: "get_datetime", arguments: {}, result: "ok", isError: false }],
|
||||
}),
|
||||
],
|
||||
{ showToolCalls: false, showReasoning: false },
|
||||
);
|
||||
|
||||
expect(markdown).not.toContain("_Tools:");
|
||||
});
|
||||
|
||||
it("only shows a reasoning section when there is reasoning", () => {
|
||||
expect(buildThreadMarkdown([message({ role: "assistant", text: "a" })])).not.toContain("Reasoning");
|
||||
expect(buildThreadMarkdown([message({ role: "assistant", text: "a", thinking: "hmm" })])).toContain(
|
||||
"Reasoning",
|
||||
);
|
||||
});
|
||||
|
||||
it("streams the reasoning live even when Show Reasoning is off", () => {
|
||||
// While a turn is in flight the reasoning is the only sign of progress, so
|
||||
// it is shown whatever the preference says. It is the live view only.
|
||||
const markdown = buildThreadMarkdown(
|
||||
[message({ role: "assistant", text: "", isStreaming: true, thinking: "weighing the options" })],
|
||||
{ showToolCalls: false, showReasoning: false },
|
||||
);
|
||||
|
||||
expect(markdown).toContain("weighing the options");
|
||||
expect(markdown).toContain("Reasoning");
|
||||
// The cursor trails the reasoning, because nothing else is arriving.
|
||||
expect(markdown).toContain(`> weighing the options ${STREAMING_CURSOR}`);
|
||||
// And the "Thinking…" stand-in is dropped, since the reasoning says it.
|
||||
expect(markdown).not.toContain("Thinking…");
|
||||
});
|
||||
|
||||
it("drops the reasoning when the turn ends and Show Reasoning is off", () => {
|
||||
const markdown = buildThreadMarkdown(
|
||||
[message({ role: "assistant", text: "the answer", thinking: "weighing the options" })],
|
||||
{ showToolCalls: false, showReasoning: false },
|
||||
);
|
||||
|
||||
expect(markdown).toContain("the answer");
|
||||
expect(markdown).not.toContain("weighing the options");
|
||||
expect(markdown).not.toContain("Reasoning");
|
||||
});
|
||||
|
||||
it("keeps the reasoning under the answer when the turn ends and Show Reasoning is on", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({ role: "assistant", text: "the answer", thinking: "weighing the options" }),
|
||||
]);
|
||||
|
||||
expect(markdown).toContain("the answer");
|
||||
expect(markdown).toContain("weighing the options");
|
||||
expect(markdown.indexOf("the answer")).toBeLessThan(markdown.indexOf("weighing the options"));
|
||||
});
|
||||
|
||||
it("starts the live reasoning without a leading blank line", () => {
|
||||
// Nothing else is on screen while the model thinks, so the reasoning is the
|
||||
// first block, and a stray blank line above it would read as a gap.
|
||||
const markdown = buildThreadMarkdown(
|
||||
[message({ role: "assistant", text: "", isStreaming: true, thinking: "weighing the options" })],
|
||||
{ showToolCalls: false, showReasoning: false },
|
||||
);
|
||||
|
||||
expect(markdown.startsWith("_Reasoning_")).toBe(true);
|
||||
});
|
||||
|
||||
it("rides the answer, not the reasoning, once an answer has arrived", () => {
|
||||
// The cursor rides the answer once there is one, even while reasoning from
|
||||
// the round before it is still on screen.
|
||||
const markdown = buildThreadMarkdown(
|
||||
[
|
||||
message({
|
||||
role: "assistant",
|
||||
text: "partial answer",
|
||||
isStreaming: true,
|
||||
thinking: "weighing the options",
|
||||
}),
|
||||
],
|
||||
{ showToolCalls: false, showReasoning: false },
|
||||
);
|
||||
|
||||
expect(markdown).toContain(`partial answer ${STREAMING_CURSOR}`);
|
||||
expect(markdown).not.toContain(`weighing the options ${STREAMING_CURSOR}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionToPlainText", () => {
|
||||
@@ -352,3 +648,73 @@ describe("attachments in the transcript", () => {
|
||||
expect(text).toContain("_(screenshot attached)_");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sources", () => {
|
||||
const sources = [
|
||||
{ index: 1, title: "First", url: "https://first.example" },
|
||||
{ index: 2, title: "Second", url: "https://second.example" },
|
||||
];
|
||||
|
||||
/** One search, and the answer that may or may not point at it. */
|
||||
function answered(text: string, streaming = false): string {
|
||||
return buildThreadMarkdown(
|
||||
[
|
||||
message({
|
||||
id: "t1:assistant",
|
||||
role: "assistant",
|
||||
text,
|
||||
isStreaming: streaming,
|
||||
toolCalls: [{ name: "web_search", arguments: {}, result: "", isError: false, sources }],
|
||||
}),
|
||||
message({ id: "t1:user", role: "user", text: "When is the rain?" }),
|
||||
],
|
||||
{ showToolCalls: false, showReasoning: false },
|
||||
);
|
||||
}
|
||||
|
||||
it("links the markers and lists the sources the answer leaned on", () => {
|
||||
const text = answered("Rain is likely [1].");
|
||||
|
||||
expect(text).toContain("Rain is likely [¹](https://first.example).");
|
||||
expect(text).toContain("_Sources_");
|
||||
expect(text).toContain("- [\\[1\\]](https://first.example) First");
|
||||
// The answer did not lean on the second one.
|
||||
expect(text).not.toContain("Second");
|
||||
});
|
||||
|
||||
it("lists everything the turn consulted when the answer cited nothing", () => {
|
||||
const text = answered("Rain is likely.");
|
||||
|
||||
expect(text).toContain("- [\\[1\\]](https://first.example) First");
|
||||
expect(text).toContain("- [\\[2\\]](https://second.example) Second");
|
||||
});
|
||||
|
||||
it("waits for a citation before drawing anything", () => {
|
||||
// Otherwise the list arrives in full with the first token and then shrinks as
|
||||
// citations appear, which reads as a glitch rather than as settling.
|
||||
expect(answered("Rain is", true)).not.toContain("_Sources_");
|
||||
});
|
||||
|
||||
it("adds nothing to a turn that consulted nothing", () => {
|
||||
expect(buildThreadMarkdown([message({ role: "assistant", text: "Plain." })])).toBe("Plain.");
|
||||
});
|
||||
|
||||
it("carries the list into the copied conversation", () => {
|
||||
const copied = sessionToPlainText([
|
||||
{
|
||||
id: "t1",
|
||||
prompt: "When is the rain?",
|
||||
response: "Rain [1].",
|
||||
thinking: "",
|
||||
model: "m",
|
||||
createdAt: 0,
|
||||
toolCalls: [{ name: "web_search", arguments: {}, result: "", isError: false, sources }],
|
||||
attachments: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(copied).toContain("**Lam**\n\nRain [1].");
|
||||
expect(copied).toContain("_Sources_");
|
||||
expect(copied).toContain("- [\\[1\\]](https://first.example) First");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { modelCapabilities, normalizeHost, parseNdjsonStream, supportsVision } from "../lib/ollama";
|
||||
import {
|
||||
deleteModel,
|
||||
describePullProgress,
|
||||
isSelfHosted,
|
||||
listModels,
|
||||
listRunningModels,
|
||||
modelCapabilities,
|
||||
normalizeHost,
|
||||
parseNdjsonStream,
|
||||
pullModel,
|
||||
supportsVision,
|
||||
unloadModel,
|
||||
webFetch,
|
||||
webSearch,
|
||||
} from "../lib/ollama";
|
||||
|
||||
/**
|
||||
* The NDJSON reader is the part of the client most likely to break in a way
|
||||
@@ -151,3 +165,438 @@ describe("modelCapabilities", () => {
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/api/show");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSelfHosted", () => {
|
||||
it("is true for a server the user runs", () => {
|
||||
expect(isSelfHosted("http://localhost:11434")).toBe(true);
|
||||
expect(isSelfHosted("https://box.local:11434")).toBe(true);
|
||||
expect(isSelfHosted("http://192.168.1.10:11434/")).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for Ollama Cloud, which has nothing to pull or delete", () => {
|
||||
expect(isSelfHosted("https://ollama.com")).toBe(false);
|
||||
expect(isSelfHosted("https://ollama.com/")).toBe(false);
|
||||
expect(isSelfHosted("https://api.ollama.com")).toBe(false);
|
||||
expect(isSelfHosted(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an unparseable host as local, because every request fails on its own terms anyway", () => {
|
||||
expect(isSelfHosted("not a url")).toBe(true);
|
||||
});
|
||||
|
||||
it("goes by hostname, so a local server behind https still counts", () => {
|
||||
expect(isSelfHosted("https://gpu-box.internal:443")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listModels", () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** Trimmed from a real `GET /api/tags` on Ollama 0.35.1, mixed local and proxied. */
|
||||
const realPayload = {
|
||||
models: [
|
||||
{
|
||||
name: "ornith-1.5:9b",
|
||||
model: "ornith-1.5:9b",
|
||||
size: 6550813657,
|
||||
details: { parameter_size: "9.0B" },
|
||||
capabilities: ["tools", "thinking", "completion", "vision"],
|
||||
},
|
||||
{
|
||||
name: "deepseek-v4.1-flash:cloud",
|
||||
model: "deepseek-v4.1-flash:cloud",
|
||||
remote_model: "deepseek-v4.1-flash",
|
||||
remote_host: "https://ollama.com",
|
||||
size: 326,
|
||||
details: { parameter_size: "763B" },
|
||||
capabilities: ["completion", "thinking", "tools", "vision"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("reads the size, capabilities and remote host off each entry", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(realPayload), { status: 200 }));
|
||||
|
||||
const models = await listModels({ host: "http://localhost:11434", apiKey: "k" });
|
||||
|
||||
expect(models).toEqual([
|
||||
{
|
||||
name: "deepseek-v4.1-flash:cloud",
|
||||
parameterSize: "763B",
|
||||
size: 326,
|
||||
capabilities: ["completion", "thinking", "tools", "vision"],
|
||||
remoteHost: "https://ollama.com",
|
||||
},
|
||||
{
|
||||
name: "ornith-1.5:9b",
|
||||
parameterSize: "9.0B",
|
||||
size: 6550813657,
|
||||
capabilities: ["tools", "thinking", "completion", "vision"],
|
||||
remoteHost: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves capabilities absent rather than empty when the server does not send them", async () => {
|
||||
// An absent list means "unknown", and a row that read it as [] would claim a
|
||||
// model cannot see images when the server simply said nothing.
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ models: [{ name: "old-model:1b" }] }), { status: 200 }),
|
||||
);
|
||||
|
||||
const [model] = await listModels({ host: "http://localhost:11434", apiKey: "k" });
|
||||
|
||||
expect(model.capabilities).toBeUndefined();
|
||||
expect(model.size).toBeUndefined();
|
||||
expect(model.remoteHost).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the model field and drops nameless entries", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ models: [{ model: "from-model-field" }, { details: {} }] }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(listModels({ host: "http://localhost:11434", apiKey: "k" })).resolves.toEqual([
|
||||
{ name: "from-model-field", parameterSize: undefined, size: undefined, capabilities: undefined, remoteHost: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces a rejected key instead of an empty list", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }));
|
||||
|
||||
await expect(listModels({ host: "https://ollama.com", apiKey: "k" })).rejects.toThrow(
|
||||
/rejected the API key/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteModel", () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("sends DELETE, because Ollama answers the POST form of this route with 405", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
|
||||
await deleteModel({ host: "http://localhost:11434", apiKey: "k", model: "llama3.2:3b" });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("http://localhost:11434/api/delete");
|
||||
expect(init.method).toBe("DELETE");
|
||||
expect(JSON.parse(init.body)).toEqual({ model: "llama3.2:3b" });
|
||||
});
|
||||
|
||||
it("says the model is not there rather than swallowing a 404 into a success", async () => {
|
||||
// Reported as a failure on purpose: for a delete, "no model called that"
|
||||
// usually means something else removed it, or the host is not the one the
|
||||
// user thinks it is.
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: "model 'x:1' not found" }), { status: 404 }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
deleteModel({ host: "http://localhost:11434", apiKey: "k", model: "x:1" }),
|
||||
).rejects.toThrow(/has no model called x:1/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pullModel", () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function ndjson(events: unknown[]): Response {
|
||||
const body = `${events.map((event) => JSON.stringify(event)).join("\n")}\n`;
|
||||
return new Response(body, { status: 200, headers: { "Content-Type": "application/x-ndjson" } });
|
||||
}
|
||||
|
||||
async function collectPull(): Promise<{ status: string; total?: number; completed?: number }[]> {
|
||||
const out: { status: string; total?: number; completed?: number }[] = [];
|
||||
for await (const progress of pullModel({ host: "http://localhost:11434", apiKey: "k", model: "qwen3:8b" })) {
|
||||
out.push(progress);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it("streams progress as it arrives", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
ndjson([
|
||||
{ status: "pulling manifest" },
|
||||
{ status: "downloading", total: 1000, completed: 250 },
|
||||
{ status: "downloading", total: 1000, completed: 1000 },
|
||||
{ status: "success" },
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(collectPull()).resolves.toEqual([
|
||||
{ status: "pulling manifest", total: undefined, completed: undefined },
|
||||
{ status: "downloading", total: 1000, completed: 250 },
|
||||
{ status: "downloading", total: 1000, completed: 1000 },
|
||||
{ status: "success", total: undefined, completed: undefined },
|
||||
]);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("http://localhost:11434/api/pull");
|
||||
expect(JSON.parse(init.body)).toEqual({ model: "qwen3:8b", stream: true });
|
||||
});
|
||||
|
||||
it("throws on the error field, which arrives with HTTP 200 rather than a status", async () => {
|
||||
// Measured against 0.35.1: a bad model name answers 200 and puts the failure
|
||||
// in the body, so a check of the status line alone reports a clean pull.
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
ndjson([{ status: "pulling manifest" }, { error: "pull model manifest: file does not exist" }]),
|
||||
);
|
||||
|
||||
await expect(collectPull()).rejects.toThrow(/file does not exist/);
|
||||
});
|
||||
|
||||
it("throws a plain message when the server refuses outright", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("nope", { status: 500 }));
|
||||
|
||||
await expect(collectPull()).rejects.toThrow(/HTTP 500/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describePullProgress", () => {
|
||||
it("passes a status through when there is nothing to measure yet", () => {
|
||||
expect(describePullProgress({ status: "pulling manifest" })).toBe("pulling manifest");
|
||||
expect(describePullProgress({ status: "verifying sha256 digest" })).toBe("verifying sha256 digest");
|
||||
});
|
||||
|
||||
it("adds a percentage and the transferred size once a total is known", () => {
|
||||
expect(describePullProgress({ status: "downloading", total: 1_000, completed: 420 })).toBe(
|
||||
"downloading · 42% · 420 B / 1.0 kB",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not divide by a total of zero", () => {
|
||||
// A tick can arrive with the total not yet filled in, and printing
|
||||
// "Infinity%" at the user is the kind of thing that only shows up in the wild.
|
||||
expect(describePullProgress({ status: "downloading", total: 0, completed: 10 })).toBe("downloading");
|
||||
expect(describePullProgress({ status: "downloading", total: 100, completed: Number.NaN })).toBe(
|
||||
"downloading",
|
||||
);
|
||||
});
|
||||
|
||||
it("never reports more than complete", () => {
|
||||
expect(describePullProgress({ status: "downloading", total: 100, completed: 140 })).toContain("100%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("web tool paths", () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function json(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), { status });
|
||||
}
|
||||
|
||||
it("uses the plain path on Ollama Cloud", async () => {
|
||||
fetchMock.mockResolvedValueOnce(json({ results: [] }));
|
||||
|
||||
await webSearch({ host: "https://ollama.com", apiKey: "k", query: "q" });
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("https://ollama.com/api/web_search");
|
||||
});
|
||||
|
||||
it("uses the experimental path on a local server, which is the one it serves", async () => {
|
||||
// The reported bug: this used to post to /api/web_search on every host, so
|
||||
// pointing the extension at a local server made every web call a 404 and the
|
||||
// model reported that its web tools were not configured.
|
||||
fetchMock.mockResolvedValueOnce(json({ results: [] }));
|
||||
|
||||
await webSearch({ host: "http://localhost:11434", apiKey: "k", query: "q" });
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/api/experimental/web_search");
|
||||
});
|
||||
|
||||
it("does the same for web_fetch", async () => {
|
||||
fetchMock.mockResolvedValueOnce(json({ title: "T", content: "C", links: null }));
|
||||
|
||||
await expect(
|
||||
webFetch({ host: "http://localhost:11434", apiKey: "k", url: "https://example.com" }),
|
||||
).resolves.toEqual({ title: "T", content: "C", links: [] });
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/api/experimental/web_fetch");
|
||||
});
|
||||
|
||||
it("falls back to the other path when the preferred one is missing", async () => {
|
||||
// A server built the other way round, or a future rename: a 404 means the
|
||||
// route is absent, so trying the other one cannot double-send anything.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response("404 page not found", { status: 404 }))
|
||||
.mockResolvedValueOnce(json({ results: [{ title: "t", url: "u", content: "c" }] }));
|
||||
|
||||
await expect(webSearch({ host: "http://localhost:11434", apiKey: "k", query: "q" })).resolves.toHaveLength(
|
||||
1,
|
||||
);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"http://localhost:11434/api/experimental/web_search",
|
||||
"http://localhost:11434/api/web_search",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not fall back on anything other than a 404", async () => {
|
||||
// A 401 or a 500 is the server answering, and retrying the other path would
|
||||
// just send the same refused request twice.
|
||||
fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }));
|
||||
|
||||
await expect(webSearch({ host: "https://ollama.com", apiKey: "k", query: "q" })).rejects.toThrow(
|
||||
/rejected the API key/,
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports the failure from the path it actually tried last", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response("404 page not found", { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response("404 page not found", { status: 404 }));
|
||||
|
||||
await expect(webSearch({ host: "http://localhost:11434", apiKey: "k", query: "q" })).rejects.toThrow(
|
||||
/404/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listRunningModels", () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** Trimmed from a real `GET /api/ps` on Ollama 0.35.1 with one model in VRAM. */
|
||||
const realPayload = {
|
||||
models: [
|
||||
{
|
||||
name: "ornith-1.5:9b",
|
||||
model: "ornith-1.5:9b",
|
||||
size: 10413733314,
|
||||
size_vram: 10413733314,
|
||||
expires_at: "2026-10-05T17:44:11.125356+08:00",
|
||||
context_length: 131072,
|
||||
details: { parameter_size: "9.0B" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("reads what is in memory, including when it will go on its own", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(realPayload), { status: 200 }));
|
||||
|
||||
const running = await listRunningModels({ host: "http://localhost:11434", apiKey: "k" });
|
||||
|
||||
expect(running).toEqual([
|
||||
{
|
||||
name: "ornith-1.5:9b",
|
||||
parameterSize: "9.0B",
|
||||
sizeVram: 10413733314,
|
||||
expiresAt: Date.parse("2026-10-05T17:44:11.125356+08:00"),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/api/ps");
|
||||
});
|
||||
|
||||
it("is empty, not an error, when the server does not serve /api/ps", async () => {
|
||||
// Older builds answer 404. Taking the whole model list down because the
|
||||
// *running* list is unavailable would be a bad trade.
|
||||
fetchMock.mockResolvedValueOnce(new Response("404 page not found", { status: 404 }));
|
||||
|
||||
await expect(listRunningModels({ host: "http://localhost:11434", apiKey: "k" })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("is empty when the request fails outright", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("connection refused"));
|
||||
|
||||
await expect(listRunningModels({ host: "http://localhost:11434", apiKey: "k" })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("copes with a model holding no VRAM figure, as a CPU-offloaded one would", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ models: [{ name: "cpu-model:1b", size_vram: 0 }] }), { status: 200 }),
|
||||
);
|
||||
|
||||
await expect(listRunningModels({ host: "http://localhost:11434", apiKey: "k" })).resolves.toEqual([
|
||||
{ name: "cpu-model:1b", parameterSize: undefined, sizeVram: 0, expiresAt: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unloadModel", () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("posts keep_alive 0 with no prompt, which is how Ollama is told to release a model", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ done: true, done_reason: "unload" }), { status: 200 }),
|
||||
);
|
||||
|
||||
await unloadModel({ host: "http://localhost:11434", apiKey: "k", model: "ornith-1.5:9b" });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("http://localhost:11434/api/generate");
|
||||
expect(init.method).toBe("POST");
|
||||
// No `prompt` key at all, so this can never generate a completion.
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
model: "ornith-1.5:9b",
|
||||
keep_alive: 0,
|
||||
stream: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a refusal rather than pretending the memory came back", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("nope", { status: 500 }));
|
||||
|
||||
await expect(
|
||||
unloadModel({ host: "http://localhost:11434", apiKey: "k", model: "x:1" }),
|
||||
).rejects.toThrow(/HTTP 500/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_HOST, DEFAULT_MODEL } from "../lib/constants";
|
||||
import { describeResumeTimeout, resolvePreferences, resolveResumeTimeout } from "../lib/preferences";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { __resetLocalStorage, __setPreferenceValues } from "../__mocks__/@vicinae/api";
|
||||
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL } from "../lib/constants";
|
||||
import {
|
||||
clearModelSelection,
|
||||
describeAutoDelete,
|
||||
describeMaxToolRounds,
|
||||
describeResumeTimeout,
|
||||
resolveAutoDelete,
|
||||
resolveMaxToolRounds,
|
||||
resolvePreferences,
|
||||
loadPreferences,
|
||||
readModelSelection,
|
||||
resolveResumeTimeout,
|
||||
selectModel,
|
||||
} from "../lib/preferences";
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
@@ -53,6 +66,30 @@ describe("resolvePreferences", () => {
|
||||
expect(resolved.showReasoning).toBe(true);
|
||||
expect(resolved.think).toBeUndefined();
|
||||
expect(resolved.resumeTimeoutMs).toBe(HOUR);
|
||||
// Auto-deletion is the one setting here that destroys data, so its default
|
||||
// is off and every unresolvable value lands on the same answer.
|
||||
expect(resolved.autoDeleteMs).toBeUndefined();
|
||||
expect(resolved.maxToolRounds).toBe(DEFAULT_MAX_TOOL_ROUNDS);
|
||||
// An empty string rather than undefined, so callers never branch on it.
|
||||
expect(resolved.place).toBe("");
|
||||
});
|
||||
|
||||
it("leaves IP geolocation off unless it was switched on", () => {
|
||||
// The one setting that sends something about the user to a third party is
|
||||
// opted into, never defaulted into.
|
||||
expect(resolvePreferences({}).ipGeolocation).toBe(false);
|
||||
expect(resolvePreferences({ ipGeolocation: true }).ipGeolocation).toBe(true);
|
||||
expect(resolvePreferences({ ipGeolocation: false }).ipGeolocation).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the configured location, trimmed", () => {
|
||||
expect(resolvePreferences({ location: " Cebu City, Philippines " }).place).toBe(
|
||||
"Cebu City, Philippines",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a whitespace-only location as unset", () => {
|
||||
expect(resolvePreferences({ location: " " }).place).toBe("");
|
||||
});
|
||||
|
||||
it("honours the display toggles being switched off", () => {
|
||||
@@ -84,4 +121,212 @@ describe("resolvePreferences", () => {
|
||||
expect(resolvePreferences({ resumeTimeout: "15m" }).resumeTimeoutMs).toBe(15 * MINUTE);
|
||||
expect(resolvePreferences({ resumeTimeout: "never" }).resumeTimeoutMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries the auto-delete window through, including disabled", () => {
|
||||
expect(resolvePreferences({ autoDeleteAfter: "30d" }).autoDeleteMs).toBe(30 * DAY);
|
||||
expect(resolvePreferences({ autoDeleteAfter: "disabled" }).autoDeleteMs).toBeUndefined();
|
||||
expect(resolvePreferences({ autoDeleteAfter: "garbage" }).autoDeleteMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries the tool-call cap through, including unlimited", () => {
|
||||
expect(resolvePreferences({ maxToolRounds: "16" }).maxToolRounds).toBe(16);
|
||||
expect(resolvePreferences({ maxToolRounds: "unlimited" }).maxToolRounds).toBeUndefined();
|
||||
expect(resolvePreferences({ maxToolRounds: "garbage" }).maxToolRounds).toBe(DEFAULT_MAX_TOOL_ROUNDS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAutoDelete", () => {
|
||||
it("maps each configured window", () => {
|
||||
expect(resolveAutoDelete("1d")).toBe(DAY);
|
||||
expect(resolveAutoDelete("7d")).toBe(7 * DAY);
|
||||
expect(resolveAutoDelete("30d")).toBe(30 * DAY);
|
||||
expect(resolveAutoDelete("90d")).toBe(90 * DAY);
|
||||
});
|
||||
|
||||
it("returns undefined for 'disabled', which is the default", () => {
|
||||
expect(resolveAutoDelete("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for missing or unrecognised values rather than guessing", () => {
|
||||
// Both readings of an unknown value are safe here only because the fallback
|
||||
// is "delete nothing". See `resolveMaxToolRounds` for the other convention.
|
||||
expect(resolveAutoDelete(undefined)).toBeUndefined();
|
||||
expect(resolveAutoDelete("")).toBeUndefined();
|
||||
expect(resolveAutoDelete("banana")).toBeUndefined();
|
||||
expect(resolveAutoDelete("1y")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tolerates surrounding whitespace and casing", () => {
|
||||
expect(resolveAutoDelete(" 7D ")).toBe(7 * DAY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeAutoDelete", () => {
|
||||
it("names the off case rather than showing a number", () => {
|
||||
expect(describeAutoDelete(undefined)).toContain("Disabled");
|
||||
});
|
||||
|
||||
it("describes days", () => {
|
||||
expect(describeAutoDelete(DAY)).toBe("After 1 day");
|
||||
expect(describeAutoDelete(30 * DAY)).toBe("After 30 days");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMaxToolRounds", () => {
|
||||
it("maps each configured cap", () => {
|
||||
expect(resolveMaxToolRounds("4")).toBe(4);
|
||||
expect(resolveMaxToolRounds("8")).toBe(8);
|
||||
expect(resolveMaxToolRounds("16")).toBe(16);
|
||||
});
|
||||
|
||||
it("returns undefined for unlimited, which is what removes the cap", () => {
|
||||
expect(resolveMaxToolRounds("unlimited")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the default cap for anything unrecognised", () => {
|
||||
// Deliberately the opposite of `resolveAutoDelete`: those two settings are
|
||||
// safe when they do nothing, and this one guards a loop, so the safe reading
|
||||
// of a value this codebase does not understand is the cap and not its absence.
|
||||
expect(resolveMaxToolRounds(undefined)).toBe(DEFAULT_MAX_TOOL_ROUNDS);
|
||||
expect(resolveMaxToolRounds("")).toBe(DEFAULT_MAX_TOOL_ROUNDS);
|
||||
expect(resolveMaxToolRounds("banana")).toBe(DEFAULT_MAX_TOOL_ROUNDS);
|
||||
expect(resolveMaxToolRounds("0")).toBe(DEFAULT_MAX_TOOL_ROUNDS);
|
||||
});
|
||||
|
||||
it("tolerates surrounding whitespace and casing", () => {
|
||||
expect(resolveMaxToolRounds(" UNLIMITED ")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeMaxToolRounds", () => {
|
||||
it("names the uncapped case", () => {
|
||||
expect(describeMaxToolRounds(undefined)).toContain("Unlimited");
|
||||
});
|
||||
|
||||
it("describes a cap in rounds", () => {
|
||||
expect(describeMaxToolRounds(8)).toBe("8 rounds per answer");
|
||||
expect(describeMaxToolRounds(1)).toBe("1 round per answer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the model selection", () => {
|
||||
beforeEach(() => {
|
||||
__resetLocalStorage();
|
||||
__setPreferenceValues({ apiKey: "k", model: "from-preference:1b" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__setPreferenceValues({});
|
||||
});
|
||||
|
||||
it("uses the preference when nothing has been picked", async () => {
|
||||
const resolved = await loadPreferences();
|
||||
|
||||
expect(resolved.model).toBe("from-preference:1b");
|
||||
expect(resolved.modelSource).toBe("preference");
|
||||
expect(resolved.preferenceModel).toBe("from-preference:1b");
|
||||
});
|
||||
|
||||
it("prefers a picked model over the preference, and says so", async () => {
|
||||
// The whole reason this exists: an extension cannot write its own
|
||||
// preferences, so the pick has to live somewhere else and win.
|
||||
await selectModel("picked:9b");
|
||||
|
||||
const resolved = await loadPreferences();
|
||||
|
||||
expect(resolved.model).toBe("picked:9b");
|
||||
expect(resolved.modelSource).toBe("selection");
|
||||
// Both values are carried, so a screen can answer "what would a reset give
|
||||
// me back".
|
||||
expect(resolved.preferenceModel).toBe("from-preference:1b");
|
||||
});
|
||||
|
||||
it("round-trips a pick through storage", async () => {
|
||||
await selectModel("first:1b");
|
||||
await expect(readModelSelection()).resolves.toBe("first:1b");
|
||||
|
||||
await selectModel("second:2b");
|
||||
await expect(readModelSelection()).resolves.toBe("second:2b");
|
||||
});
|
||||
|
||||
it("hands the model back to the preference when the selection is cleared", async () => {
|
||||
await selectModel("picked:9b");
|
||||
await clearModelSelection();
|
||||
|
||||
const resolved = await loadPreferences();
|
||||
|
||||
expect(resolved.model).toBe("from-preference:1b");
|
||||
expect(resolved.modelSource).toBe("preference");
|
||||
});
|
||||
|
||||
it("ignores a stored selection that is empty or whitespace", async () => {
|
||||
// A blank value is not a model name, and treating it as one would leave the
|
||||
// chat asking for "" on every turn.
|
||||
await selectModel(" ");
|
||||
|
||||
await expect(readModelSelection()).resolves.toBeUndefined();
|
||||
await expect(loadPreferences()).resolves.toMatchObject({ model: "from-preference:1b" });
|
||||
});
|
||||
|
||||
it("trims a stored selection", async () => {
|
||||
await selectModel(" spaced:1b ");
|
||||
|
||||
await expect(readModelSelection()).resolves.toBe("spaced:1b");
|
||||
});
|
||||
|
||||
it("falls back to the preference when storage cannot be read", async () => {
|
||||
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
|
||||
const original = LocalStorage.getItem;
|
||||
LocalStorage.getItem = async () => {
|
||||
throw new Error("storage is unavailable");
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(loadPreferences()).resolves.toMatchObject({ model: "from-preference:1b" });
|
||||
} finally {
|
||||
LocalStorage.getItem = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the model source as the preference when resolving without storage", () => {
|
||||
expect(resolvePreferences({ model: "plain:1b" })).toMatchObject({
|
||||
model: "plain:1b",
|
||||
modelSource: "preference",
|
||||
preferenceModel: "plain:1b",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("file access preferences", () => {
|
||||
it("is off when nothing says otherwise", () => {
|
||||
// The default matters more than the setting here: an assistant that can read files
|
||||
// should be one the user switched on, because reading is the half that cannot ask
|
||||
// first.
|
||||
expect(resolvePreferences({}).fileEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("is on only when the checkbox is ticked", () => {
|
||||
expect(resolvePreferences({ fileAccess: true }).fileEnabled).toBe(true);
|
||||
expect(resolvePreferences({ fileAccess: false }).fileEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("carries extra roots, and reports the ones it could not use", () => {
|
||||
const resolved = resolvePreferences({
|
||||
readRoots: "/Volumes/Archive\nrelative/oops",
|
||||
writeRoots: "/Volumes/Archive/scratch",
|
||||
});
|
||||
|
||||
expect(resolved.extraReadRoots).toEqual(["/Volumes/Archive"]);
|
||||
expect(resolved.rejectedReadRoots).toEqual(["relative/oops"]);
|
||||
expect(resolved.extraWriteRoots).toEqual(["/Volumes/Archive/scratch"]);
|
||||
expect(resolved.rejectedWriteRoots).toEqual([]);
|
||||
});
|
||||
|
||||
it("has no extra roots when the fields are empty", () => {
|
||||
const resolved = resolvePreferences({});
|
||||
expect(resolved.extraReadRoots).toEqual([]);
|
||||
expect(resolved.extraWriteRoots).toEqual([]);
|
||||
expect(resolved.rejectedReadRoots).toEqual([]);
|
||||
expect(resolved.rejectedWriteRoots).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderInlineContent, renderModelOutput } from "../lib/render";
|
||||
import type { Source } from "../lib/types";
|
||||
|
||||
/**
|
||||
* The drawn text, for the many cases here that are about what the pane shows
|
||||
* rather than about which citations were linked.
|
||||
*/
|
||||
function rendered(markdown: string, sources?: Source[]): string {
|
||||
return renderModelOutput(markdown, sources ? { sources } : {}).text;
|
||||
}
|
||||
|
||||
describe("renderModelOutput", () => {
|
||||
it("renders inline maths in a sentence", () => {
|
||||
expect(rendered("The answer is $x^2 + 1$, obviously.")).toBe("The answer is x² + 1, obviously.");
|
||||
expect(rendered("So \\(x^2\\) holds.")).toBe("So x² holds.");
|
||||
});
|
||||
|
||||
it("leaves money and shell variables alone", () => {
|
||||
expect(rendered("Costs $5 and $10 today.")).toBe("Costs $5 and $10 today.");
|
||||
expect(rendered("cd $HOME && echo $PATH")).toBe("cd $HOME && echo $PATH");
|
||||
});
|
||||
|
||||
it("puts a display formula on a paragraph of its own", () => {
|
||||
expect(rendered("$$\\frac{a}{b} = c$$")).toBe("\na/b = c\n");
|
||||
expect(rendered("$$\nx = 1\n$$")).toBe("\nx = 1\n");
|
||||
expect(rendered("\\[\\sum_{i=1}^{n} i\\]")).toBe("\n∑ᵢ₌₁ⁿ i\n");
|
||||
});
|
||||
|
||||
it("does not let a stray marker swallow the rest of the answer", () => {
|
||||
// An opening `$$` with no closer only counts when what follows reads as a
|
||||
// formula, so a price is not a formula and the paragraphs after it survive.
|
||||
expect(rendered("The price is\n$$5 per unit.\n\nThat is cheap.")).toBe(
|
||||
"The price is\n$$5 per unit.\n\nThat is cheap.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a maths fence as a formula rather than as code", () => {
|
||||
expect(rendered("```math\n\\sum_{i=1}^{n} i\n```")).toBe("\n∑ᵢ₌₁ⁿ i\n");
|
||||
expect(rendered("```latex\nx^2\n```")).toBe("\nx²\n");
|
||||
});
|
||||
|
||||
it("leaves maths inside a fence alone", () => {
|
||||
const markdown = "```ts\nconst x = $a$;\n```";
|
||||
expect(rendered(markdown)).toBe(markdown);
|
||||
});
|
||||
|
||||
it("leaves a dollar inside a code span alone", () => {
|
||||
expect(rendered("`$x^2$` stays.")).toBe("`$x^2$` stays.");
|
||||
});
|
||||
|
||||
it("renames a fence language the renderer would print but not colour", () => {
|
||||
expect(rendered("```console\nls\n```")).toBe("```bash\nls\n```");
|
||||
expect(rendered("```terminal\nls\n```")).toBe("```bash\nls\n```");
|
||||
});
|
||||
|
||||
it("drops a fence label that carries no information", () => {
|
||||
expect(rendered("```text\nplain\n```")).toBe("```\nplain\n```");
|
||||
});
|
||||
|
||||
it("leaves a label that does resolve exactly as written", () => {
|
||||
const markdown = "```python\nprint(1)\n```";
|
||||
expect(rendered(markdown)).toBe(markdown);
|
||||
});
|
||||
|
||||
it("fences a diagram so its alignment survives", () => {
|
||||
expect(rendered("src/\n├── a.ts\n└── b.ts")).toBe("```\nsrc/\n├── a.ts\n└── b.ts\n```");
|
||||
});
|
||||
|
||||
it("leaves a real table to the table renderer", () => {
|
||||
const table = "| a | b |\n| --- | --- |\n| 1 | 2 |";
|
||||
expect(rendered(table)).toBe(table);
|
||||
});
|
||||
|
||||
it("leaves a diagram inside a fence alone", () => {
|
||||
const markdown = "```text\n├── a.ts\n└── b.ts\n```";
|
||||
expect(rendered(markdown)).toBe("```\n├── a.ts\n└── b.ts\n```");
|
||||
});
|
||||
|
||||
it("filters HTML in the flow of an answer", () => {
|
||||
expect(rendered("<script>alert(1)</script>still here")).toBe("still here");
|
||||
expect(rendered('<span style="color:#ff0000;position:fixed">x</span>')).toBe(
|
||||
'<span style="color:#ff0000">x</span>',
|
||||
);
|
||||
expect(rendered('Read <a href="https://example.com">the docs</a>.')).toBe(
|
||||
"Read [the docs](https://example.com).",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the markup inside a fence as the code it is", () => {
|
||||
const markdown = "```html\n<b>x</b>\n```";
|
||||
expect(rendered(markdown)).toBe(markdown);
|
||||
});
|
||||
|
||||
it("hands a block of HTML to the engine as a paragraph", () => {
|
||||
// A line that starts with a block tag is an HTML *block*, and Vicinae's block
|
||||
// path keeps only the text of one. Wrapped in a span it is a paragraph, which
|
||||
// is the path that keeps raw HTML.
|
||||
expect(rendered("<table><tr><td>a</td><td>b</td></tr></table>")).toBe(
|
||||
"<span><table><tr><td>a</td><td>b</td></tr></table></span>",
|
||||
);
|
||||
});
|
||||
|
||||
it("joins a block of HTML onto one line", () => {
|
||||
// `class` is dropped, because there is no stylesheet for it to name, and the
|
||||
// style attribute is the only thing the renderer reads.
|
||||
expect(rendered('<div class="card">\n<b>bold</b>\n</div>')).toBe("<span><div> <b>bold</b> </div></span>");
|
||||
});
|
||||
|
||||
it("leaves markup that does not balance where the text it carries still shows", () => {
|
||||
expect(rendered("<div>hello")).toBe("<div>hello");
|
||||
});
|
||||
|
||||
it("leaves a block holding an image alone, because that path loads it", () => {
|
||||
expect(rendered('<div><img src="https://x/y.png"></div>')).toBe('<div><img src="https://x/y.png"></div>');
|
||||
});
|
||||
|
||||
it("leaves a lone inline tag alone, because it is not a block", () => {
|
||||
const markdown = "Try <b>bold</b> for that.";
|
||||
expect(rendered(markdown)).toBe(markdown);
|
||||
});
|
||||
|
||||
it("keeps an answer that has nothing to render exactly as written", () => {
|
||||
const plain = "Just a sentence, with a [link](https://example.com) and `code`.";
|
||||
expect(rendered(plain)).toBe(plain);
|
||||
});
|
||||
|
||||
it("repairs fences in model output but not in what a person typed", () => {
|
||||
// A lone command is a fence when a model writes it, and a question about it
|
||||
// is left as the person typed it.
|
||||
expect(rendered("`pwd`")).toBe("```bash\npwd\n```");
|
||||
expect(renderInlineContent("`pwd`")).toBe("`pwd`");
|
||||
});
|
||||
|
||||
it("still renders maths and markup in what a person typed", () => {
|
||||
expect(renderInlineContent("what is $x^2$?")).toBe("what is x²?");
|
||||
expect(renderInlineContent("<script>alert(1)</script>hi")).toBe("hi");
|
||||
expect(renderInlineContent("```ts\nconst x = $a$;\n```")).toBe("```ts\nconst x = $a$;\n```");
|
||||
});
|
||||
});
|
||||
|
||||
describe("citations in the pipeline", () => {
|
||||
const sources = [{ index: 1, title: "First", url: "https://first.example" }];
|
||||
|
||||
it("reports which citations it linked", () => {
|
||||
const result = renderModelOutput("Rain [1].", { sources });
|
||||
|
||||
expect(result.text).toBe("Rain [¹](https://first.example).");
|
||||
expect([...result.cited]).toEqual([1]);
|
||||
});
|
||||
|
||||
it("links a marker that has to survive the maths and markup passes", () => {
|
||||
expect(rendered("Let $x^2$ be [1], and nothing else.", sources)).toContain(
|
||||
"x² be [¹](https://first.example)",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a marker inside a fence for the reader to copy", () => {
|
||||
const text = rendered("```bash\ncurl [1]\n```\n\nSee [1].", sources);
|
||||
|
||||
expect(text).toContain("curl [1]");
|
||||
expect(text).toContain("[¹](https://first.example).");
|
||||
});
|
||||
|
||||
it("changes nothing when no sources are given", () => {
|
||||
expect(rendered("Rain [1].")).toBe("Rain [1].");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SNIP_ORPHAN_GRACE_MS } from "../lib/constants";
|
||||
import {
|
||||
describeRemoval,
|
||||
expiredSessions,
|
||||
orphanedSnips,
|
||||
sweepExpired,
|
||||
type SnipFile,
|
||||
type SweepPorts,
|
||||
} from "../lib/retention";
|
||||
import type { SessionSummary } from "../lib/types";
|
||||
|
||||
/**
|
||||
* The deletion rules.
|
||||
*
|
||||
* This is the only code in the extension that destroys anything, so these tests
|
||||
* are mostly about what it refuses to delete: the off switch, the open
|
||||
* conversation, a snip that is still pending, and the whole orphan pass when the
|
||||
* referenced set is not trustworthy.
|
||||
*/
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const now = 1_000 * DAY;
|
||||
|
||||
function summary(id: string, updatedAt: number): SessionSummary {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
model: "test-model",
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
turnCount: 1,
|
||||
preview: "",
|
||||
attachmentPaths: [],
|
||||
};
|
||||
}
|
||||
|
||||
function snip(path: string, mtimeMs: number): SnipFile {
|
||||
return { path, mtimeMs };
|
||||
}
|
||||
|
||||
describe("expiredSessions", () => {
|
||||
it("is off when no window is configured", () => {
|
||||
expect(expiredSessions([summary("a", now - 400 * DAY)], undefined, now)).toEqual([]);
|
||||
});
|
||||
|
||||
it("picks the conversations that have not been used for longer than the window", () => {
|
||||
const recent = summary("recent", now - DAY + 1);
|
||||
const stale = summary("stale", now - 8 * DAY);
|
||||
|
||||
expect(expiredSessions([recent, stale], 7 * DAY, now)).toEqual([stale]);
|
||||
});
|
||||
|
||||
it("ages a conversation from when it was last used, not when it was started", () => {
|
||||
// A chat started last month and used this morning is not old.
|
||||
const longLived = { ...summary("long-lived", now - 1_000), createdAt: now - 60 * DAY };
|
||||
|
||||
expect(expiredSessions([longLived], 7 * DAY, now)).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts a conversation updated exactly at the window as expired", () => {
|
||||
expect(expiredSessions([summary("edge", now - 7 * DAY)], 7 * DAY, now)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("never expires the conversation that is open", () => {
|
||||
const open = summary("open", now - 400 * DAY);
|
||||
|
||||
expect(expiredSessions([open, summary("other", now - 400 * DAY)], 7 * DAY, now, "open")).toEqual([
|
||||
expect.objectContaining({ id: "other" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a clock that has run backwards as fresh rather than as ancient", () => {
|
||||
expect(expiredSessions([summary("future", now + DAY)], 7 * DAY, now)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("orphanedSnips", () => {
|
||||
it("collects an unreferenced file once it has waited out the grace period", () => {
|
||||
const old = snip("/snips/old.png", now - SNIP_ORPHAN_GRACE_MS - 1);
|
||||
|
||||
expect(orphanedSnips([old], [], now)).toEqual([old]);
|
||||
});
|
||||
|
||||
it("leaves an unreferenced file alone while it could still be a pending snip", () => {
|
||||
// The file is on disk before the chat view opens and stays unreferenced
|
||||
// until the user sends it, so this wait is what protects it.
|
||||
const fresh = snip("/snips/fresh.png", now - 1_000);
|
||||
|
||||
expect(orphanedSnips([fresh], [], now)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a file a surviving conversation still refers to, however old it is", () => {
|
||||
const referenced = snip("/snips/referenced.png", now - 400 * DAY);
|
||||
|
||||
expect(orphanedSnips([referenced], ["/snips/referenced.png"], now)).toEqual([]);
|
||||
});
|
||||
|
||||
it("honours a caller's own grace period", () => {
|
||||
const file = snip("/snips/one.png", now - 2 * 60 * 1000);
|
||||
|
||||
expect(orphanedSnips([file], [], now, 60 * 1000)).toEqual([file]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sweepExpired", () => {
|
||||
function ports(overrides: Partial<SweepPorts> = {}): SweepPorts {
|
||||
return {
|
||||
listSessions: vi.fn(async () => []),
|
||||
deleteSessions: vi.fn(async () => []),
|
||||
referencedAttachments: vi.fn(async () => []),
|
||||
listSnipFiles: vi.fn(async () => []),
|
||||
removeSnipFiles: vi.fn(async () => 0),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("reads and writes nothing at all when it is disabled", async () => {
|
||||
const fake = ports();
|
||||
|
||||
const outcome = await sweepExpired({ maxAgeMs: undefined, now }, fake);
|
||||
|
||||
expect(outcome).toEqual({ sessions: 0, files: 0 });
|
||||
expect(fake.listSessions).not.toHaveBeenCalled();
|
||||
expect(fake.listSnipFiles).not.toHaveBeenCalled();
|
||||
expect(fake.removeSnipFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes expired conversations and the screenshots they held", async () => {
|
||||
const fake = ports({
|
||||
listSessions: vi.fn(async () => [summary("stale", now - 8 * DAY), summary("fresh", now)]),
|
||||
deleteSessions: vi.fn(async () => ["/snips/taken-minutes-ago.png"]),
|
||||
});
|
||||
|
||||
const outcome = await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
|
||||
|
||||
expect(fake.deleteSessions).toHaveBeenCalledWith(["stale"]);
|
||||
// Explicitly, not by the grace-period rule: a screenshot taken minutes before
|
||||
// its conversation is deleted should go with it.
|
||||
expect(fake.removeSnipFiles).toHaveBeenCalledWith(["/snips/taken-minutes-ago.png"]);
|
||||
expect(outcome).toEqual({ sessions: 1, files: 1 });
|
||||
});
|
||||
|
||||
it("collects orphan files and spares the ones a conversation still refers to", async () => {
|
||||
const orphan = "/snips/orphan.png";
|
||||
const referenced = "/snips/referenced.png";
|
||||
|
||||
const fake = ports({
|
||||
listSessions: vi.fn(async () => [summary("kept", now)]),
|
||||
referencedAttachments: vi.fn(async () => [referenced]),
|
||||
listSnipFiles: vi.fn(async () => [
|
||||
snip(orphan, now - 2 * DAY),
|
||||
snip(referenced, now - 2 * DAY),
|
||||
]),
|
||||
});
|
||||
|
||||
const outcome = await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
|
||||
|
||||
expect(fake.removeSnipFiles).toHaveBeenCalledWith([orphan]);
|
||||
expect(outcome).toEqual({ sessions: 0, files: 1 });
|
||||
});
|
||||
|
||||
it("skips the orphan pass when the referenced set cannot be established", async () => {
|
||||
// An incomplete set of references is indistinguishable from a pile of
|
||||
// orphans, and the wrong answer deletes real screenshots.
|
||||
const fake = ports({
|
||||
referencedAttachments: vi.fn(async () => undefined),
|
||||
listSnipFiles: vi.fn(async () => [snip("/snips/maybe.png", now - 400 * DAY)]),
|
||||
});
|
||||
|
||||
const outcome = await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
|
||||
|
||||
expect(fake.removeSnipFiles).not.toHaveBeenCalled();
|
||||
expect(outcome).toEqual({ sessions: 0, files: 0 });
|
||||
});
|
||||
|
||||
it("never deletes the conversation that is open", async () => {
|
||||
const fake = ports({
|
||||
listSessions: vi.fn(async () => [summary("open", now - 400 * DAY)]),
|
||||
});
|
||||
|
||||
const outcome = await sweepExpired({ maxAgeMs: 7 * DAY, now, keepId: "open" }, fake);
|
||||
|
||||
expect(fake.deleteSessions).not.toHaveBeenCalled();
|
||||
expect(outcome.sessions).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
it("keeps a screenshot a surviving conversation also refers to", async () => {
|
||||
// Two conversations can hold the same capture, so the deleted one's files are
|
||||
// not simply its own.
|
||||
const shared = "/snips/shared.png";
|
||||
|
||||
const fake = ports({
|
||||
listSessions: vi.fn(async () => [summary("stale", now - 8 * DAY), summary("keep", now)]),
|
||||
deleteSessions: vi.fn(async () => [shared]),
|
||||
referencedAttachments: vi.fn(async () => [shared]),
|
||||
});
|
||||
|
||||
const outcome = await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
|
||||
|
||||
expect(fake.removeSnipFiles).not.toHaveBeenCalled();
|
||||
expect(outcome).toEqual({ sessions: 1, files: 0 });
|
||||
});
|
||||
|
||||
it("does not remove a deleted conversation's files when the references are unknown", async () => {
|
||||
// Leaking a file is recoverable. Deleting one a survivor still shows is not.
|
||||
const fake = ports({
|
||||
listSessions: vi.fn(async () => [summary("stale", now - 8 * DAY)]),
|
||||
deleteSessions: vi.fn(async () => ["/snips/unverifiable.png"]),
|
||||
referencedAttachments: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
const outcome = await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
|
||||
|
||||
expect(fake.removeSnipFiles).not.toHaveBeenCalled();
|
||||
expect(outcome).toEqual({ sessions: 1, files: 0 });
|
||||
});
|
||||
|
||||
it("does not remove the same file twice when both passes find it", async () => {
|
||||
const fake = ports({
|
||||
listSessions: vi.fn(async () => [summary("stale", now - 8 * DAY)]),
|
||||
deleteSessions: vi.fn(async () => ["/snips/both.png"]),
|
||||
listSnipFiles: vi.fn(async () => [snip("/snips/both.png", now - 8 * DAY)]),
|
||||
});
|
||||
|
||||
await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
|
||||
|
||||
expect(fake.removeSnipFiles).toHaveBeenCalledWith(["/snips/both.png"]);
|
||||
});
|
||||
|
||||
it("still tidies files when there is nothing to expire", async () => {
|
||||
// An unsent capture belongs to no conversation, so no amount of session
|
||||
// deletion would ever reach it.
|
||||
const fake = ports({
|
||||
listSnipFiles: vi.fn(async () => [snip("/snips/unsent.png", now - 400 * DAY)]),
|
||||
});
|
||||
|
||||
const outcome = await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
|
||||
|
||||
expect(outcome).toEqual({ sessions: 0, files: 1 });
|
||||
});
|
||||
|
||||
it("leaves storage and the file system alone when nothing qualifies", async () => {
|
||||
const fake = ports({ listSnipFiles: vi.fn(async () => [snip("/snips/fresh.png", now)]) });
|
||||
|
||||
await sweepExpired({ maxAgeMs: 7 * DAY, now }, fake);
|
||||
|
||||
expect(fake.removeSnipFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeRemoval", () => {
|
||||
it("says nothing when nothing was deleted", () => {
|
||||
expect(describeRemoval({ sessions: 0, files: 0 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("counts conversations, keeping the noun singular at one", () => {
|
||||
expect(describeRemoval({ sessions: 1, files: 0 })).toBe("1 conversation");
|
||||
expect(describeRemoval({ sessions: 3, files: 0 })).toBe("3 conversations");
|
||||
});
|
||||
|
||||
it("mentions screenshots when it removed any", () => {
|
||||
expect(describeRemoval({ sessions: 0, files: 1 })).toBe("1 screenshot");
|
||||
expect(describeRemoval({ sessions: 2, files: 4 })).toBe("2 conversations and 4 screenshots");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { access, mkdir, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { afterEach, 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 {
|
||||
@@ -7,10 +8,14 @@ import {
|
||||
createSession,
|
||||
createTurn,
|
||||
clearSessions,
|
||||
cullOrphanedSnips,
|
||||
deleteSession,
|
||||
deleteSessions,
|
||||
listSessions,
|
||||
loadSession,
|
||||
referencedAttachments,
|
||||
saveSession,
|
||||
sweepExpiredSessions,
|
||||
withTitleFromFirstTurn,
|
||||
} from "../lib/sessions";
|
||||
import type { ChatTurn } from "../lib/types";
|
||||
@@ -160,3 +165,352 @@ describe("createTurn", () => {
|
||||
expect(created.toolCalls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachment paths", () => {
|
||||
const PATH = "/snips/one.png";
|
||||
|
||||
function sniffedTurn(index: number, path: string): ChatTurn {
|
||||
return { ...turn(index), attachments: [{ path, mime: "image/png" }] };
|
||||
}
|
||||
|
||||
it("records the paths a conversation refers to on its index entry", async () => {
|
||||
// The index has to know which screenshots are spoken for without reading a
|
||||
// transcript, because the orphan sweep runs after every single answer.
|
||||
const session = { ...createSession("m", 1_000), turns: [sniffedTurn(1, PATH)] };
|
||||
await saveSession(session);
|
||||
|
||||
const [summary] = await listSessions();
|
||||
expect(summary.attachmentPaths).toEqual([PATH]);
|
||||
});
|
||||
|
||||
it("deduplicates a path used by more than one turn", async () => {
|
||||
const session = {
|
||||
...createSession("m", 1_000),
|
||||
turns: [sniffedTurn(1, PATH), sniffedTurn(2, PATH), sniffedTurn(3, "/snips/two.png")],
|
||||
};
|
||||
await saveSession(session);
|
||||
|
||||
const [summary] = await listSessions();
|
||||
expect(summary.attachmentPaths).toEqual([PATH, "/snips/two.png"]);
|
||||
});
|
||||
|
||||
it("reports every path the conversations that remain refer to", async () => {
|
||||
await saveSession({ ...createSession("m", 1_000), turns: [sniffedTurn(1, PATH)] });
|
||||
|
||||
expect(await referencedAttachments()).toEqual([PATH]);
|
||||
});
|
||||
|
||||
it("fills in an index written before attachment paths were recorded", async () => {
|
||||
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
|
||||
await saveSession({ ...createSession("m", 1_000), turns: [sniffedTurn(1, PATH)] });
|
||||
|
||||
// Exactly what an upgrade looks like: the field is simply absent.
|
||||
const [summary] = await listSessions();
|
||||
const legacy = { ...summary };
|
||||
delete legacy.attachmentPaths;
|
||||
await LocalStorage.setItem("lam.sessions.index", JSON.stringify([legacy]));
|
||||
|
||||
expect(await referencedAttachments()).toEqual([PATH]);
|
||||
|
||||
// And repaired, so the next sweep does not read the transcript again.
|
||||
const [repaired] = await listSessions();
|
||||
expect(repaired.attachmentPaths).toEqual([PATH]);
|
||||
});
|
||||
|
||||
it("refuses to guess when a transcript will not load", async () => {
|
||||
// A session whose body cannot be read would look like it referred to
|
||||
// nothing, and everything it referred to would be deleted.
|
||||
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
|
||||
await saveSession({ ...createSession("m", 1_000), turns: [sniffedTurn(1, PATH)] });
|
||||
|
||||
const [summary] = await listSessions();
|
||||
const legacy = { ...summary };
|
||||
delete legacy.attachmentPaths;
|
||||
await LocalStorage.setItem("lam.sessions.index", JSON.stringify([legacy]));
|
||||
await LocalStorage.removeItem(`lam.session.${summary.id}`);
|
||||
|
||||
expect(await referencedAttachments()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deletes several conversations in one pass and hands back their screenshots", async () => {
|
||||
const first = { ...createSession("m", 1_000), turns: [sniffedTurn(1, PATH)] };
|
||||
const second = { ...createSession("m", 2_000), turns: [sniffedTurn(1, "/snips/two.png")] };
|
||||
const kept = { ...createSession("m", 3_000), turns: [sniffedTurn(1, "/snips/three.png")] };
|
||||
|
||||
await saveSession(first);
|
||||
await saveSession(second);
|
||||
await saveSession(kept);
|
||||
|
||||
const removed = await deleteSessions([first.id, second.id]);
|
||||
|
||||
expect(removed.sort()).toEqual([PATH, "/snips/two.png"]);
|
||||
expect((await listSessions()).map((summary) => summary.id)).toEqual([kept.id]);
|
||||
expect(await loadSession(first.id)).toBeUndefined();
|
||||
expect(await loadSession(second.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing when asked to delete nothing", async () => {
|
||||
await saveSession(createSession("m", 1_000));
|
||||
|
||||
expect(await deleteSessions([])).toEqual([]);
|
||||
expect(await listSessions()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sweepExpiredSessions", () => {
|
||||
const DIRECTORY = "/tmp/lam-agent-support/snips";
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
async function makeSnip(name: string, ageMs: number): Promise<string> {
|
||||
const path = `${DIRECTORY}/${name}`;
|
||||
await writeFile(path, "png");
|
||||
const when = new Date(Date.now() - ageMs);
|
||||
await utimes(path, when, when);
|
||||
return path;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await rm(DIRECTORY, { recursive: true, force: true });
|
||||
await mkdir(DIRECTORY, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(DIRECTORY, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("deletes expired conversations, their screenshots, and unreferenced leftovers", async () => {
|
||||
const now = Date.now();
|
||||
|
||||
const takenRecently = await makeSnip("recent-deleted.png", 60 * 1000);
|
||||
const referenced = await makeSnip("still-referenced.png", 30 * DAY);
|
||||
const abandoned = await makeSnip("never-sent.png", 3 * DAY);
|
||||
|
||||
const stale = {
|
||||
...createSession("m", now - 60 * DAY),
|
||||
updatedAt: now - 60 * DAY,
|
||||
turns: [{ ...turn(1), attachments: [{ path: takenRecently, mime: "image/png" }] }],
|
||||
};
|
||||
const kept = {
|
||||
...createSession("m", now),
|
||||
updatedAt: now,
|
||||
turns: [{ ...turn(2), attachments: [{ path: referenced, mime: "image/png" }] }],
|
||||
};
|
||||
|
||||
await saveSession(stale);
|
||||
await saveSession(kept);
|
||||
|
||||
const outcome = await sweepExpiredSessions({ maxAgeMs: 7 * DAY, now });
|
||||
|
||||
expect(outcome).toEqual({ sessions: 1, files: 2 });
|
||||
expect((await listSessions()).map((summary) => summary.id)).toEqual([kept.id]);
|
||||
|
||||
// The screenshot of the conversation that went, gone with it, even though it
|
||||
// was taken a minute ago.
|
||||
await expect(access(takenRecently)).rejects.toThrow();
|
||||
// A snip nothing ever referred to, collected after its wait.
|
||||
await expect(access(abandoned)).rejects.toThrow();
|
||||
// Still spoken for, so untouched however old it is.
|
||||
await expect(access(referenced)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves the open conversation and its screenshots alone", async () => {
|
||||
const now = Date.now();
|
||||
const referenced = await makeSnip("open-chat.png", 30 * DAY);
|
||||
const open = {
|
||||
...createSession("m", now - 60 * DAY),
|
||||
updatedAt: now - 60 * DAY,
|
||||
turns: [{ ...turn(1), attachments: [{ path: referenced, mime: "image/png" }] }],
|
||||
};
|
||||
|
||||
await saveSession(open);
|
||||
|
||||
const outcome = await sweepExpiredSessions({ maxAgeMs: 7 * DAY, now, keepId: open.id });
|
||||
|
||||
expect(outcome.sessions).toBe(0);
|
||||
expect((await listSessions()).map((summary) => summary.id)).toEqual([open.id]);
|
||||
await expect(access(referenced)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
it("keeps a screenshot an expired conversation shared with a surviving one", async () => {
|
||||
const now = Date.now();
|
||||
const shared = await makeSnip("shared.png", 30 * DAY);
|
||||
const attachment = [{ path: shared, mime: "image/png" }];
|
||||
|
||||
const stale = {
|
||||
...createSession("m", now - 60 * DAY),
|
||||
updatedAt: now - 60 * DAY,
|
||||
turns: [{ ...turn(1), attachments: attachment }],
|
||||
};
|
||||
const kept = {
|
||||
...createSession("m", now),
|
||||
updatedAt: now,
|
||||
turns: [{ ...turn(2), attachments: attachment }],
|
||||
};
|
||||
|
||||
await saveSession(stale);
|
||||
await saveSession(kept);
|
||||
|
||||
const outcome = await sweepExpiredSessions({ maxAgeMs: 7 * DAY, now });
|
||||
|
||||
expect(outcome).toEqual({ sessions: 1, files: 0 });
|
||||
await expect(access(shared)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("touches nothing when auto-deletion is off", async () => {
|
||||
const now = Date.now();
|
||||
const abandoned = await makeSnip("never-sent.png", 400 * DAY);
|
||||
const stale = { ...createSession("m", now - 400 * DAY), updatedAt: now - 400 * DAY };
|
||||
await saveSession(stale);
|
||||
|
||||
expect(await sweepExpiredSessions({ maxAgeMs: undefined, now })).toEqual({ sessions: 0, files: 0 });
|
||||
expect(await listSessions()).toHaveLength(1);
|
||||
await expect(access(abandoned)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("manual deletion", () => {
|
||||
const DIRECTORY = "/tmp/lam-agent-support/snips";
|
||||
|
||||
async function makeSnip(name: string): Promise<string> {
|
||||
const path = `${DIRECTORY}/${name}`;
|
||||
await writeFile(path, "png");
|
||||
return path;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await rm(DIRECTORY, { recursive: true, force: true });
|
||||
await mkdir(DIRECTORY, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(DIRECTORY, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("takes a deleted conversation's screenshots with it", async () => {
|
||||
// Otherwise a manual delete leaks images that nothing will ever collect,
|
||||
// since the orphan pass only runs while auto-deletion is switched on.
|
||||
const path = await makeSnip("one.png");
|
||||
const session = {
|
||||
...createSession("m", 1_000),
|
||||
turns: [{ ...turn(1), attachments: [{ path, mime: "image/png" }] }],
|
||||
};
|
||||
await saveSession(session);
|
||||
|
||||
await deleteSession(session.id);
|
||||
|
||||
await expect(access(path)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("takes every conversation's screenshots when clearing all of them", async () => {
|
||||
const first = await makeSnip("one.png");
|
||||
const second = await makeSnip("two.png");
|
||||
|
||||
await saveSession({
|
||||
...createSession("m", 1_000),
|
||||
turns: [{ ...turn(1), attachments: [{ path: first, mime: "image/png" }] }],
|
||||
});
|
||||
await saveSession({
|
||||
...createSession("m", 2_000),
|
||||
turns: [{ ...turn(1), attachments: [{ path: second, mime: "image/png" }] }],
|
||||
});
|
||||
|
||||
await clearSessions();
|
||||
|
||||
expect(await listSessions()).toHaveLength(0);
|
||||
await expect(access(first)).rejects.toThrow();
|
||||
await expect(access(second)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("does not touch a screenshot another conversation still uses", async () => {
|
||||
const shared = await makeSnip("shared.png");
|
||||
const attachment = [{ path: shared, mime: "image/png" }];
|
||||
|
||||
const doomed = { ...createSession("m", 1_000), turns: [{ ...turn(1), attachments: attachment }] };
|
||||
const kept = { ...createSession("m", 2_000), turns: [{ ...turn(2), attachments: attachment }] };
|
||||
|
||||
await saveSession(doomed);
|
||||
await saveSession(kept);
|
||||
await deleteSession(doomed.id);
|
||||
|
||||
// Deleting the file would leave the surviving conversation showing a broken
|
||||
// thumbnail, which the orphan pass would never catch because it is referenced.
|
||||
await expect(access(shared)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cullOrphanedSnips", () => {
|
||||
const DIRECTORY = "/tmp/lam-agent-support/snips";
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
async function makeSnip(name: string, ageMs: number): Promise<string> {
|
||||
const path = `${DIRECTORY}/${name}`;
|
||||
await writeFile(path, "png");
|
||||
const when = new Date(Date.now() - ageMs);
|
||||
await utimes(path, when, when);
|
||||
return path;
|
||||
}
|
||||
|
||||
async function reference(path: string): Promise<void> {
|
||||
await saveSession({
|
||||
...createSession("m", Date.now()),
|
||||
turns: [{ ...turn(1), attachments: [{ path, mime: "image/png" }] }],
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await rm(DIRECTORY, { recursive: true, force: true });
|
||||
await mkdir(DIRECTORY, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(DIRECTORY, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("collects a capture that no conversation refers to", async () => {
|
||||
const orphan = await makeSnip("orphan.png", 2 * DAY);
|
||||
|
||||
await expect(cullOrphanedSnips()).resolves.toBe(1);
|
||||
await expect(access(orphan)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("keeps a capture a conversation still shows", async () => {
|
||||
const referenced = await makeSnip("referenced.png", 30 * DAY);
|
||||
await reference(referenced);
|
||||
|
||||
await expect(cullOrphanedSnips()).resolves.toBe(0);
|
||||
await expect(access(referenced)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a fresh capture, which could still be one the user has not sent", async () => {
|
||||
// This is the same grace period the background sweep uses. The file is on
|
||||
// disk before the chat view opens and stays unreferenced until the question
|
||||
// is sent, so deleting it early would break a snip in flight.
|
||||
const pending = await makeSnip("pending.png", 60 * 1000);
|
||||
|
||||
await expect(cullOrphanedSnips()).resolves.toBe(0);
|
||||
await expect(access(pending)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("answers nothing rather than zero when a transcript will not load", async () => {
|
||||
// Zero would be a claim that everything referenced is accounted for. With an
|
||||
// unreadable conversation that claim cannot be made, so the caller is told
|
||||
// the answer is unknown and deletes nothing.
|
||||
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
|
||||
const orphan = await makeSnip("orphan.png", 30 * DAY);
|
||||
|
||||
await saveSession({ ...createSession("m", Date.now()), turns: [turn(1)] });
|
||||
const [summary] = await listSessions();
|
||||
const legacy = { ...summary };
|
||||
delete legacy.attachmentPaths;
|
||||
await LocalStorage.setItem("lam.sessions.index", JSON.stringify([legacy]));
|
||||
await LocalStorage.removeItem(`lam.session.${summary.id}`);
|
||||
|
||||
await expect(cullOrphanedSnips()).resolves.toBeUndefined();
|
||||
await expect(access(orphan)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports zero when there is nothing to collect", async () => {
|
||||
await expect(cullOrphanedSnips()).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSnipContext, snipDirectory } from "../lib/snip";
|
||||
import { access, mkdir, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { listSnipFiles, readSnipContext, removeSnipFiles, snipDirectory } from "../lib/snip";
|
||||
|
||||
/**
|
||||
* The snip handoff crosses a command boundary as a serialized launch context, so
|
||||
@@ -55,3 +56,96 @@ describe("snipDirectory", () => {
|
||||
expect(snipDirectory()).toBe("/tmp/lam-agent-support/snips");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listSnipFiles", () => {
|
||||
const DIRECTORY = "/tmp/lam-agent-support/snips";
|
||||
|
||||
beforeEach(async () => {
|
||||
await rm(DIRECTORY, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(DIRECTORY, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns nothing before anything has been snipped", async () => {
|
||||
await expect(listSnipFiles()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reports each capture with the time it was written", async () => {
|
||||
await mkdir(DIRECTORY, { recursive: true });
|
||||
const path = `${DIRECTORY}/one.png`;
|
||||
await writeFile(path, "png");
|
||||
const when = new Date(Date.now() - 60_000);
|
||||
await utimes(path, when, when);
|
||||
|
||||
const [file] = await listSnipFiles();
|
||||
|
||||
expect(file.path).toBe(path);
|
||||
expect(Date.now() - file.mtimeMs).toBeGreaterThan(30_000);
|
||||
});
|
||||
|
||||
it("ignores a subdirectory, which is not a capture", async () => {
|
||||
await mkdir(`${DIRECTORY}/nested`, { recursive: true });
|
||||
|
||||
await expect(listSnipFiles()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeSnipFiles", () => {
|
||||
const DIRECTORY = "/tmp/lam-agent-support/snips";
|
||||
const OUTSIDE = "/tmp/lam-agent-not-a-snip.png";
|
||||
|
||||
beforeEach(async () => {
|
||||
await rm(DIRECTORY, { recursive: true, force: true });
|
||||
await mkdir(DIRECTORY, { recursive: true });
|
||||
await writeFile(OUTSIDE, "do not delete me");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(DIRECTORY, { recursive: true, force: true });
|
||||
await rm(OUTSIDE, { force: true });
|
||||
});
|
||||
|
||||
it("deletes a capture inside the snip directory and counts it", async () => {
|
||||
const path = `${DIRECTORY}/one.png`;
|
||||
await writeFile(path, "png");
|
||||
|
||||
await expect(removeSnipFiles([path])).resolves.toBe(1);
|
||||
await expect(access(path)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("counts a file that was already gone, because the question is whether it is still there", async () => {
|
||||
await expect(removeSnipFiles([`${DIRECTORY}/never-existed.png`])).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it("refuses a path outside the snip directory and does not count it", async () => {
|
||||
// These paths come out of stored JSON, which is data rather than code, so
|
||||
// this must not be a general-purpose delete-any-file helper.
|
||||
await expect(removeSnipFiles([OUTSIDE])).resolves.toBe(0);
|
||||
|
||||
await expect(access(OUTSIDE)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a path that climbs out of the directory", async () => {
|
||||
await expect(removeSnipFiles([`${DIRECTORY}/../../lam-agent-not-a-snip.png`])).resolves.toBe(0);
|
||||
|
||||
await expect(access(OUTSIDE)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses the directory itself", async () => {
|
||||
await expect(removeSnipFiles([DIRECTORY])).resolves.toBe(0);
|
||||
|
||||
await expect(access(DIRECTORY)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps going when one path in the batch is refused, and counts only the one it acted on", async () => {
|
||||
const inside = `${DIRECTORY}/one.png`;
|
||||
await writeFile(inside, "png");
|
||||
|
||||
await expect(removeSnipFiles([OUTSIDE, inside])).resolves.toBe(1);
|
||||
|
||||
await expect(access(inside)).rejects.toThrow();
|
||||
await expect(access(OUTSIDE)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,7 +191,7 @@ describe("resolveStartup", () => {
|
||||
});
|
||||
|
||||
it("does not resume when a session was pushed in by hand", async () => {
|
||||
// The sessions command already knows which conversation to show.
|
||||
// The Chat History command already knows which conversation to show.
|
||||
const p = ports();
|
||||
|
||||
await resolveStartup(input({ hasInitialSession: true }), p);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EMPTY_TRACE, afterToolCall, withFinalTrace, withThinking } from "../lib/thinking";
|
||||
|
||||
describe("ThinkingTrace", () => {
|
||||
it("starts empty", () => {
|
||||
expect(EMPTY_TRACE).toEqual({ full: "", current: "" });
|
||||
});
|
||||
|
||||
it("appends a delta to both the full trace and the current segment", () => {
|
||||
const trace = withThinking(withThinking(EMPTY_TRACE, "let me "), "think");
|
||||
|
||||
expect(trace).toEqual({ full: "let me think", current: "let me think" });
|
||||
});
|
||||
|
||||
it("keeps the full trace when a tool starts, and clears the current segment", () => {
|
||||
// The reported case: the model thinks, calls a tool, and thinks again. The
|
||||
// live view shows only the new thinking; the stored turn keeps both rounds.
|
||||
const first = withThinking(EMPTY_TRACE, "I should search");
|
||||
const cleared = afterToolCall(first);
|
||||
|
||||
expect(cleared.full).toBe("I should search");
|
||||
expect(cleared.current).toBe("");
|
||||
|
||||
const second = withThinking(cleared, "now I know");
|
||||
expect(second.full).toBe("I should searchnow I know");
|
||||
expect(second.current).toBe("now I know");
|
||||
});
|
||||
|
||||
it("does not mutate the trace it was given", () => {
|
||||
const trace = withThinking(EMPTY_TRACE, "a");
|
||||
afterToolCall(trace);
|
||||
withThinking(trace, "b");
|
||||
|
||||
expect(trace).toEqual({ full: "a", current: "a" });
|
||||
});
|
||||
|
||||
it("clears each round separately across several tool calls", () => {
|
||||
let trace = EMPTY_TRACE;
|
||||
for (const round of ["one", "two", "three"]) {
|
||||
trace = withThinking(trace, round);
|
||||
trace = afterToolCall(trace);
|
||||
}
|
||||
|
||||
expect(trace.full).toBe("onetwothree");
|
||||
expect(trace.current).toBe("");
|
||||
});
|
||||
|
||||
it("adopts the authoritative trace the turn ended with", () => {
|
||||
const trace = withFinalTrace("the whole thing");
|
||||
|
||||
expect(trace.full).toBe("the whole thing");
|
||||
expect(trace.current).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildThread, type LiveState } from "../lib/thread";
|
||||
import { buildThread, describeActivity, type LiveState } from "../lib/thread";
|
||||
import type { ChatTurn } from "../lib/types";
|
||||
|
||||
const idle: LiveState = { isStreaming: false, response: "", thinking: "" };
|
||||
|
||||
const activity = { label: "Reading a page", count: 1 };
|
||||
|
||||
function turn(id: string, prompt: string, response: string, overrides: Partial<ChatTurn> = {}): ChatTurn {
|
||||
return {
|
||||
id,
|
||||
@@ -133,3 +135,46 @@ describe("buildThread", () => {
|
||||
expect(thread[0].error).toBe("Request failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("carrying the running tool", () => {
|
||||
const live: LiveState = { isStreaming: true, response: "", thinking: "", activity };
|
||||
|
||||
it("puts it on the message being streamed", () => {
|
||||
const thread = buildThread([turn("t1", "q", "a")], live);
|
||||
const assistant = thread.find((message) => message.role === "assistant");
|
||||
|
||||
expect(assistant?.activity).toEqual(activity);
|
||||
});
|
||||
|
||||
it("keeps it off every earlier message", () => {
|
||||
// Only the newest turn can be mid-flight, and an older answer carrying a
|
||||
// "Searching the web" line would be a claim about a finished turn.
|
||||
const thread = buildThread([turn("t1", "q", "a"), turn("t2", "q2", "a2")], live);
|
||||
|
||||
expect(thread.slice(2).every((message) => message.activity === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("drops it once the turn is over", () => {
|
||||
const thread = buildThread([turn("t1", "q", "a")], { ...idle, activity });
|
||||
|
||||
expect(thread.every((message) => message.activity === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("never puts one on a question", () => {
|
||||
const thread = buildThread([turn("t1", "q", "a")], live);
|
||||
|
||||
expect(thread.find((message) => message.role === "user")?.activity).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeActivity", () => {
|
||||
it("is just the label for a first call", () => {
|
||||
expect(describeActivity({ label: "Reading a page", count: 1 })).toBe("Reading a page");
|
||||
});
|
||||
|
||||
it("says a repeat is a repeat, because a bare number reads as progress", () => {
|
||||
expect(describeActivity({ label: "Reading a page", count: 3 })).toBe(
|
||||
"Reading a page · the same call again (3)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+396
-17
@@ -1,5 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TOOL_DEFINITIONS, WEB_TOOL_NAMES, executeTool, toolsFor } from "../lib/tools";
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { createSourceList } from "../lib/citations";
|
||||
import { createPolicy, type ApproveFn } from "../lib/files";
|
||||
import { TOOL_DEFINITIONS, WEB_TOOL_NAMES, executeTool, toolsFor, type ToolContext } from "../lib/tools";
|
||||
|
||||
/**
|
||||
* Direct coverage for the tool layer.
|
||||
@@ -39,10 +44,12 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("tool registration", () => {
|
||||
it("exposes all four tools to the model", () => {
|
||||
it("exposes the four always-available tools to the model", () => {
|
||||
// The file tools are in the list too, but they are gated by a preference, so the
|
||||
// unconditional ones are what this pins.
|
||||
const names = TOOL_DEFINITIONS.map((tool) => tool.function.name);
|
||||
|
||||
expect(names).toEqual(["get_datetime", "get_location", "web_search", "web_fetch"]);
|
||||
expect(names.slice(0, 4)).toEqual(["get_datetime", "get_location", "web_search", "web_fetch"]);
|
||||
});
|
||||
|
||||
it("marks both web tools as cloud-dependent", () => {
|
||||
@@ -86,7 +93,7 @@ describe("web_search", () => {
|
||||
expect(requestBody()).toEqual({ query: "vicinae launcher", max_results: 3 });
|
||||
});
|
||||
|
||||
it("renders results as numbered title, url, and snippet", async () => {
|
||||
it("renders each result under the number the model will cite", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
results: [
|
||||
@@ -99,9 +106,9 @@ describe("web_search", () => {
|
||||
const result = await executeTool("web_search", { query: "x" }, context);
|
||||
|
||||
expect(result.content).toContain('Web results for "x"');
|
||||
expect(result.content).toContain("1. First");
|
||||
expect(result.content).toContain("[1] First");
|
||||
expect(result.content).toContain("https://first.example");
|
||||
expect(result.content).toContain("2. Second");
|
||||
expect(result.content).toContain("[2] Second");
|
||||
});
|
||||
|
||||
it("omits max_results when the model does not ask for it", async () => {
|
||||
@@ -177,15 +184,13 @@ describe("web_fetch", () => {
|
||||
expect(request().url).toBe("https://ollama.com/api/web_fetch");
|
||||
expect(requestBody()).toEqual({ url: "https://example.com" });
|
||||
|
||||
expect(result.content).toContain("Title: Example");
|
||||
expect(result.content).toContain("[1] Example");
|
||||
expect(result.content).toContain("Body text.");
|
||||
expect(result.content).toContain("https://a.example");
|
||||
});
|
||||
|
||||
it("truncates an enormous page and says so", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ title: "Huge", content: "y".repeat(20_000), links: [] }),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ title: "Huge", content: "y".repeat(20_000), links: [] }));
|
||||
|
||||
const result = await executeTool("web_fetch", { url: "https://huge.example" }, context);
|
||||
|
||||
@@ -198,7 +203,7 @@ describe("web_fetch", () => {
|
||||
|
||||
const result = await executeTool("web_fetch", { url: "https://untitled.example" }, context);
|
||||
|
||||
expect(result.content).toContain("Title: https://untitled.example");
|
||||
expect(result.content).toContain("[1] https://untitled.example");
|
||||
});
|
||||
|
||||
it("rejects a call with no url without touching the network", async () => {
|
||||
@@ -210,12 +215,104 @@ describe("web_fetch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sources", () => {
|
||||
it("keeps numbering across the calls in one turn", async () => {
|
||||
// Two searches in a turn must not each start at 1, or a marker in the answer
|
||||
// would point at two different pages at once.
|
||||
const list = createSourceList();
|
||||
const turn = { ...context, sources: list };
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{ title: "First", url: "https://first.example", content: "First body" },
|
||||
{ title: "Second", url: "https://second.example", content: "Second body" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ results: [{ title: "Third", url: "https://third.example", content: "Third" }] }),
|
||||
);
|
||||
|
||||
const first = await executeTool("web_search", { query: "one" }, turn);
|
||||
const second = await executeTool("web_search", { query: "two" }, turn);
|
||||
|
||||
expect(first.content).toContain("[1] First");
|
||||
expect(second.content).toContain("[3] Third");
|
||||
expect(second.sources?.map((source) => source.index)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("returns only what the call itself added", async () => {
|
||||
// The turn's whole list is the flattening of its records, so a record that
|
||||
// repeated the running total would store the same page once per later call.
|
||||
const list = createSourceList();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{ title: "First", url: "https://first.example", content: "First body" },
|
||||
{ title: "Second", url: "https://second.example", content: "Second body" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await executeTool("web_search", { query: "x" }, { ...context, sources: list });
|
||||
|
||||
expect(result.sources?.map((source) => source.index)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("gives a page the number it already has, however the turn reached it", async () => {
|
||||
const list = createSourceList();
|
||||
const turn = { ...context, sources: list };
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ results: [{ title: "First", url: "https://first.example", content: "B" }] }),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ title: "First", content: "body", links: [] }));
|
||||
|
||||
await executeTool("web_search", { query: "x" }, turn);
|
||||
const page = await executeTool("web_fetch", { url: "https://first.example" }, turn);
|
||||
|
||||
expect(page.content).toContain("[1] First");
|
||||
// Nothing new, so nothing for the turn to store a second time.
|
||||
expect(page.sources).toEqual([]);
|
||||
});
|
||||
|
||||
it("numbers from one when the caller keeps no list of its own", async () => {
|
||||
// The format must not depend on whether anyone is collecting: a test or a
|
||||
// future caller with no interest in citations sees the same numbering.
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ results: [{ title: "First", url: "https://first.example", content: "B" }] }),
|
||||
);
|
||||
|
||||
const result = await executeTool("web_search", { query: "x" }, context);
|
||||
|
||||
expect(result.content).toContain("[1] First");
|
||||
});
|
||||
|
||||
it("prints no number for a result with no address to link to", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ results: [{ title: "Nowhere", url: " ", content: "Body" }] }),
|
||||
);
|
||||
|
||||
const result = await executeTool("web_search", { query: "x" }, context);
|
||||
|
||||
expect(result.content).toContain("Nowhere");
|
||||
expect(result.content).not.toContain("[0]");
|
||||
expect(result.sources).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local tools", () => {
|
||||
it("uses the injected clock instead of the wall clock", async () => {
|
||||
const result = await executeTool("get_datetime", {}, {
|
||||
...context,
|
||||
now: new Date("2026-01-02T03:04:05Z"),
|
||||
});
|
||||
const result = await executeTool(
|
||||
"get_datetime",
|
||||
{},
|
||||
{
|
||||
...context,
|
||||
now: new Date("2026-01-02T03:04:05Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.content).toContain("2026-01-02T03:04:05.000Z");
|
||||
@@ -225,13 +322,147 @@ describe("local tools", () => {
|
||||
expect(result.content).toContain("Current date and time:");
|
||||
});
|
||||
|
||||
it("describes location as a region rather than an address", async () => {
|
||||
it("describes location as a region rather than an address when nothing is configured", async () => {
|
||||
const result = await executeTool("get_location", {}, context);
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.content).toContain("Timezone:");
|
||||
expect(result.content).toContain("Locale:");
|
||||
expect(result.content).toContain("not a precise address");
|
||||
expect(result.content).toContain("no Location is set in preferences");
|
||||
expect(result.content).toContain("Ask which city they are in");
|
||||
});
|
||||
|
||||
it("never claims a city derived from the timezone name", async () => {
|
||||
// `Asia/Manila` is one zone covering the whole Philippines, so reporting
|
||||
// "City: Manila" was wrong for anyone in Cebu and confidently so.
|
||||
const result = await executeTool("get_location", {}, context);
|
||||
|
||||
expect(result.content).not.toMatch(/^City:/m);
|
||||
expect(result.content).toContain("The timezone area is the timezone's own name, not the user's city.");
|
||||
});
|
||||
|
||||
it("puts the configured place first and says to prefer it", async () => {
|
||||
const result = await executeTool(
|
||||
"get_location",
|
||||
{},
|
||||
{
|
||||
...context,
|
||||
place: "Cebu City, Philippines",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.content).toContain("Place: Cebu City, Philippines");
|
||||
expect(result.content).toContain("prefer it");
|
||||
expect(result.content).not.toContain("no Location is set");
|
||||
expect(result.content.indexOf("Place:")).toBeLessThan(result.content.indexOf("Timezone:"));
|
||||
});
|
||||
|
||||
it("still reports the timezone alongside a configured place, for local time questions", async () => {
|
||||
const result = await executeTool("get_location", {}, { ...context, place: "Cebu City" });
|
||||
|
||||
expect(result.content).toContain("Timezone:");
|
||||
expect(result.content).toContain("UTC offset:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_location with IP geolocation", () => {
|
||||
const resolved = {
|
||||
status: "ok" as const,
|
||||
place: {
|
||||
place: "Cebu City, Central Visayas, Philippines",
|
||||
city: "Cebu City",
|
||||
region: "Central Visayas",
|
||||
country: "Philippines",
|
||||
timezone: "Asia/Manila",
|
||||
source: "ipwho.is",
|
||||
fetchedAt: 1_000,
|
||||
},
|
||||
};
|
||||
|
||||
it("reports the resolved place and says how approximate it is", async () => {
|
||||
const ipPlace = vi.fn(async () => resolved);
|
||||
|
||||
const result = await executeTool("get_location", {}, { ...context, ipPlace });
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.content).toContain("Place: Cebu City, Central Visayas, Philippines");
|
||||
expect(result.content).toContain("rough guess");
|
||||
expect(result.content).toContain("ipwho.is");
|
||||
});
|
||||
|
||||
it("does not look the address up at all when the user has said where they are", async () => {
|
||||
// The preference is both more accurate and free, so sending the IP anyway
|
||||
// would be a privacy cost for nothing.
|
||||
const ipPlace = vi.fn(async () => resolved);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_location",
|
||||
{},
|
||||
{
|
||||
...context,
|
||||
place: "Cebu City, Philippines",
|
||||
ipPlace,
|
||||
},
|
||||
);
|
||||
|
||||
expect(ipPlace).not.toHaveBeenCalled();
|
||||
expect(result.content).toContain("Place: Cebu City, Philippines");
|
||||
expect(result.content).not.toContain("ipwho.is");
|
||||
});
|
||||
|
||||
it("surfaces a failed lookup instead of quietly serving the region answer", async () => {
|
||||
const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "ipwho.is answered HTTP 429" }));
|
||||
|
||||
const result = await executeTool("get_location", {}, { ...context, ipPlace });
|
||||
|
||||
expect(result.content).toContain("IP geolocation is switched on but the lookup failed");
|
||||
expect(result.content).toContain("HTTP 429");
|
||||
// The region answer is still there, so the tool stays useful.
|
||||
expect(result.content).toContain("Timezone:");
|
||||
});
|
||||
|
||||
it("does not blame an unset preference when the lookup itself failed", async () => {
|
||||
// These are different problems and send the user to different places. The
|
||||
// original wording said "no Location is set in preferences" for both, which
|
||||
// is exactly what made a broken lookup look like a missing setting.
|
||||
const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "ipwho.is answered HTTP 429" }));
|
||||
|
||||
const result = await executeTool("get_location", {}, { ...context, ipPlace });
|
||||
|
||||
expect(result.content).not.toContain("no Location is set in preferences");
|
||||
});
|
||||
|
||||
it("tells the model to pass a failed lookup on, since it is the only channel to the user", async () => {
|
||||
const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "offline" }));
|
||||
|
||||
const result = await executeTool("get_location", {}, { ...context, ipPlace });
|
||||
|
||||
expect(result.content).toContain("Tell the user");
|
||||
expect(result.content).toContain("Setting Location in preferences avoids the lookup");
|
||||
});
|
||||
|
||||
it("flags an IP timezone that disagrees with the clock, which usually means a VPN", async () => {
|
||||
const ipPlace = vi.fn(async () => ({
|
||||
status: "ok" as const,
|
||||
place: { ...resolved.place, timezone: "America/New_York" },
|
||||
}));
|
||||
|
||||
const result = await executeTool("get_location", {}, { ...context, ipPlace });
|
||||
|
||||
expect(result.content).toContain("differs from the device timezone");
|
||||
});
|
||||
|
||||
it("says nothing about a mismatch when the timezones agree", async () => {
|
||||
const ipPlace = vi.fn(async () => resolved);
|
||||
const result = await executeTool("get_location", {}, { ...context, ipPlace });
|
||||
|
||||
// The test host's own zone varies, so this asserts the absence of the note
|
||||
// only for the case where the provider echoed the same value.
|
||||
if (result.content.includes("IP-reported timezone")) {
|
||||
expect(result.content).toContain("differs");
|
||||
} else {
|
||||
expect(result.content).not.toContain("IP-reported timezone");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -244,3 +475,151 @@ describe("dispatch", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the file tools", () => {
|
||||
let home: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Resolved, because mkdtemp on macOS hands back a path under /var, which is a
|
||||
// symlink: the policy would then be built on one path and check another.
|
||||
home = await realpath(await mkdtemp(path.join(tmpdir(), "lam-tools-")));
|
||||
await mkdir(path.join(home, "Downloads"), { recursive: true });
|
||||
await writeFile(path.join(home, "Downloads", "notes.md"), "alpha\nbeta\n");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const context = (approve: ApproveFn = async () => true): ToolContext => ({
|
||||
host: "h",
|
||||
apiKey: "k",
|
||||
files: { policy: createPolicy({ home }), approve },
|
||||
});
|
||||
|
||||
it("is absent from the list unless file access is on", () => {
|
||||
expect(toolsFor({ webEnabled: true }).some((tool) => tool.function.name === "read_file")).toBe(false);
|
||||
expect(
|
||||
toolsFor({ webEnabled: true, fileEnabled: true }).some((tool) => tool.function.name === "read_file"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the web tools alone when file access is off", () => {
|
||||
const names = toolsFor({ webEnabled: true }).map((tool) => tool.function.name);
|
||||
expect(names).toContain("web_search");
|
||||
expect(names).not.toContain("delete_path");
|
||||
});
|
||||
|
||||
it("reads a file through the policy", async () => {
|
||||
const result = await executeTool(
|
||||
"read_file",
|
||||
{ path: path.join(home, "Downloads", "notes.md") },
|
||||
context(),
|
||||
);
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.content).toContain("alpha");
|
||||
});
|
||||
|
||||
it("writes through the approval, and reports a decline as an error", async () => {
|
||||
const written = await executeTool(
|
||||
"write_file",
|
||||
{ path: path.join(home, "Downloads", "new.md"), content: "x\n" },
|
||||
context(),
|
||||
);
|
||||
expect(written.isError).toBe(false);
|
||||
|
||||
const refused = await executeTool(
|
||||
"write_file",
|
||||
{ path: path.join(home, "Downloads", "other.md"), content: "x\n" },
|
||||
context(async () => false),
|
||||
);
|
||||
expect(refused.isError).toBe(true);
|
||||
expect(refused.content).toContain("declined");
|
||||
});
|
||||
|
||||
it("returns a policy refusal as an error the model can read", async () => {
|
||||
const result = await executeTool("read_file", { path: "/etc/passwd" }, context());
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content).toContain("outside the paths");
|
||||
});
|
||||
|
||||
it("lists a folder", async () => {
|
||||
const result = await executeTool("list_directory", { path: home }, context());
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.content).toContain("Downloads/");
|
||||
});
|
||||
|
||||
it("searches for a file by name", async () => {
|
||||
const result = await executeTool("search_files", { path: home, pattern: "*.md" }, context());
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.content).toContain("notes.md");
|
||||
});
|
||||
|
||||
it("edits a file", async () => {
|
||||
const result = await executeTool(
|
||||
"edit_file",
|
||||
{
|
||||
path: path.join(home, "Downloads", "notes.md"),
|
||||
old_string: "alpha",
|
||||
new_string: "ALPHA",
|
||||
},
|
||||
context(),
|
||||
);
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.content).toContain("1 occurrence");
|
||||
});
|
||||
|
||||
it("deletes and moves", async () => {
|
||||
const moved = await executeTool(
|
||||
"move_path",
|
||||
{
|
||||
from: path.join(home, "Downloads", "notes.md"),
|
||||
to: path.join(home, "Downloads", "moved.md"),
|
||||
},
|
||||
context(),
|
||||
);
|
||||
expect(moved.isError).toBe(false);
|
||||
|
||||
const deleted = await executeTool(
|
||||
"delete_path",
|
||||
{ path: path.join(home, "Downloads", "moved.md") },
|
||||
context(),
|
||||
);
|
||||
expect(deleted.isError).toBe(false);
|
||||
expect(deleted.content).toContain("Deleted");
|
||||
});
|
||||
|
||||
it("says a tool is unavailable when the context carries no policy", async () => {
|
||||
const result = await executeTool("read_file", { path: "~" }, { host: "h", apiKey: "k" });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content).toContain("not available");
|
||||
});
|
||||
|
||||
it("requires its arguments", async () => {
|
||||
const read = await executeTool("read_file", {}, context());
|
||||
expect(read.isError).toBe(true);
|
||||
expect(read.content).toContain("path");
|
||||
|
||||
const search = await executeTool("search_files", { path: home }, context());
|
||||
expect(search.isError).toBe(true);
|
||||
expect(search.content).toContain("pattern");
|
||||
});
|
||||
|
||||
it("labels every file tool for the activity line", () => {
|
||||
const tools = toolsFor({ webEnabled: true, fileEnabled: true });
|
||||
for (const name of [
|
||||
"list_directory",
|
||||
"search_files",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"delete_path",
|
||||
"move_path",
|
||||
]) {
|
||||
expect(
|
||||
tools.some((tool) => tool.function.name === name),
|
||||
name,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
errorMessage,
|
||||
formatBytes,
|
||||
formatRelativeTime,
|
||||
recencyGroup,
|
||||
titleFromPrompt,
|
||||
@@ -98,3 +99,35 @@ describe("errorMessage", () => {
|
||||
expect(typeof errorMessage(circular)).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("matches what `ollama list` prints for the same bytes", () => {
|
||||
// Captured from this machine: `ollama list` shows ornith-1.5:9b as 6.6 GB,
|
||||
// and a size that disagreed with the terminal would read as our bug.
|
||||
expect(formatBytes(6_550_813_657)).toBe("6.6 GB");
|
||||
});
|
||||
|
||||
it("keeps small counts in bytes", () => {
|
||||
expect(formatBytes(0)).toBe("0 B");
|
||||
expect(formatBytes(326)).toBe("326 B");
|
||||
expect(formatBytes(999)).toBe("999 B");
|
||||
});
|
||||
|
||||
it("steps up one unit at a time", () => {
|
||||
expect(formatBytes(1_500)).toBe("1.5 kB");
|
||||
expect(formatBytes(2_500_000)).toBe("2.5 MB");
|
||||
expect(formatBytes(3_000_000_000_000)).toBe("3.0 TB");
|
||||
});
|
||||
|
||||
it("stops at terabytes rather than inventing a unit", () => {
|
||||
expect(formatBytes(9_000_000_000_000_000)).toBe("9000.0 TB");
|
||||
});
|
||||
|
||||
it("has no answer for a size that is absent or nonsense", () => {
|
||||
// undefined rather than "NaN B", so a caller leaves the field out.
|
||||
expect(formatBytes(undefined)).toBeUndefined();
|
||||
expect(formatBytes(-1)).toBeUndefined();
|
||||
expect(formatBytes(Number.NaN)).toBeUndefined();
|
||||
expect(formatBytes(Number.POSITIVE_INFINITY)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Action, ActionPanel, Clipboard, Icon, List, useNavigation } from "@vicinae/api";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { runAutoDelete } from "./lib/cleanup";
|
||||
import { ChatView } from "./lib/components/ChatView";
|
||||
import { sessionToPlainText } from "./lib/markdown";
|
||||
import { loadPreferences } from "./lib/preferences";
|
||||
import { clearSessions, deleteSession, listSessions, loadSession } from "./lib/sessions";
|
||||
import type { SessionSummary } from "./lib/types";
|
||||
import { formatRelativeTime, truncate } from "./lib/utils";
|
||||
@@ -23,7 +25,25 @@ export default function SessionsCommand() {
|
||||
.catch(() => setSessions([]));
|
||||
}, []);
|
||||
|
||||
useEffect(refresh, [refresh]);
|
||||
// Opening the list is the other moment the user is asking about their history,
|
||||
// so it is the other moment expired conversations should be gone. No `keepId`:
|
||||
// nothing is open here to spare. The refresh afterwards is what stops the list
|
||||
// rendering a row that was just deleted from underneath it.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
// Read here rather than at render time: preferences are asynchronous now
|
||||
// that the model selection has to be layered on top of them.
|
||||
const preferences = await loadPreferences();
|
||||
await runAutoDelete({ maxAgeMs: preferences.autoDeleteMs });
|
||||
if (!cancelled) refresh();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const openSession = useCallback(
|
||||
(id: string) => {
|
||||
@@ -58,7 +78,7 @@ export default function SessionsCommand() {
|
||||
return (
|
||||
<List
|
||||
isLoading={!loaded}
|
||||
navigationTitle="Chat Sessions"
|
||||
navigationTitle="Chat History"
|
||||
searchBarPlaceholder="Search conversations…"
|
||||
>
|
||||
{loaded && items.length === 0 ? (
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { readSnipContext } from "./lib/snip";
|
||||
* 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.
|
||||
* also be pushed onto the navigation stack from the Chat History command.
|
||||
*
|
||||
* Assign this command a global shortcut in Vicinae's settings to get the
|
||||
* launcher-and-start-typing behaviour. `fallbackText` is populated when the
|
||||
|
||||
+194
-8
@@ -1,4 +1,5 @@
|
||||
import { MAX_TOOL_ITERATIONS } from "./constants";
|
||||
import { createSourceList } from "./citations";
|
||||
import { DEFAULT_MAX_TOOL_ROUNDS, MAX_REPEATED_TOOL_CALLS } from "./constants";
|
||||
import { streamChat } from "./ollama";
|
||||
import { executeTool, toolLabel, type ToolContext } from "./tools";
|
||||
import type { ApiMessage, ToolCall, ToolCallRecord, ToolDefinition } from "./types";
|
||||
@@ -14,6 +15,11 @@ export interface AgentEvents {
|
||||
onContentDelta?: (delta: string) => void;
|
||||
onThinkingDelta?: (delta: string) => void;
|
||||
onToolCall?: (record: ToolCallRecord) => void;
|
||||
/**
|
||||
* A tool is about to run, with how many times this turn has already made the
|
||||
* identical call, so the UI can tell progress from a rut.
|
||||
*/
|
||||
onToolStart?: (name: string, label: string, count: number) => void;
|
||||
/** A short line for the UI's activity indicator, e.g. "Searching the web". */
|
||||
onStatus?: (status: string) => void;
|
||||
}
|
||||
@@ -26,6 +32,16 @@ export interface AgentOptions {
|
||||
think?: string;
|
||||
messages: ApiMessage[];
|
||||
tools: ToolDefinition[];
|
||||
/**
|
||||
* Rounds of tool calls this turn may spend before it has to answer.
|
||||
*
|
||||
* Omitted means {@link DEFAULT_MAX_TOOL_ROUNDS}, and `null` means no cap at
|
||||
* all. Unlimited is spelled as an explicit `null` rather than as an omitted
|
||||
* option because the opposite arrangement makes "no cap" the default for a
|
||||
* function whose whole job is to stop looping, and a caller that forgets to
|
||||
* pass a preference should not be the one that removes the guard.
|
||||
*/
|
||||
maxToolRounds?: number | null;
|
||||
toolContext: Omit<ToolContext, "signal" | "now"> & { now?: Date };
|
||||
signal?: AbortSignal;
|
||||
events?: AgentEvents;
|
||||
@@ -59,12 +75,94 @@ function normalizeToolCall(call: ToolCall): { name: string; arguments: Record<st
|
||||
return { name, arguments: args as Record<string, unknown> };
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable key for "the same call".
|
||||
*
|
||||
* Keys are sorted so that a model which writes the same arguments in a different
|
||||
* order still counts as repeating itself, which is the shape the rut usually
|
||||
* takes. Values are compared as JSON, so two searches for the same query count as
|
||||
* one call, which is the point: the second one will not return anything the first
|
||||
* did not.
|
||||
*/
|
||||
function callSignature(name: string, args: Record<string, unknown>): string {
|
||||
const sorted = Object.keys(args)
|
||||
.sort()
|
||||
.map((key) => [key, args[key]] as const);
|
||||
|
||||
return `${name}(${JSON.stringify(sorted)})`;
|
||||
}
|
||||
|
||||
/** Why the tool loop stopped short: the budget ran out, or the model got stuck. */
|
||||
type StopReason = { kind: "budget"; rounds: number } | { kind: "repeat"; name: string; count: number };
|
||||
|
||||
/** Sent to the model in place of a tool result, so the protocol stays valid and it learns why. */
|
||||
function repeatedNotice(name: string, count: number): string {
|
||||
return `Refused: this turn has now asked for \`${name}\` with identical arguments ${count} times, so running it again will not return anything new. No more tool calls are available. Answer with what you have already gathered.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The last word, when the tool budget runs out.
|
||||
*
|
||||
* The loop used to end by throwing away every preamble the model had written and
|
||||
* returning a notice about itself, so a long research question produced no answer
|
||||
* at all. One closing round, with the tools taken away, turns that into an answer
|
||||
* built from whatever was already gathered, with the gaps named. The instruction
|
||||
* asks for the gaps because an answer that knows what it is missing is worth more
|
||||
* than a confident half of one.
|
||||
*/
|
||||
const CLOSING_INSTRUCTION =
|
||||
"You have reached the limit on tool calls for this turn, so no more tools are available. Answer now using what you have already gathered. If something is missing, say plainly which parts you could not confirm.";
|
||||
|
||||
/**
|
||||
* The closing instruction when the loop stopped because the model was repeating itself.
|
||||
*
|
||||
* Worth saying outright rather than reusing the budget wording: the model has no
|
||||
* way to know it was caught going in circles, and naming the call is what turns
|
||||
* "I ran out of tools" into something it can act on.
|
||||
*/
|
||||
function closingInstruction(reason: StopReason): string {
|
||||
if (reason.kind === "budget") return CLOSING_INSTRUCTION;
|
||||
|
||||
return `This turn asked for \`${reason.name}\` with identical arguments ${reason.count} times, so no more tools are available. Answer now using what you have already gathered. If something is missing, say plainly which parts you could not confirm.`;
|
||||
}
|
||||
|
||||
/** Only reachable if the closing round comes back empty too. */
|
||||
function exhaustedNotice(reason: StopReason): string {
|
||||
if (reason.kind === "repeat") {
|
||||
return `_Stopped because \`${reason.name}\` kept repeating the same call, and could not write an answer from what it had. Try asking something narrower._`;
|
||||
}
|
||||
|
||||
return `_Ran out of tool calls after ${reason.rounds} rounds and could not write an answer. Try asking something narrower._`;
|
||||
}
|
||||
|
||||
export async function runAgent(options: AgentOptions): Promise<AgentResult> {
|
||||
const conversation: ApiMessage[] = [...options.messages];
|
||||
const toolCalls: ToolCallRecord[] = [];
|
||||
/**
|
||||
* The turn's sources, so one number means one page for the whole turn.
|
||||
*
|
||||
* Created here rather than passed in: it is scoped to a single run of this loop,
|
||||
* and every call the loop makes shares it, which is what keeps three searches in
|
||||
* one answer from each starting again at 1.
|
||||
*/
|
||||
const sources = createSourceList();
|
||||
/** `Infinity` needs no special case: the loop simply never reaches its own end. */
|
||||
const rounds =
|
||||
options.maxToolRounds === null ? Infinity : (options.maxToolRounds ?? DEFAULT_MAX_TOOL_ROUNDS);
|
||||
/** How many times this turn has asked for each identical call. */
|
||||
const repeats = new Map<string, number>();
|
||||
let thinking = "";
|
||||
/**
|
||||
* The most recent preamble the model wrote.
|
||||
*
|
||||
* Not normally shown, because a preamble is what it says before finding out
|
||||
* anything. Kept anyway, so that a turn which runs out of budget still has
|
||||
* something to fall back on if the closing round produces nothing.
|
||||
*/
|
||||
let lastPreamble = "";
|
||||
let stopped: StopReason | undefined;
|
||||
|
||||
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
|
||||
for (let iteration = 0; iteration < rounds; iteration++) {
|
||||
let content = "";
|
||||
let streamedThinking = "";
|
||||
let pending: ToolCall[] | undefined;
|
||||
@@ -91,43 +189,131 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
|
||||
|
||||
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.
|
||||
// No tool calls means this was the model's answer, rather than something it
|
||||
// said on the way to finding out.
|
||||
if (!pending || pending.length === 0) {
|
||||
return { content, thinking, toolCalls };
|
||||
}
|
||||
|
||||
if (content.trim()) lastPreamble = content;
|
||||
|
||||
conversation.push({ role: "assistant", content, tool_calls: pending });
|
||||
|
||||
let repeated: { name: string; count: number } | undefined;
|
||||
|
||||
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" });
|
||||
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;
|
||||
}
|
||||
|
||||
const signature = callSignature(name, args);
|
||||
const count = (repeats.get(signature) ?? 0) + 1;
|
||||
repeats.set(signature, count);
|
||||
|
||||
// Every tool call in a round needs a result, so a refused one is answered
|
||||
// with the refusal rather than skipped: dropping it would leave the next
|
||||
// request carrying a tool call the protocol says must be answered.
|
||||
if (count >= MAX_REPEATED_TOOL_CALLS) {
|
||||
repeated = { name, count };
|
||||
|
||||
const refusal = repeatedNotice(name, count);
|
||||
const record: ToolCallRecord = {
|
||||
name,
|
||||
arguments: args,
|
||||
result: refusal,
|
||||
isError: true,
|
||||
repeated: true,
|
||||
};
|
||||
|
||||
conversation.push({ role: "tool", content: refusal, tool_name: name });
|
||||
toolCalls.push(record);
|
||||
options.events?.onToolCall?.(record);
|
||||
continue;
|
||||
}
|
||||
|
||||
options.events?.onToolStart?.(name, toolLabel(name), count);
|
||||
options.events?.onStatus?.(toolLabel(name));
|
||||
|
||||
const result = await executeTool(name, args, {
|
||||
host: options.toolContext.host,
|
||||
apiKey: options.toolContext.apiKey,
|
||||
place: options.toolContext.place,
|
||||
now: options.toolContext.now,
|
||||
signal: options.signal,
|
||||
sources,
|
||||
// The policy and the ledger travel with the turn, so a second write to a path
|
||||
// the user already approved does not ask again, and a new question starts
|
||||
// asking from the beginning.
|
||||
files: options.toolContext.files,
|
||||
});
|
||||
|
||||
const record: ToolCallRecord = { name, arguments: args, result: result.content, isError: result.isError };
|
||||
const record: ToolCallRecord = {
|
||||
name,
|
||||
arguments: args,
|
||||
result: result.content,
|
||||
isError: result.isError,
|
||||
// Only what this call added. The turn's whole list is the flattening of
|
||||
// its records, so the running total is deliberately not repeated here.
|
||||
sources: result.sources,
|
||||
};
|
||||
toolCalls.push(record);
|
||||
options.events?.onToolCall?.(record);
|
||||
|
||||
conversation.push({ role: "tool", content: result.content, tool_name: name });
|
||||
}
|
||||
|
||||
// The rest of the round still ran, because every call in it needs an answer,
|
||||
// but the turn is over: the model is going in circles and one more round
|
||||
// would only add one more circle.
|
||||
if (repeated) {
|
||||
stopped = { kind: "repeat", name: repeated.name, count: repeated.count };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The loop is done, either because the budget is spent or because the model got
|
||||
// stuck. Either way the turn gets one closing round with the tools taken away.
|
||||
// Ending on an answer drawn from what was gathered beats ending on a notice
|
||||
// about the loop itself, and the model is the only one that can say what it
|
||||
// did and did not manage to confirm.
|
||||
const reason: StopReason = stopped ?? { kind: "budget", rounds };
|
||||
|
||||
options.events?.onStatus?.("Writing the answer");
|
||||
|
||||
conversation.push({ role: "user", content: closingInstruction(reason) });
|
||||
|
||||
let closing = "";
|
||||
|
||||
for await (const chunk of streamChat({
|
||||
host: options.host,
|
||||
apiKey: options.apiKey,
|
||||
model: options.model,
|
||||
messages: conversation,
|
||||
// Deliberately no tools: the answer has to be written from what is already here.
|
||||
tools: undefined,
|
||||
think: options.think,
|
||||
signal: options.signal,
|
||||
})) {
|
||||
if (chunk.thinking) {
|
||||
thinking += chunk.thinking;
|
||||
options.events?.onThinkingDelta?.(chunk.thinking);
|
||||
}
|
||||
if (chunk.content) {
|
||||
closing += chunk.content;
|
||||
options.events?.onContentDelta?.(chunk.content);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: `_Stopped after ${MAX_TOOL_ITERATIONS} rounds of tool calls without a final answer._`,
|
||||
content: closing.trim() || lastPreamble.trim() || exhaustedNotice(reason),
|
||||
thinking,
|
||||
toolCalls,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Alert, confirmAlert } from "@vicinae/api";
|
||||
import type { ApprovalRequest, ApproveFn, FileOperation } from "./files";
|
||||
|
||||
/**
|
||||
* The approval step, and the only file in this feature that imports Vicinae.
|
||||
*
|
||||
* Kept apart from `files.ts` on purpose: the policy there is a value and this is a
|
||||
* dialog, so the boundary can be tested without a launcher and the wording can be
|
||||
* changed without touching a path rule.
|
||||
*/
|
||||
|
||||
export interface FileApproval {
|
||||
title: string;
|
||||
/** Every line the user needs: the path first, then what changes. */
|
||||
body: string;
|
||||
/** A delete or a move, which the dialog styles differently. */
|
||||
destructive: boolean;
|
||||
}
|
||||
|
||||
const VERBS: Record<FileOperation, string> = {
|
||||
create: "Create",
|
||||
overwrite: "Overwrite",
|
||||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
move: "Move",
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns a request into the words the user reads.
|
||||
*
|
||||
* The path is the second thing in the body and is never omitted: it is the thing
|
||||
* being approved, and a dialog that said "Lam wants to write a file" would leave the
|
||||
* user with nothing to judge.
|
||||
*/
|
||||
export function describeApproval(request: ApprovalRequest): FileApproval {
|
||||
return {
|
||||
title: `${VERBS[request.operation]} ${request.subject}?`,
|
||||
body: [request.display, ...request.details].join("\n"),
|
||||
destructive: request.irreversible,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The ledger: one approval per resolved path, per answer, except for the
|
||||
* irreversible operations, which ask every time.
|
||||
*
|
||||
* Per answer rather than per session because a session can run for hours, and because
|
||||
* "yes, edit that file while we work on it" is not consent to whatever Lam decides to
|
||||
* do with it twenty minutes later.
|
||||
*
|
||||
* Keyed on the resolved path, so `~/Downloads/x.md` and `/Users/me/Downloads/x.md`
|
||||
* are one entry: they are one file, and keying on the written form would let a second
|
||||
* spelling of the same path slip past an approval the user already gave for it.
|
||||
*/
|
||||
export function createApprovalLedger(confirm: (approval: FileApproval) => Promise<boolean>): {
|
||||
approve: ApproveFn;
|
||||
} {
|
||||
const approved = new Set<string>();
|
||||
|
||||
return {
|
||||
async approve(request: ApprovalRequest): Promise<boolean> {
|
||||
if (!request.irreversible && approved.has(request.path)) return true;
|
||||
|
||||
// A dialog that cannot be shown is not consent, and neither is an exception
|
||||
// thrown while trying to show one, so both come back as a refusal. This is the
|
||||
// fail-closed half of the approval story, and it is here rather than at a call
|
||||
// site because every call site would have to remember it.
|
||||
const granted = await confirm(describeApproval(request)).catch(() => false);
|
||||
if (granted && !request.irreversible) approved.add(request.path);
|
||||
|
||||
return granted;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The dialog itself.
|
||||
*
|
||||
* Vicinae's `rememberUserChoice` is declared in the API but not implemented in the
|
||||
* server, so nothing here offers "do not ask again": the ledger above is the only
|
||||
* memory, and it lasts one answer.
|
||||
*/
|
||||
export function confirmWithAlert(approval: FileApproval): Promise<boolean> {
|
||||
return confirmAlert({
|
||||
title: approval.title,
|
||||
message: approval.body,
|
||||
primaryAction: {
|
||||
title: "Allow",
|
||||
style: approval.destructive ? Alert.ActionStyle.Destructive : Alert.ActionStyle.Default,
|
||||
},
|
||||
dismissAction: { title: "Decline", style: Alert.ActionStyle.Cancel },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { mapOutsideCodeSpans, markdownLink } from "./code";
|
||||
import type { Source, ToolCallRecord } from "./types";
|
||||
|
||||
/**
|
||||
* Numbered sources for one turn, and the citation markers that point at them.
|
||||
*
|
||||
* Vicinae has no footnote support to lean on: it parses markdown with
|
||||
* `CMARK_OPT_DEFAULT` and attaches only the table and strikethrough extensions
|
||||
* (`src/server/src/ui/quick/markdown-model.cpp` upstream), so a `[^1]` written by
|
||||
* a model arrives in the pane as literal text. Everything a footnote would do
|
||||
* therefore has to happen here, on the way to the renderer.
|
||||
*
|
||||
* Imports no Vicinae module, so it unit tests with no runtime.
|
||||
*/
|
||||
|
||||
/** What a turn uses to hand out numbers, one per page it consults. */
|
||||
export interface SourceList {
|
||||
/**
|
||||
* The number for a URL, registering it the first time it is seen.
|
||||
*
|
||||
* `index` is 0 for a result with no usable address, which is the caller's
|
||||
* signal to print no marker at all. `source` comes back only when this call
|
||||
* registered something new, so a caller records each page exactly once.
|
||||
*/
|
||||
add(title: string, url: string): { index: number; source?: Source };
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh list, numbering from one.
|
||||
*
|
||||
* Scoped to a turn rather than a conversation: a new question starts again at
|
||||
* one, which is what lets a marker mean one thing for the whole answer beside
|
||||
* it, and lets the transcript list sources per answer rather than per session.
|
||||
*/
|
||||
export function createSourceList(): SourceList {
|
||||
const byUrl = new Map<string, Source>();
|
||||
let next = 1;
|
||||
|
||||
return {
|
||||
add(title, url) {
|
||||
const address = url.trim();
|
||||
if (address.length === 0) return { index: 0 };
|
||||
|
||||
const known = byUrl.get(address);
|
||||
if (known) return { index: known.index };
|
||||
|
||||
const source: Source = { index: next, title: title.trim() || address, url: address };
|
||||
next += 1;
|
||||
byUrl.set(address, source);
|
||||
|
||||
return { index: source.index, source };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every source a turn consulted, in number order.
|
||||
*
|
||||
* A record holds only what its own call added, so the turn's list is the
|
||||
* flattening of its records. That keeps the running total from being stored once
|
||||
* per later call, and it means the list can be rebuilt from a stored turn without
|
||||
* re-reading a tool result string and hoping its format held.
|
||||
*/
|
||||
export function collectSources(calls: ToolCallRecord[]): Source[] {
|
||||
const byIndex = new Map<number, Source>();
|
||||
|
||||
for (const call of calls) {
|
||||
for (const source of call.sources ?? []) {
|
||||
// First wins. A number is assigned once, so a second record carrying it is
|
||||
// describing the same page, and the first one is what named it.
|
||||
if (!byIndex.has(source.index)) byIndex.set(source.index, source);
|
||||
}
|
||||
}
|
||||
|
||||
return [...byIndex.values()].sort((left, right) => left.index - right.index);
|
||||
}
|
||||
|
||||
/**
|
||||
* The numbers an answer cites that are actually sources.
|
||||
*
|
||||
* A plain scan, without the fence awareness the linking pass has, because its one
|
||||
* caller is the clipboard export: what a copied document needs is the pages the
|
||||
* answer leaned on, and a `[1]` inside a snippet there costs a reader nothing.
|
||||
*/
|
||||
export function citedIndices(answer: string, sources: Source[]): Set<number> {
|
||||
const known = new Set(sources.map((source) => source.index));
|
||||
const cited = new Set<number>();
|
||||
|
||||
for (const match of answer.matchAll(/\[\^?(\d+(?:\s*,\s*\d+)*)\]/g)) {
|
||||
for (const part of match[1].split(",")) {
|
||||
const index = Number(part.trim());
|
||||
if (known.has(index)) cited.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
return cited;
|
||||
}
|
||||
|
||||
/** What linking did to one run of prose. */
|
||||
export interface CitationResult {
|
||||
text: string;
|
||||
/** The numbers that became links, which is what the list filters on. */
|
||||
cited: Set<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One citation marker, in the two shapes a model reaches for, plus any markers
|
||||
* written straight after it.
|
||||
*
|
||||
* Adjacent markers are matched as a single run, because a raised digit carries no
|
||||
* separator of its own: `[1][2]` drawn as `¹²` reads as twelve, or as twenty one, and
|
||||
* either way as one number rather than as two sources.
|
||||
*
|
||||
* The trailing guards are what keep this off markdown the model wrote on purpose:
|
||||
* `](` starts a link, and `]:` is a reference definition. The digit list allows
|
||||
* `[1, 3]`, which is how a sentence resting on two sources is often written.
|
||||
*/
|
||||
const CITATION_RUN = /\[\^?\d+(?:\s*,\s*\d+)*\](?![(:])(?:[ \t]*\[\^?\d+(?:\s*,\s*\d+)*\](?![(:]))*/g;
|
||||
|
||||
/** The numbers inside one run, in the order they were written. */
|
||||
const RUN_NUMBERS = /\d+/g;
|
||||
|
||||
/** The digits a superscript marker is built from, in order. */
|
||||
const DIGITS = "0123456789";
|
||||
const SUPERSCRIPT = "⁰¹²³⁴⁵⁶⁷⁸⁹";
|
||||
|
||||
/**
|
||||
* A number as a superscript, which is what a citation mark is.
|
||||
*
|
||||
* Unicode rather than a `<sup>` tag on purpose. This is the label of a markdown
|
||||
* link, and a plain label cannot be broken by any part of the pipeline, while
|
||||
* inline HTML in a label asks two renderers to agree about something neither this
|
||||
* extension nor its tests can see. The glyphs are raised by design, so the mark
|
||||
* still reads as a superscript even when a font lacks the rarer ones and Qt falls
|
||||
* back for them.
|
||||
*/
|
||||
function superscript(index: number): string {
|
||||
let out = "";
|
||||
|
||||
for (const digit of String(index)) {
|
||||
const at = DIGITS.indexOf(digit);
|
||||
out += at >= 0 ? SUPERSCRIPT[at] : digit;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A footnote definition line, which is `[^1]: title, url` on a line of its own.
|
||||
*
|
||||
* The caret is required rather than optional, and the two shapes mean different
|
||||
* things. `[1]: something` is markdown's own reference definition syntax, which
|
||||
* backs a link the model wrote, so dropping one because it happens to carry a
|
||||
* source number would break that link. `[^1]:` is a footnote definition, which is
|
||||
* a duplicate of an entry we render ourselves.
|
||||
*/
|
||||
const FOOTNOTE_DEFINITION = /^\s*\[\^(\d+)\]:\s*\S/;
|
||||
|
||||
/**
|
||||
* A destination this window can safely be asked to open, or nothing.
|
||||
*
|
||||
* A search result is untrusted input, so the scheme is checked here for the same
|
||||
* reason `SAFE_URL` in `html.ts` checks it: a clickable `javascript:` link would
|
||||
* be a real vulnerability rather than a rendering quirk. The rest of the test is
|
||||
* characters that would break the link or smuggle a second one out of it.
|
||||
*/
|
||||
function linkableUrl(url: string): string | undefined {
|
||||
const address = url.trim();
|
||||
if (!/^https?:\/\//i.test(address)) return undefined;
|
||||
if (/[\s<>\\]/.test(address)) return undefined;
|
||||
|
||||
// Control characters by code point. A character class would say the same thing
|
||||
// and trip `no-control-regex`, and a NUL or a DEL in a destination is never
|
||||
// anything but damage, so it is refused either way.
|
||||
for (const character of address) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
if (code < 0x20 || code === 0x7f) return undefined;
|
||||
}
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a definition line for a source the turn already lists.
|
||||
*
|
||||
* The entry is drawn from what the tool returned, so the model's copy of the same
|
||||
* thing is a duplicate line rather than information. One for a number we know
|
||||
* nothing about is left alone, because then it is the only place that text exists.
|
||||
*/
|
||||
function stripDefinitions(text: string, known: Set<number>): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((line) => {
|
||||
const definition = FOOTNOTE_DEFINITION.exec(line);
|
||||
return !definition || !known.has(Number(definition[1]));
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* `[1]`, `[^1]`, `[1, 3]` and `[1][2]` as superscript links, ordered ascending and
|
||||
* each page once, plus the numbers that became links.
|
||||
*
|
||||
* One run of prose only: fences are the caller's business, because the caller
|
||||
* already walks the document in segments for the maths pass and there is no
|
||||
* reason for two ideas of where code starts.
|
||||
*/
|
||||
export function linkCitations(text: string, sources: Source[]): CitationResult {
|
||||
const cited = new Set<number>();
|
||||
if (sources.length === 0) return { text, cited };
|
||||
|
||||
const byIndex = new Map(sources.map((source) => [source.index, source]));
|
||||
const prose = stripDefinitions(text, new Set(byIndex.keys()));
|
||||
|
||||
const linked = mapOutsideCodeSpans(prose, (chunk) =>
|
||||
chunk.replace(CITATION_RUN, (whole: string) => {
|
||||
/**
|
||||
* Ascending, and each page once.
|
||||
*
|
||||
* A run is a set of sources, and a model lists them in the order it thought of
|
||||
* them rather than by number: a real answer sent `[2][1]` for a sentence resting
|
||||
* on both, which drew as "two, one", and `[8][4]` for another. The numbers
|
||||
* themselves are never renumbered, so `[3]` is still the third source the turn
|
||||
* found and the reader can find it in the list.
|
||||
*/
|
||||
const named = [...whole.matchAll(RUN_NUMBERS)].map((match) => Number(match[0]));
|
||||
const wanted = [...new Set(named)].sort((left, right) => left - right);
|
||||
const links: string[] = [];
|
||||
|
||||
for (const index of wanted) {
|
||||
const source = byIndex.get(index);
|
||||
const href = source ? linkableUrl(source.url) : undefined;
|
||||
// All or nothing, and nothing at all if a destination is unusable: a
|
||||
// marker that is partly a link reads as a citation that works.
|
||||
if (!href) return whole;
|
||||
|
||||
links.push(markdownLink(superscript(index), href));
|
||||
}
|
||||
|
||||
for (const index of wanted) cited.add(index);
|
||||
// Separated even when the model wrote them touching: `¹ ²` is two citations,
|
||||
// where `¹²` is a number, and the reader has no way to tell it from a real one.
|
||||
// A space rather than a comma, which sits at the baseline and so drops away from
|
||||
// digits that are raised above it.
|
||||
return links.join(" ");
|
||||
}),
|
||||
);
|
||||
|
||||
return { text: linked, cited };
|
||||
}
|
||||
|
||||
/**
|
||||
* The sources under an answer, or nothing when there is nothing to say.
|
||||
*
|
||||
* The marker is the link, matching the inline marker character for character, so
|
||||
* one token means one thing in both places. Numbers are the model's rather than
|
||||
* the reader's: `[3]` is the third source the turn found whether or not the answer
|
||||
* cited the two before it, which keeps the transcript agreeing with the tool
|
||||
* output and with anything the model said about its own numbering.
|
||||
*/
|
||||
export function renderSources(sources: Source[], cited: Set<number>, streaming: boolean): string {
|
||||
// While an answer is arriving the cited set is still filling up, so the list
|
||||
// waits rather than appearing in full and then dropping most of itself. An
|
||||
// answer that cites nothing at all is the one case left to fall back on
|
||||
// everything consulted, because there is then nothing to filter by.
|
||||
const shown =
|
||||
cited.size > 0 ? sources.filter((source) => cited.has(source.index)) : streaming ? [] : sources;
|
||||
if (shown.length === 0) return "";
|
||||
|
||||
const entries = shown.map((source) => {
|
||||
const href = linkableUrl(source.url);
|
||||
const marker = href ? markdownLink(`[${source.index}]`, href) : `[${source.index}]`;
|
||||
return `- ${marker} ${source.title}`;
|
||||
});
|
||||
|
||||
return ["_Sources_", "", ...entries].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Toast, showToast } from "@vicinae/api";
|
||||
import { describeRemoval } from "./retention";
|
||||
import { clearSessions, cullOrphanedSnips, sweepExpiredSessions } from "./sessions";
|
||||
|
||||
/**
|
||||
* The tidy-ups, and telling the user what they did.
|
||||
*
|
||||
* Split out from `retention.ts` so that file can stay a pure decision with every
|
||||
* side effect injected, and out of the commands so that four call sites do not
|
||||
* each carry their own copy of the same toast. This is the one place that knows
|
||||
* both what was deleted and how to say it.
|
||||
*
|
||||
* Two audiences live here, and they want opposite things. The background sweep
|
||||
* stays silent unless it did something, because a notification on every launch is
|
||||
* one people learn to dismiss without reading. The two actions in the setup screen
|
||||
* always answer, because the user pressed a button and silence would read as a
|
||||
* failure.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The retention sweep, run on the way into a command.
|
||||
*
|
||||
* Silent when nothing was deleted.
|
||||
*/
|
||||
export async function runAutoDelete(options: { maxAgeMs?: number; keepId?: string }): Promise<void> {
|
||||
const outcome = await sweepExpiredSessions(options);
|
||||
const summary = describeRemoval(outcome);
|
||||
if (!summary) return;
|
||||
|
||||
await showToast({
|
||||
style: Toast.Style.Success,
|
||||
title: "Cleared out old conversations",
|
||||
message: summary,
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes every conversation, and every screenshot they captured. */
|
||||
export async function clearAllChats(): Promise<void> {
|
||||
const counts = await clearSessions();
|
||||
|
||||
await showToast({
|
||||
style: Toast.Style.Success,
|
||||
title: "Cleared out all conversations",
|
||||
message: describeRemoval(counts) ?? "there was nothing to delete",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the captures no conversation refers to any more.
|
||||
*
|
||||
* The failure case is a refusal, not a failure to delete: when a conversation
|
||||
* cannot be read, there is no way to know which files are still in use, and
|
||||
* guessing there destroys real images. So it says so instead.
|
||||
*/
|
||||
export async function clearOrphanedSnips(): Promise<void> {
|
||||
const removed = await cullOrphanedSnips();
|
||||
|
||||
if (removed === undefined) {
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
title: "Could not check which photos are in use",
|
||||
message: "A conversation could not be read, so nothing was deleted.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await showToast({
|
||||
style: Toast.Style.Success,
|
||||
title: "Cleared out unattached photos",
|
||||
message: describeRemoval({ sessions: 0, files: removed }) ?? "there was nothing to delete",
|
||||
});
|
||||
}
|
||||
+254
-21
@@ -82,10 +82,14 @@ const FENCE = "```";
|
||||
* bare ``` are the same two lines apart from context, so a pattern that only
|
||||
* matched bare fences treated every closing fence as a new opening one and
|
||||
* swallowed the rest of the answer.
|
||||
*
|
||||
* Exported because the maths and HTML passes have to skip fenced code for the
|
||||
* same reason this file does, and a second copy of the pattern is a second
|
||||
* place to get it wrong.
|
||||
*/
|
||||
const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*([^\s`~]*)\s*$/;
|
||||
export const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*([^\s`~]*)\s*$/;
|
||||
/** A closing fence is the marker and nothing else. */
|
||||
const CLOSING_FENCE = /^\s*(`{3,}|~{3,})\s*$/;
|
||||
export const CLOSING_FENCE = /^\s*(`{3,}|~{3,})\s*$/;
|
||||
const STANDALONE_CODE_SPAN = /^\s*`([^`\n]+)`\s*[.!]?\s*$/;
|
||||
|
||||
/**
|
||||
@@ -107,7 +111,7 @@ const STANDALONE_URL_SPAN = /^`([^`\n]+)`[.!]?$/;
|
||||
const LIST_MARKER = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)(.*)$/;
|
||||
|
||||
/** A GFM table row: it opens and closes with a pipe. */
|
||||
const TABLE_ROW = /^\s*\|.*\|\s*$/;
|
||||
export const TABLE_ROW = /^\s*\|.*\|\s*$/;
|
||||
/**
|
||||
* The delimiter row under a header.
|
||||
*
|
||||
@@ -115,7 +119,72 @@ const TABLE_ROW = /^\s*\|.*\|\s*$/;
|
||||
* column. It is that the line could be nothing else: pipes, dashes, colons and
|
||||
* spaces, with at least one dash to tell it apart from a row of empty cells.
|
||||
*/
|
||||
const TABLE_DELIMITER_ROW = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
|
||||
export const TABLE_DELIMITER_ROW = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
|
||||
|
||||
/**
|
||||
* A run of backticks, and where the span it opens ends.
|
||||
*
|
||||
* The run length is part of the delimiter in markdown, and `` `a` `` inside a
|
||||
* double-backtick span is content rather than a closer, so the search is for a
|
||||
* run of exactly the same length. A run with no closer is literal text, so only
|
||||
* the backticks are consumed.
|
||||
*/
|
||||
export function readCodeSpan(text: string, start: number): { raw: string; end: number } {
|
||||
let length = 0;
|
||||
while (text[start + length] === "`") length += 1;
|
||||
|
||||
const marker = "`".repeat(length);
|
||||
let cursor = start + length;
|
||||
|
||||
while (cursor < text.length) {
|
||||
const found = text.indexOf(marker, cursor);
|
||||
if (found === -1) break;
|
||||
|
||||
if (text[found + length] === "`") {
|
||||
cursor = found + length;
|
||||
while (text[cursor] === "`") cursor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
return { raw: text.slice(start, found + length), end: found + length };
|
||||
}
|
||||
|
||||
return { raw: marker, end: start + length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies `transform` to everything outside a code span, leaving the spans
|
||||
* exactly as they were written.
|
||||
*
|
||||
* Every pass that rewrites a message needs this for the same reason: a
|
||||
* `` `x^2` `` or a `` `<b>` `` is something being shown to the reader, not
|
||||
* something to convert. Shared so the rule for what a span is exists once.
|
||||
*/
|
||||
export function mapOutsideCodeSpans(text: string, transform: (text: string) => string): string {
|
||||
let out = "";
|
||||
let index = 0;
|
||||
let start = 0;
|
||||
let found = false;
|
||||
|
||||
while (index < text.length) {
|
||||
if (text[index] !== "`") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
found = true;
|
||||
const span = readCodeSpan(text, index);
|
||||
out += transform(text.slice(start, index));
|
||||
out += span.raw;
|
||||
index = span.end;
|
||||
start = index;
|
||||
}
|
||||
|
||||
if (!found) return transform(text);
|
||||
|
||||
out += transform(text.slice(start));
|
||||
return out;
|
||||
}
|
||||
|
||||
function significantLines(code: string): string[] {
|
||||
return code
|
||||
@@ -308,22 +377,30 @@ function asBareUrl(candidate: string): string | undefined {
|
||||
return trimmed.replace(TRAILING_SENTENCE_PUNCTUATION, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A markdown link, escaped the way the parser needs it.
|
||||
*
|
||||
* Shared with the HTML pass, which rewrites an `<a href>` into the one shape
|
||||
* Vicinae makes clickable. Square brackets in the *label* would end the link
|
||||
* text early, and a destination containing parentheses (plenty of Wikipedia
|
||||
* articles) has to be wrapped in pointy brackets or the parser reads the first
|
||||
* `)` as the end of the destination and leaves the rest as trailing prose.
|
||||
*/
|
||||
export function markdownLink(label: string, destination: string): string {
|
||||
const escaped = label.replace(/[[\]]/g, "\\$&");
|
||||
const target = /[()\s]/.test(destination) ? `<${destination}>` : destination;
|
||||
return `[${escaped}](${target})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a URL as a markdown link, using the address itself as the label so the
|
||||
* destination stays readable rather than turning into a generic word.
|
||||
*
|
||||
* A destination containing parentheses (plenty of Wikipedia articles) has to
|
||||
* be wrapped in pointy brackets or the parser reads the first `)` as the end of
|
||||
* the destination and leaves the rest as trailing prose.
|
||||
*/
|
||||
function asLink(url: string): string {
|
||||
// Square brackets in the *label* would end the link text early, which is why
|
||||
// an IPv6 literal such as `http://[::1]/` needs them escaped. The destination
|
||||
// tolerates brackets, but not parentheses, which is what the pointy form is
|
||||
// for.
|
||||
const label = url.replace(/[[\]]/g, "\\$&");
|
||||
const destination = /[()]/.test(url) ? `<${url}>` : url;
|
||||
return `[${label}](${destination})`;
|
||||
// An IPv6 literal such as `http://[::1]/` carries the brackets in the label,
|
||||
// which `markdownLink` escapes. The destination tolerates brackets but not
|
||||
// parentheses, which is what the pointy form is for.
|
||||
return markdownLink(url, url);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -455,16 +532,172 @@ export function separateTables(markdown: string): string {
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fence languages that draw a label but no colour, and what to say instead.
|
||||
*
|
||||
* `syntax::highlight` in Vicinae resolves a language with
|
||||
* `definitionForName(language)` and then `definitionForFileName("file." + language)`,
|
||||
* so a name KSyntaxHighlighting has never heard of still gets printed above the
|
||||
* block while producing no highlighting at all. A model reaching for "terminal"
|
||||
* or "console" is describing a shell, so it is told a name that resolves. The
|
||||
* `undefined` entries are the ones that carry no information whatsoever, and a
|
||||
* bare fence is the honest rendering of those.
|
||||
*/
|
||||
const LANGUAGE_ALIASES: Record<string, string | undefined> = {
|
||||
console: "bash",
|
||||
terminal: "bash",
|
||||
shell: "bash",
|
||||
commandline: "bash",
|
||||
"command-line": "bash",
|
||||
none: undefined,
|
||||
output: undefined,
|
||||
plain: undefined,
|
||||
plaintext: undefined,
|
||||
raw: undefined,
|
||||
text: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
* Rewrites a fence label the renderer would print but not colour.
|
||||
*
|
||||
* A label the model chose is otherwise left alone: this only touches names that
|
||||
* are known not to resolve.
|
||||
*/
|
||||
export function normalizeFenceLabels(markdown: string): string {
|
||||
const lines = markdown.split("\n");
|
||||
const out: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const opener = OPENING_FENCE.exec(lines[index]);
|
||||
if (!opener) {
|
||||
out.push(lines[index]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, indent, marker, existing] = opener;
|
||||
const key = existing.toLowerCase();
|
||||
|
||||
if (existing && key in LANGUAGE_ALIASES) {
|
||||
const replacement = LANGUAGE_ALIASES[key];
|
||||
out.push(replacement ? `${indent}${marker}${replacement}` : `${indent}${marker}`);
|
||||
} else {
|
||||
out.push(lines[index]);
|
||||
}
|
||||
|
||||
index += 1;
|
||||
while (index < lines.length && !CLOSING_FENCE.test(lines[index])) {
|
||||
out.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (index < lines.length) {
|
||||
out.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** Box drawing and block elements, which is what a diagram is drawn with here. */
|
||||
const BOX_DRAWING = /[\u2500-\u257f\u2580-\u259f]/g;
|
||||
|
||||
/**
|
||||
* Whether a line is part of a picture rather than part of a sentence.
|
||||
*
|
||||
* Two box-drawing characters is the clear signal. The ASCII spelling of the same
|
||||
* thing is a line that starts and ends with a border character, which is how a
|
||||
* hand-written `+---+---+` table or an `| a | b |` row begins.
|
||||
*/
|
||||
function isArtLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0) return false;
|
||||
|
||||
if ((trimmed.match(BOX_DRAWING)?.length ?? 0) >= 2) return true;
|
||||
|
||||
return /^[+|].*[+|]$/.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a paragraph is a picture that has to keep its alignment.
|
||||
*
|
||||
* Deliberately cautious, because fencing prose is worse than leaving a diagram
|
||||
* to reflow: at least two art lines are required and they have to make up half
|
||||
* the paragraph, a real GFM table is handed to the table renderer instead, and
|
||||
* an indented block is already a code block in markdown.
|
||||
*/
|
||||
function isAsciiArt(paragraph: string[]): boolean {
|
||||
if (paragraph.length < 2) return false;
|
||||
|
||||
const isTable = paragraph.some(
|
||||
(line, index) => TABLE_ROW.test(line) && TABLE_DELIMITER_ROW.test(paragraph[index + 1] ?? ""),
|
||||
);
|
||||
if (isTable) return false;
|
||||
|
||||
if (paragraph.some((line) => /^\s{4,}\S/.test(line))) return false;
|
||||
|
||||
const art = paragraph.filter(isArtLine).length;
|
||||
return art >= 2 && art * 2 >= paragraph.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fences a diagram that was written as plain text.
|
||||
*
|
||||
* A directory tree or a boxed diagram is drawn with spaces that carry the
|
||||
* meaning, and a paragraph reflows them: `MdParagraph.qml` wraps, so the
|
||||
* columns of an ASCII table land wherever the pane width puts them and the
|
||||
* picture arrives scrambled. A fence is monospace and scrolls instead of
|
||||
* wrapping, which is the one rendering that keeps the drawing intact.
|
||||
*/
|
||||
export function fenceAsciiArt(markdown: string): string {
|
||||
const lines = markdown.split("\n");
|
||||
const out: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
if (OPENING_FENCE.test(lines[index])) {
|
||||
index = copyFence(lines, out, index);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A paragraph is the run of non-blank lines, which is the unit that has to
|
||||
// stay together: half a tree in a fence is no better than none of it.
|
||||
let end = index;
|
||||
while (end < lines.length && lines[end].trim().length > 0 && !OPENING_FENCE.test(lines[end])) end += 1;
|
||||
|
||||
const paragraph = lines.slice(index, end);
|
||||
if (isAsciiArt(paragraph)) out.push(FENCE, ...paragraph, FENCE);
|
||||
else out.push(...paragraph);
|
||||
|
||||
if (end === index) {
|
||||
// A blank line, which is already a block boundary.
|
||||
out.push(lines[end]);
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
index = end;
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The full pre-render pass, in order.
|
||||
*
|
||||
* Tables are separated first because they change block boundaries: a blank line
|
||||
* has to exist before anything else looks at the line. Links are restored next
|
||||
* so a recovered URL leaves no span for the promoter to fence again. Promotion
|
||||
* then runs before labelling, so a promoted block is seen as an ordinary fence
|
||||
* by the labeller, which is also harmless when promotion already inferred a
|
||||
* language.
|
||||
* has to exist before anything else looks at the line. Diagrams are fenced next,
|
||||
* so every pass after this one sees them as code. Links are restored third so a
|
||||
* recovered URL leaves no span for the promoter to fence again. Promotion then
|
||||
* runs before labelling, so a promoted block is seen as an ordinary fence by the
|
||||
* labeller, which is also harmless when promotion already inferred a language.
|
||||
* Labels are normalised last, which is the only pass that looks at a fence the
|
||||
* model wrote itself.
|
||||
*/
|
||||
export function repairCodeBlocks(markdown: string): string {
|
||||
return labelCodeFences(promoteStandaloneCodeSpans(restoreUrlLinks(separateTables(markdown))));
|
||||
return normalizeFenceLabels(
|
||||
labelCodeFences(promoteStandaloneCodeSpans(restoreUrlLinks(fenceAsciiArt(separateTables(markdown))))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Action, ActionPanel, Icon, LaunchType, List, launchCommand } from "@vicinae/api";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { readAttachment } from "../attachments";
|
||||
import { runAutoDelete } from "../cleanup";
|
||||
import { useChat } from "../hooks/useChat";
|
||||
import { usePreferences } from "../hooks/usePreferences";
|
||||
import { useVisionSupport } from "../hooks/useVision";
|
||||
import { buildThreadMarkdown, renderAttachments, type TranscriptOptions } from "../markdown";
|
||||
import { getLamPreferences } from "../preferences";
|
||||
import type { ResolvedPreferences } from "../preferences";
|
||||
import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions";
|
||||
import { resolveStartup } from "../startup";
|
||||
import { buildThread } from "../thread";
|
||||
@@ -81,18 +83,49 @@ interface SessionRow {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
/** Shown while the conversation and the preferences are still being read. */
|
||||
function LoadingChat() {
|
||||
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat surface, once its preferences are known.
|
||||
*
|
||||
* Split in two so that {@link ChatView} can wait for the model before the state
|
||||
* machine starts. `useChat` is created with the preferences it will use, so
|
||||
* handing it preferences that are still loading would pin the conversation to
|
||||
* whatever the preference said before the selection arrived.
|
||||
*
|
||||
* The outer half does nothing but that wait, and the loading state costs one
|
||||
* LocalStorage read, which is a few milliseconds while the user is still moving
|
||||
* their hands towards the keyboard.
|
||||
*/
|
||||
export function ChatView(props: ChatViewProps) {
|
||||
const { preferences } = usePreferences();
|
||||
|
||||
if (!preferences) return <LoadingChat />;
|
||||
return <ChatSession {...props} preferences={preferences} />;
|
||||
}
|
||||
|
||||
interface ChatSessionProps extends ChatViewProps {
|
||||
preferences: ResolvedPreferences;
|
||||
}
|
||||
|
||||
function ChatSession({
|
||||
preferences,
|
||||
initialSession,
|
||||
initialPrompt,
|
||||
initialSessionId,
|
||||
initialAttachmentPath,
|
||||
launchNote,
|
||||
}: ChatViewProps) {
|
||||
const preferences = useMemo(() => getLamPreferences(), []);
|
||||
const chat = useChat({ preferences, initialSession });
|
||||
}: ChatSessionProps) {
|
||||
// The capability check is resolved before the chat exists, because its answer
|
||||
// is part of the system prompt: a model that knows it can read images describes
|
||||
// the one it is sent, and one that does not know reasons about itself instead.
|
||||
const vision = useVisionSupport(preferences);
|
||||
const chat = useChat({ preferences, initialSession, vision: vision.support });
|
||||
const { replaceSession } = chat;
|
||||
const { canSeeImages } = useVisionSupport(preferences);
|
||||
|
||||
const { canSeeImages } = vision;
|
||||
const [input, setInput] = useState(initialPrompt ?? "");
|
||||
const [summaries, setSummaries] = useState<SessionSummary[] | undefined>(undefined);
|
||||
// One flag, not two. See the startup effect below for why that matters.
|
||||
@@ -110,10 +143,30 @@ export function ChatView({
|
||||
.catch(() => setSummaries([]));
|
||||
}, []);
|
||||
|
||||
// Refreshed after each turn so the sidebar picks up a newly created chat.
|
||||
// Refreshed after each turn so the sidebar picks up a newly created chat, and
|
||||
// swept at the same time: a turn is exactly when a conversation's age changes,
|
||||
// and waiting for the next launch to collect an expired one would mean showing
|
||||
// the user a conversation that has already been deleted.
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload, chat.session.updatedAt]);
|
||||
// Not until startup has finished, because until then the open conversation is
|
||||
// still the placeholder one and the real conversation being restored has no
|
||||
// id to be exempted by. Sweeping first could delete the chat on its way to
|
||||
// the screen.
|
||||
if (startup !== "ready") return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
// The open conversation is exempt: it is about to be rendered, and being
|
||||
// the one it opens by default is the clearest sign it is still in use.
|
||||
await runAutoDelete({ maxAgeMs: preferences.autoDeleteMs, keepId: chat.session.id });
|
||||
if (!cancelled) reload();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [startup, chat.session.id, chat.session.updatedAt, preferences.autoDeleteMs, reload]);
|
||||
|
||||
/**
|
||||
* Everything that has to happen before the first render, in one place.
|
||||
@@ -165,8 +218,16 @@ export function ChatView({
|
||||
response: chat.streamingResponse,
|
||||
thinking: chat.streamingThinking,
|
||||
status: chat.status,
|
||||
activity: chat.activity,
|
||||
}),
|
||||
[chat.session.turns, chat.isStreaming, chat.streamingResponse, chat.streamingThinking, chat.status],
|
||||
[
|
||||
chat.session.turns,
|
||||
chat.isStreaming,
|
||||
chat.streamingResponse,
|
||||
chat.streamingThinking,
|
||||
chat.status,
|
||||
chat.activity,
|
||||
],
|
||||
);
|
||||
|
||||
const transcriptOptions = useMemo<TranscriptOptions>(
|
||||
@@ -188,6 +249,10 @@ export function ChatView({
|
||||
// The preview comes first so the captured image is visible before the user
|
||||
// decides what to ask about it, rather than only after sending.
|
||||
pending ? `${renderAttachments([pending])}\n\n${PENDING_NOTE}` : undefined,
|
||||
// Deliberately nothing about the running tool here. That line is rendered by
|
||||
// the transcript, on the live message, so that one fact is printed once:
|
||||
// adding it here as well put "Reading a page" on screen twice, one line
|
||||
// above the other, while a tool ran before any text had arrived.
|
||||
].filter((note): note is string => Boolean(note));
|
||||
|
||||
// The placeholder is what tells a user that the search bar is the composer.
|
||||
@@ -277,7 +342,7 @@ export function ChatView({
|
||||
|
||||
const hasPrompt = input.trim().length > 0 || pending !== undefined;
|
||||
|
||||
const stopAction = <Action title="Stop Generating" icon={Icon.Stop} onAction={chat.cancel} />;
|
||||
const stopAction = <Action title="Force Stop" icon={Icon.Stop} onAction={chat.cancel} />;
|
||||
const sendAction = <Action title="Send" icon={Icon.SpeechBubble} onAction={() => send(input)} />;
|
||||
|
||||
const newChatAction = (
|
||||
@@ -356,7 +421,7 @@ export function ChatView({
|
||||
};
|
||||
|
||||
if (startup === "loading" || summaries === undefined) {
|
||||
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
|
||||
return <LoadingChat />;
|
||||
}
|
||||
|
||||
const placeholder = chat.status ? `${chat.status}. Press ⏎ to stop` : "Lam is thinking. Press ⏎ to stop";
|
||||
|
||||
+88
-2
@@ -3,12 +3,64 @@
|
||||
export const DEFAULT_HOST = "https://ollama.com";
|
||||
export const DEFAULT_MODEL = "gpt-oss:120b";
|
||||
|
||||
/**
|
||||
* Hosts that run the models themselves rather than handing them to a server.
|
||||
*
|
||||
* Only these can be managed: `pull` and `delete` are operations on a machine the
|
||||
* user controls, and against Ollama Cloud there is nothing to pull or remove.
|
||||
*/
|
||||
export const CLOUD_HOSTNAMES = new Set(["ollama.com", "api.ollama.com"]);
|
||||
|
||||
/** 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;
|
||||
/**
|
||||
* The model picked in the Manage Models command.
|
||||
*
|
||||
* Separate from the `model` preference because an extension cannot write its own
|
||||
* preferences: Vicinae reads them once at startup and holds them in memory, so
|
||||
* writing the config file has no effect until it restarts (verified against
|
||||
* 0.29.1). A selection in LocalStorage takes effect immediately instead, which is
|
||||
* the whole point of picking a model from a list.
|
||||
*/
|
||||
export const STORAGE_MODEL_KEY = "lam.model.selected";
|
||||
|
||||
/**
|
||||
* How many rounds of tool calls one turn may spend before the model is told to
|
||||
* answer with what it has.
|
||||
*
|
||||
* Five was too few for a question that needs several searches and a few pages,
|
||||
* and the failure mode was the worst one available: the budget ran out, every
|
||||
* preamble the model had written was discarded, and the turn ended with a notice
|
||||
* about the limit rather than an answer. The closing round in `agent.ts` now
|
||||
* turns that into an answer, and this is the room it gets to gather first.
|
||||
*
|
||||
* A default rather than a ceiling: the Tool Call Limit preference overrides it,
|
||||
* and "Unlimited" removes it entirely.
|
||||
*/
|
||||
export const DEFAULT_MAX_TOOL_ROUNDS = 8;
|
||||
|
||||
/**
|
||||
* How many times one turn may ask for the exact same call before the loop gives
|
||||
* up on it.
|
||||
*
|
||||
* A model stuck in a rut repeats itself with identical arguments, so the repeat
|
||||
* is the signal: the third time it asks the same question it is not going to
|
||||
* learn anything new, and the only thing a fourth round buys is another round.
|
||||
* This is the backstop the rounds cap cannot be, because the cap can be set to
|
||||
* Unlimited.
|
||||
*/
|
||||
export const MAX_REPEATED_TOOL_CALLS = 3;
|
||||
|
||||
/**
|
||||
* How long a snip must sit unreferenced before the orphan sweep collects it.
|
||||
*
|
||||
* Snips are written to disk before the chat view opens, and a capture the user
|
||||
* never sends is stored nowhere. The wait is what keeps the sweep from deleting
|
||||
* the file out from under a snip the user is still deciding what to ask about.
|
||||
*/
|
||||
export const SNIP_ORPHAN_GRACE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Oldest sessions past this count are evicted, along with their storage keys. */
|
||||
export const MAX_SESSIONS = 100;
|
||||
@@ -28,6 +80,10 @@ Lead with the answer, then the detail. This renders in a narrow window, so stay
|
||||
|
||||
Use the markdown this window actually draws rather than flattening everything into prose. It renders tables, bulleted and numbered lists, headings, blockquotes, bold, italics, inline code and fenced code, so pick the shape that fits. A table is the right shape for comparing three or more things across the same few attributes, so write the table itself: never hand back a table as a bulleted list of rows, and never describe a table you could simply write. Keep it to about four narrow columns so it fits, give it a header row, and leave a blank line before it. A list is for steps or for independent items. Reach for a heading only when an answer genuinely has sections, since at this size a heading usually costs a line and buys nothing.
|
||||
|
||||
Maths is drawn, so write it as LaTeX rather than as flattened text: $x^2$ inside a sentence, and a formula worth a line of its own written as $$...$$. Fractions, roots, sums, integrals, products, Greek letters, and subscripts and superscripts all render. Stacked notation does not, because the window is narrow, so \\frac{a}{b} is drawn as a/b and a matrix comes out linear. Prefer a formula that reads on one line, and keep whole formulas out of code fences.
|
||||
|
||||
A small amount of HTML is drawn too, for the rare case where a visual carries it better than a sentence. Bold, italic, underline, strike, sub, sup and a span with a colour or a size all work, for example <span style="background:#2563eb;color:#fff">new</span>, and a small HTML table is fine. Reach for that only when it earns its place, a status chip or a tiny grid, never to wrap an answer, and still write a comparison as a markdown table. A diagram drawn with characters is fine, but fence it so the columns stay monospaced.
|
||||
|
||||
Write plainly:
|
||||
- Never use an em dash. Use a comma, a colon, or a full stop.
|
||||
- Skip stock service-desk phrasing: "Certainly", "I'd be happy to help", "Great question", "I hope this helps", "Let me know if you need anything else". Warmth that means something beats politeness that means nothing.
|
||||
@@ -40,6 +96,8 @@ Fence every command and every snippet, and always name the language: bash for sh
|
||||
|
||||
A web address is a link, never code. A markdown link, written as [label](https://example.com), is clickable in this window, while a URL inside backticks or a fenced block is dead text the user cannot open. So never fence a URL, never put one in inline code, and never answer "here are the links" with a snippet. Label a link with what it is ("OpenClickly on GitHub"), and when you give several, put each on its own bullet with its own link.
|
||||
|
||||
When a tool result gives you a source, cite it: put its number in square brackets right after the sentence it supports, like a claim resting on a search result [1]. Cite the page that carries the point rather than the search that found it, put each number in its own brackets when a sentence rests on two sources (write [1][2]), and never cite a number you were not shown. The window turns that number into a link and lists the sources under your answer, so write no footnote definitions and no sources or references list of your own.
|
||||
|
||||
A fenced block is never a whole answer on its own. Say in one plain sentence what the code does or when to reach for it, so the reply reads like something a person wrote rather than a bare snippet.
|
||||
|
||||
Call a tool when it makes the answer more accurate. Anything time-sensitive or past your cutoff should be checked rather than guessed, and never invent tool output. If a tool fails, say so and answer with what you know.
|
||||
@@ -49,3 +107,31 @@ Say what you are unsure about rather than hedging.`;
|
||||
/** 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.";
|
||||
|
||||
/**
|
||||
* File access caps.
|
||||
*
|
||||
* Every one of these exists because the alternative is a tool that can swallow a
|
||||
* turn's whole context window or a worker's heap: a directory with 40,000 entries,
|
||||
* a log file of 900 MB, a search that walks a home directory to its leaves. Each cap
|
||||
* is stated in the tool's own output when it bites, so the model knows it is looking
|
||||
* at part of something rather than all of it.
|
||||
*/
|
||||
export const MAX_READ_LINES = 200;
|
||||
export const MAX_READ_CHARS = 8000;
|
||||
export const MAX_LIST_ENTRIES = 200;
|
||||
export const MAX_SEARCH_RESULTS = 50;
|
||||
export const MAX_SEARCH_DEPTH = 8;
|
||||
export const MAX_SEARCH_BYTES = 64 * 1024;
|
||||
export const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The file paragraph added to the runtime description when the tools are on.
|
||||
*
|
||||
* Separate from `describeRuntime` because it is the part that does not change with
|
||||
* the configured roots: what a refusal means, and the one thing about file reading
|
||||
* that the user cannot see from the dialog, which is that the contents leave the
|
||||
* machine on their way to the model.
|
||||
*/
|
||||
export const FILE_TOOL_GUIDANCE =
|
||||
"A refused change is final: say what you could not do rather than trying again. Anything you read is sent to the model provider with the conversation, so read what the question needs and nothing more, and read a large file in parts with offset and limit instead of asking for the whole thing.";
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
import { mkdir, readFile, readdir, realpath, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
MAX_FILE_BYTES,
|
||||
MAX_LIST_ENTRIES,
|
||||
MAX_READ_CHARS,
|
||||
MAX_READ_LINES,
|
||||
MAX_SEARCH_BYTES,
|
||||
MAX_SEARCH_DEPTH,
|
||||
MAX_SEARCH_RESULTS,
|
||||
} from "./constants";
|
||||
|
||||
/**
|
||||
* Where Lam may read and write, and how a written path becomes a real one.
|
||||
*
|
||||
* No Vicinae import anywhere in this file, and no dialog: the policy is a value a
|
||||
* test can build, and approval arrives as a callback from the caller. That split is
|
||||
* what makes the whole boundary testable without a running launcher, and it is also
|
||||
* why every function here returns a result rather than throwing: a refusal and an
|
||||
* I/O failure reach the model in the same shape.
|
||||
*/
|
||||
|
||||
export interface PolicyOptions {
|
||||
/** The home directory. Overridden in tests, which is why it is not read inline. */
|
||||
home?: string;
|
||||
/** `environment.supportPath`, the one directory an extension is invited to write to. */
|
||||
supportPath?: string;
|
||||
/** Extra read roots from the preference. */
|
||||
readRoots?: string[];
|
||||
/** Extra write roots from the preference. */
|
||||
writeRoots?: string[];
|
||||
}
|
||||
|
||||
export interface PathPolicy {
|
||||
home: string;
|
||||
readRoots: string[];
|
||||
writeRoots: string[];
|
||||
}
|
||||
|
||||
/** Directories under home that hold credentials, matched on the resolved path. */
|
||||
const DENIED_DIRECTORIES = [".ssh", ".gnupg", ".aws", ".config/gh"];
|
||||
/** Files under home that hold credentials. */
|
||||
const DENIED_FILES = [".netrc", ".docker/config.json"];
|
||||
/** Basenames that are key material or credentials wherever they sit. */
|
||||
const DENIED_BASENAMES = ["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "credentials.json"];
|
||||
/** Extensions that mean key material. */
|
||||
const DENIED_EXTENSIONS = [".pem", ".key", ".p12"];
|
||||
/** A basename that is an environment file, exactly or with a dot suffix. */
|
||||
const DENIED_PREFIXES = [".env"];
|
||||
|
||||
/** Directories a search does not walk into, because they are noise and they are large. */
|
||||
const SKIP_DIRECTORIES = [".git", "node_modules"];
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return [...new Set(values.filter((value) => value.length > 0))];
|
||||
}
|
||||
|
||||
export function createPolicy(options: PolicyOptions = {}): PathPolicy {
|
||||
const home = options.home ?? homedir();
|
||||
const under = (...parts: string[]) => path.join(home, ...parts);
|
||||
|
||||
return {
|
||||
home,
|
||||
readRoots: unique([home, ...(options.readRoots ?? [])]),
|
||||
// Downloads and a folder of Lam's own are the two the user named. The support
|
||||
// directory comes third because it is where the extension is invited to write,
|
||||
// which makes it the one place a failure cannot be blamed on a guessed path.
|
||||
writeRoots: unique([
|
||||
under("Downloads"),
|
||||
under("Documents", "Lam Agent"),
|
||||
...(options.supportPath ? [options.supportPath] : []),
|
||||
...(options.writeRoots ?? []),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
export interface RootList {
|
||||
roots: string[];
|
||||
/** Entries that are not absolute paths, reported rather than silently dropped. */
|
||||
rejected: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a roots preference: one path per line, or comma separated.
|
||||
*
|
||||
* A rejected entry does not disable the others, and a root that does not exist yet is
|
||||
* kept, because a write root may be created after it is configured. Relative entries
|
||||
* are rejected rather than resolved: in a tool argument a relative path has an obvious
|
||||
* meaning (the user's home), and in a setting it has none.
|
||||
*/
|
||||
export function splitRoots(value: string | undefined, home: string = homedir()): RootList {
|
||||
const roots: string[] = [];
|
||||
const rejected: string[] = [];
|
||||
|
||||
for (const piece of (value ?? "").split(/[\n,]/)) {
|
||||
const trimmed = piece.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const expanded = expandHome(trimmed, home);
|
||||
if (!path.isAbsolute(expanded)) {
|
||||
rejected.push(trimmed);
|
||||
continue;
|
||||
}
|
||||
roots.push(expanded);
|
||||
}
|
||||
|
||||
return { roots: unique(roots), rejected: unique(rejected) };
|
||||
}
|
||||
|
||||
/** `~` and `~/x` become home. Anything else is left alone. */
|
||||
function expandHome(written: string, home: string): string {
|
||||
if (written === "~") return home;
|
||||
if (written.startsWith("~/")) return path.join(home, written.slice(2));
|
||||
return written;
|
||||
}
|
||||
|
||||
/** True when `target` is `root` itself or sits under it, compared component by component. */
|
||||
export function isWithin(target: string, root: string): boolean {
|
||||
if (target === root) return true;
|
||||
|
||||
// The separator is the whole point: comparing raw prefixes would put
|
||||
// /Users/me/Downloads-evil inside /Users/me/Downloads.
|
||||
const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
|
||||
return target.startsWith(prefix);
|
||||
}
|
||||
|
||||
export interface PathResolution {
|
||||
ok: true;
|
||||
/** The absolute, symlink-free path, or the nearest real ancestor plus the rest. */
|
||||
path: string;
|
||||
/** The nearest existing directory, which is what containment is judged against. */
|
||||
anchor: string;
|
||||
/** True when the path itself does not exist, so a create is what happens. */
|
||||
missing: boolean;
|
||||
}
|
||||
export type ResolveResult = PathResolution | { ok: false; reason: string };
|
||||
|
||||
/**
|
||||
* Turns a path the model wrote into a real one.
|
||||
*
|
||||
* The order matters and is the whole point: expand, resolve, then `realpath`, then
|
||||
* judge containment. Checking containment before `realpath` would decide about a path
|
||||
* by where it is written rather than where it lands, and a symlink inside a write root
|
||||
* would then be a way to write anywhere.
|
||||
*/
|
||||
export async function resolvePath(written: unknown, policy: PathPolicy): Promise<ResolveResult> {
|
||||
if (typeof written !== "string" || written.trim().length === 0) {
|
||||
return { ok: false, reason: "A path is required, as a string." };
|
||||
}
|
||||
if (written.includes("\0")) {
|
||||
return { ok: false, reason: "That path contains a null byte, which no real path has." };
|
||||
}
|
||||
|
||||
const expanded = expandHome(written.trim(), policy.home);
|
||||
// A relative path is read against home rather than the working directory: a launcher
|
||||
// command has no meaningful working directory.
|
||||
const absolute = path.resolve(policy.home, expanded);
|
||||
const { real, anchor, missing } = await realpathChain(absolute);
|
||||
|
||||
return { ok: true, path: real, anchor, missing };
|
||||
}
|
||||
|
||||
/**
|
||||
* The real path of a location that may not exist yet.
|
||||
*
|
||||
* `realpath` fails on a path that is not there, and the create case is the one that
|
||||
* matters most, so the nearest existing ancestor is resolved instead and the rest of
|
||||
* the path is re-appended. That ancestor is also returned, because it is what the
|
||||
* write check is judged against.
|
||||
*/
|
||||
async function realpathChain(absolute: string): Promise<{ real: string; anchor: string; missing: boolean }> {
|
||||
const trailing: string[] = [];
|
||||
let current = absolute;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
const real = await realpath(current);
|
||||
return {
|
||||
real: path.join(real, ...trailing.reverse()),
|
||||
anchor: real,
|
||||
missing: trailing.length > 0,
|
||||
};
|
||||
} catch {
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return { real: absolute, anchor: absolute, missing: true };
|
||||
trailing.push(path.basename(current));
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Why a path may not be read, or undefined when it may. */
|
||||
export function denialFor(resolved: string, policy: PathPolicy): string | undefined {
|
||||
for (const directory of DENIED_DIRECTORIES) {
|
||||
if (isWithin(resolved, path.join(policy.home, directory))) {
|
||||
return `it is inside ~/${directory}, which holds credentials`;
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of DENIED_FILES) {
|
||||
if (resolved === path.join(policy.home, file)) return `it is ~/${file}, which holds credentials`;
|
||||
}
|
||||
|
||||
const base = path.basename(resolved);
|
||||
|
||||
if (DENIED_BASENAMES.includes(base)) return `its name, ${base}, marks it as a credential file`;
|
||||
|
||||
const extension = path.extname(base).toLowerCase();
|
||||
if (DENIED_EXTENSIONS.includes(extension)) {
|
||||
return `it is a ${extension} file, which is key material`;
|
||||
}
|
||||
|
||||
if (DENIED_PREFIXES.some((prefix) => base === prefix || base.startsWith(`${prefix}.`))) {
|
||||
return `its name, ${base}, marks it as an environment file`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Why a path may not be read, or undefined when it may. */
|
||||
export function readDenial(resolved: string, policy: PathPolicy): string | undefined {
|
||||
const denied = denialFor(resolved, policy);
|
||||
if (denied) return denied;
|
||||
|
||||
const allowed = policy.readRoots.some((root) => isWithin(resolved, root));
|
||||
return allowed
|
||||
? undefined
|
||||
: `it is outside the paths Lam may read (${describeRoots(policy.readRoots, policy.home)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a path may not be changed, or undefined when it may.
|
||||
*
|
||||
* Deliberately does not consult the denylist: those rules protect against disclosure,
|
||||
* and a write discloses nothing. A write to `~/Downloads/report.pem` is allowed, and
|
||||
* the dialog names the path it is about to replace.
|
||||
*/
|
||||
export function writeDenial(resolved: string, policy: PathPolicy): string | undefined {
|
||||
const allowed = policy.writeRoots.some((root) => isWithin(resolved, root));
|
||||
return allowed
|
||||
? undefined
|
||||
: `it is outside the paths Lam may change (${describeRoots(policy.writeRoots, policy.home)})`;
|
||||
}
|
||||
|
||||
/** Shortens a path to its `~` form for anything a person reads. */
|
||||
export function displayPath(resolved: string, home: string): string {
|
||||
if (resolved === home) return "~";
|
||||
if (isWithin(resolved, home)) return `~/${path.relative(home, resolved)}`;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** "~ and /Volumes/Archive", for a refusal the model is meant to act on. */
|
||||
function describeRoots(roots: string[], home: string): string {
|
||||
return roots.map((root) => displayPath(root, home)).join(", ");
|
||||
}
|
||||
|
||||
export interface FileOk {
|
||||
ok: true;
|
||||
content: string;
|
||||
path: string;
|
||||
bytes?: number;
|
||||
}
|
||||
export interface FileRefused {
|
||||
ok: false;
|
||||
/**
|
||||
* `policy` is "there is nothing here you are allowed to touch", `approval` is "the
|
||||
* user said no to this one", `io` is the disk saying no. The three are kept apart
|
||||
* because the model should stop trying in the first case, ask rather than retry in
|
||||
* the second, and can often route around the third.
|
||||
*/
|
||||
refused: "policy" | "approval" | "io";
|
||||
content: string;
|
||||
}
|
||||
export type FileResult = FileOk | FileRefused;
|
||||
|
||||
/** The approval callback the mutating operations call. Supplied by `approval.ts`. */
|
||||
export type ApproveFn = (request: ApprovalRequest) => Promise<boolean>;
|
||||
|
||||
export type FileOperation = "create" | "overwrite" | "edit" | "delete" | "move";
|
||||
|
||||
/**
|
||||
* What an operation wants to do, as data.
|
||||
*
|
||||
* Describes the request rather than the dialog, so this file stays free of UI and the
|
||||
* wording lives in `approval.ts`. The resolved path is separate from the display form
|
||||
* because the ledger remembers the real path while the user reads the short one.
|
||||
*/
|
||||
export interface ApprovalRequest {
|
||||
operation: FileOperation;
|
||||
/** The resolved path, which is what the ledger keys on. */
|
||||
path: string;
|
||||
/** The path as the user should read it, tilde shortened. */
|
||||
display: string;
|
||||
/** The thing the dialog names, such as a basename, or "a.txt to b.txt". */
|
||||
subject: string;
|
||||
/** Phrases describing what changes, already written out. */
|
||||
details: string[];
|
||||
/** A delete or a move asks every time, because it cannot be undone. */
|
||||
irreversible: boolean;
|
||||
}
|
||||
|
||||
function refusal(refused: FileRefused["refused"], content: string): FileRefused {
|
||||
return { ok: false, refused, content };
|
||||
}
|
||||
|
||||
/** The model never sees a stack trace: an OS error is one sentence about one path. */
|
||||
function ioFailure(error: unknown, display: string): FileRefused {
|
||||
const code = (error as { code?: string })?.code;
|
||||
const message = (error as { message?: string })?.message ?? "unknown error";
|
||||
|
||||
if (code === "ENOENT") return refusal("io", `${display} does not exist.`);
|
||||
if (code === "EACCES" || code === "EPERM") {
|
||||
return refusal("io", `${display} was refused by the operating system (permission denied).`);
|
||||
}
|
||||
if (code === "EISDIR") return refusal("io", `${display} is a directory, not a file.`);
|
||||
if (code === "ENOTDIR") return refusal("io", `${display} is not a directory.`);
|
||||
if (code === "ENOTEMPTY") return refusal("io", `${display} is not empty.`);
|
||||
|
||||
return refusal("io", `${display} could not be read or written: ${message}`);
|
||||
}
|
||||
|
||||
/** "1.2 KB", so a size in a dialog is a number a person can judge. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1000) return `${bytes} bytes`;
|
||||
if (bytes < 1_000_000) return `${(bytes / 1000).toFixed(1)} KB`;
|
||||
return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** "3 days ago", for a modification time. */
|
||||
export function describeAge(changed: Date, now: Date): string {
|
||||
const seconds = Math.max(0, Math.round((now.getTime() - changed.getTime()) / 1000));
|
||||
if (seconds < 60) return "just now";
|
||||
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
||||
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||||
|
||||
const days = Math.round(hours / 24);
|
||||
if (days < 30) return `${days} day${days === 1 ? "" : "s"} ago`;
|
||||
|
||||
return changed.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function lineCount(text: string): number {
|
||||
return text.length === 0 ? 0 : text.split("\n").length;
|
||||
}
|
||||
|
||||
/** One line of text, cut to a length a dialog can show. */
|
||||
export function oneLine(text: string, limit = 200): string {
|
||||
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||
return collapsed.length > limit ? `${collapsed.slice(0, limit)} …` : collapsed;
|
||||
}
|
||||
|
||||
type PreparedWrite = { ok: true; resolved: PathResolution; display: string };
|
||||
|
||||
/** Resolve, then the write check. The shared opening move of every mutating call. */
|
||||
async function prepareWrite(written: string, policy: PathPolicy): Promise<PreparedWrite | FileRefused> {
|
||||
const resolved = await resolvePath(written, policy);
|
||||
if (!resolved.ok) return refusal("policy", `Refused: ${resolved.reason}`);
|
||||
|
||||
const display = displayPath(resolved.path, policy.home);
|
||||
const denied = writeDenial(resolved.path, policy);
|
||||
if (denied) return refusal("policy", `Refused: ${display} cannot be changed because ${denied}.`);
|
||||
|
||||
return { ok: true, resolved, display };
|
||||
}
|
||||
|
||||
function declined(display: string): FileRefused {
|
||||
return refusal(
|
||||
"approval",
|
||||
`Refused: the user declined to change ${display}. Nothing was written. Ask first rather than trying again.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listDirectory(
|
||||
written: string,
|
||||
policy: PathPolicy,
|
||||
options: { hidden?: boolean; now?: Date } = {},
|
||||
): Promise<FileResult> {
|
||||
const resolved = await resolvePath(written, policy);
|
||||
if (!resolved.ok) return refusal("policy", `Refused: ${resolved.reason}`);
|
||||
|
||||
const display = displayPath(resolved.path, policy.home);
|
||||
const denied = readDenial(resolved.path, policy);
|
||||
if (denied) return refusal("policy", `Refused: ${display} cannot be read because ${denied}.`);
|
||||
|
||||
const details = await stat(resolved.path).catch(() => undefined);
|
||||
if (!details) return refusal("io", `${display} does not exist.`);
|
||||
if (!details.isDirectory()) {
|
||||
return refusal("io", `${display} is a file, not a directory. Use read_file to read it.`);
|
||||
}
|
||||
|
||||
const entries = await readdir(resolved.path, { withFileTypes: true }).catch((error) => error);
|
||||
if (!Array.isArray(entries)) return ioFailure(entries, display);
|
||||
|
||||
const visible = options.hidden ? entries : entries.filter((entry) => !entry.name.startsWith("."));
|
||||
|
||||
const rows = await Promise.all(
|
||||
visible.map(async (entry) => {
|
||||
const full = path.join(resolved.path, entry.name);
|
||||
const info = await stat(full).catch(() => undefined);
|
||||
return {
|
||||
directory: entry.isDirectory(),
|
||||
name: entry.name,
|
||||
size: info?.size ?? 0,
|
||||
changed: info?.mtime,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
rows.sort((left, right) => {
|
||||
if (left.directory !== right.directory) return left.directory ? -1 : 1;
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
|
||||
const now = options.now ?? new Date();
|
||||
const shown = rows.slice(0, MAX_LIST_ENTRIES);
|
||||
const lines = shown.map((row) => {
|
||||
if (row.directory) return `${row.name}/ (directory)`;
|
||||
const age = row.changed ? `, changed ${describeAge(row.changed, now)}` : "";
|
||||
return `${row.name} (${formatBytes(row.size)}${age})`;
|
||||
});
|
||||
|
||||
const more =
|
||||
rows.length > shown.length
|
||||
? `\n\nShowing ${shown.length} of ${rows.length} entries, and ${rows.length - shown.length} more.`
|
||||
: "";
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: resolved.path,
|
||||
content: `${display} holds ${rows.length} entr${rows.length === 1 ? "y" : "ies"}:\n\n${lines
|
||||
.map((line) => `- ${line}`)
|
||||
.join("\n")}${more}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readTextFile(
|
||||
args: { path: string; offset?: number; limit?: number },
|
||||
policy: PathPolicy,
|
||||
): Promise<FileResult> {
|
||||
const resolved = await resolvePath(args.path, policy);
|
||||
if (!resolved.ok) return refusal("policy", `Refused: ${resolved.reason}`);
|
||||
|
||||
const display = displayPath(resolved.path, policy.home);
|
||||
const denied = readDenial(resolved.path, policy);
|
||||
if (denied) return refusal("policy", `Refused: ${display} cannot be read because ${denied}.`);
|
||||
|
||||
const details = await stat(resolved.path).catch(() => undefined);
|
||||
if (!details) return refusal("io", `${display} does not exist.`);
|
||||
if (details.isDirectory()) {
|
||||
return refusal("io", `${display} is a directory. Use list_directory to see what is in it.`);
|
||||
}
|
||||
if (details.size > MAX_FILE_BYTES) {
|
||||
return refusal(
|
||||
"io",
|
||||
`${display} is ${formatBytes(details.size)}, too large to read in one go (the limit is ${formatBytes(MAX_FILE_BYTES)}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const bytes = await readFile(resolved.path).catch((error) => error);
|
||||
if (!Buffer.isBuffer(bytes)) return ioFailure(bytes, display);
|
||||
|
||||
// A NUL byte in the first stretch is what separates text from everything else, and
|
||||
// detecting it here is why a PNG never comes back as mojibake the model might try
|
||||
// to read anyway.
|
||||
if (bytes.subarray(0, 8192).includes(0)) {
|
||||
return refusal(
|
||||
"io",
|
||||
`${display} is binary (${formatBytes(bytes.length)}), not text, so Lam will not print it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const lines = bytes.toString("utf8").split("\n");
|
||||
const offset = Math.max(0, (args.offset ?? 1) - 1);
|
||||
const limit = Math.max(1, args.limit ?? MAX_READ_LINES);
|
||||
|
||||
const numbered: string[] = [];
|
||||
let characters = 0;
|
||||
let consumed = offset;
|
||||
|
||||
for (let index = offset; index < lines.length && numbered.length < limit; index++) {
|
||||
const line = `${index + 1}\t${lines[index]}`;
|
||||
// The character cap is the second of two limits and is reported as such, so a long
|
||||
// file is never presented as if it were complete.
|
||||
if (characters + line.length > MAX_READ_CHARS) break;
|
||||
characters += line.length;
|
||||
numbered.push(line);
|
||||
consumed = index + 1;
|
||||
}
|
||||
|
||||
const head =
|
||||
consumed < lines.length
|
||||
? `${display} (showing lines ${offset + 1} to ${consumed} of ${lines.length}; continue with offset ${consumed + 1}):\n\n`
|
||||
: `${display} (${lines.length} lines):\n\n`;
|
||||
|
||||
return { ok: true, path: resolved.path, bytes: bytes.length, content: head + numbered.join("\n") };
|
||||
}
|
||||
|
||||
export async function searchFiles(
|
||||
args: { path: string; pattern: string; contains?: string },
|
||||
policy: PathPolicy,
|
||||
): Promise<FileResult> {
|
||||
const resolved = await resolvePath(args.path, policy);
|
||||
if (!resolved.ok) return refusal("policy", `Refused: ${resolved.reason}`);
|
||||
|
||||
const display = displayPath(resolved.path, policy.home);
|
||||
const denied = readDenial(resolved.path, policy);
|
||||
if (denied) return refusal("policy", `Refused: ${display} cannot be searched because ${denied}.`);
|
||||
|
||||
const matcher = globMatcher(args.pattern);
|
||||
const needle = args.contains?.toLowerCase();
|
||||
const found: string[] = [];
|
||||
let capped = false;
|
||||
|
||||
const walk = async (directory: string, depth: number): Promise<void> => {
|
||||
if (found.length >= MAX_SEARCH_RESULTS) {
|
||||
capped = true;
|
||||
return;
|
||||
}
|
||||
if (depth > MAX_SEARCH_DEPTH) return;
|
||||
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (found.length >= MAX_SEARCH_RESULTS) {
|
||||
capped = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const full = path.join(directory, entry.name);
|
||||
// Every candidate is judged by the same rules as a direct read, so a search can
|
||||
// never become a way around the denylist.
|
||||
if (readDenial(full, policy)) continue;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIP_DIRECTORIES.includes(entry.name)) await walk(full, depth + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!matcher(entry.name)) continue;
|
||||
|
||||
if (needle) {
|
||||
const bytes = await readFile(full).catch(() => undefined);
|
||||
if (!bytes) continue;
|
||||
if (!bytes.subarray(0, MAX_SEARCH_BYTES).toString("utf8").toLowerCase().includes(needle)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
found.push(displayPath(full, policy.home));
|
||||
}
|
||||
};
|
||||
|
||||
const details = await stat(resolved.path).catch(() => undefined);
|
||||
if (!details) return refusal("io", `${display} does not exist.`);
|
||||
if (details.isDirectory()) await walk(resolved.path, 0);
|
||||
else if (matcher(path.basename(resolved.path))) found.push(display);
|
||||
|
||||
if (found.length === 0) {
|
||||
return { ok: true, path: resolved.path, content: `No files under ${display} matched.` };
|
||||
}
|
||||
|
||||
const note = capped ? `\n\nStopped at ${MAX_SEARCH_RESULTS} matches.` : "";
|
||||
return {
|
||||
ok: true,
|
||||
path: resolved.path,
|
||||
content: `${found.length} match${found.length === 1 ? "" : "es"} under ${display}:\n\n${found
|
||||
.map((entry) => `- ${entry}`)
|
||||
.join("\n")}${note}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A glob matcher for `*`, `?` and `**`.
|
||||
*
|
||||
* Written here rather than pulled in as a dependency: it is a dozen lines, and the only
|
||||
* thing it is ever handed is a filename pattern written by a model or by the user. The
|
||||
* escape step and the wildcards are one pass with a callback, because two passes need a
|
||||
* placeholder for `**` and a placeholder in a regular expression is a control character
|
||||
* this repository's lint forbids, having already been bitten by that once.
|
||||
*/
|
||||
export function globMatcher(pattern: string): (name: string) => boolean {
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\*?]/g, (character) => {
|
||||
if (character === "*") return "[^/]*";
|
||||
if (character === "?") return "[^/]";
|
||||
return `\\${character}`;
|
||||
});
|
||||
|
||||
const expression = new RegExp(`^${escaped}$`, "i");
|
||||
return (name: string) => expression.test(name);
|
||||
}
|
||||
|
||||
export async function writeTextFile(
|
||||
args: { path: string; content: string },
|
||||
policy: PathPolicy,
|
||||
approve: ApproveFn,
|
||||
options: { now?: Date } = {},
|
||||
): Promise<FileResult> {
|
||||
const prepared = await prepareWrite(args.path, policy);
|
||||
if (!prepared.ok) return prepared;
|
||||
|
||||
const { resolved, display } = prepared;
|
||||
const existing = resolved.missing ? undefined : await stat(resolved.path).catch(() => undefined);
|
||||
if (existing?.isDirectory()) {
|
||||
return refusal("io", `${display} is a directory, and Lam does not overwrite one with a file.`);
|
||||
}
|
||||
|
||||
const content = typeof args.content === "string" ? args.content : "";
|
||||
const bytes = Buffer.byteLength(content, "utf8");
|
||||
const parent = path.dirname(resolved.path);
|
||||
const details: string[] = [];
|
||||
|
||||
if (parent !== resolved.anchor) {
|
||||
details.push(`Creates the folder ${displayPath(parent, policy.home)} as well.`);
|
||||
}
|
||||
if (existing) {
|
||||
details.push(
|
||||
`Replaces ${formatBytes(existing.size)}, last changed ${describeAge(existing.mtime, options.now ?? new Date())}.`,
|
||||
);
|
||||
}
|
||||
details.push(`New content is ${formatBytes(bytes)}, ${lineCount(content)} lines.`);
|
||||
|
||||
const request: ApprovalRequest = {
|
||||
operation: existing ? "overwrite" : "create",
|
||||
path: resolved.path,
|
||||
display,
|
||||
subject: path.basename(resolved.path),
|
||||
details,
|
||||
irreversible: false,
|
||||
};
|
||||
if (!(await approve(request))) return declined(display);
|
||||
|
||||
try {
|
||||
await mkdir(parent, { recursive: true });
|
||||
await writeFile(resolved.path, content, "utf8");
|
||||
} catch (error) {
|
||||
return ioFailure(error, display);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: resolved.path,
|
||||
bytes,
|
||||
content: `${existing ? "Overwrote" : "Wrote"} ${display} (${formatBytes(bytes)}, ${lineCount(content)} lines).`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function editTextFile(
|
||||
args: { path: string; oldString: string; newString: string; all?: boolean },
|
||||
policy: PathPolicy,
|
||||
approve: ApproveFn,
|
||||
): Promise<FileResult> {
|
||||
const prepared = await prepareWrite(args.path, policy);
|
||||
if (!prepared.ok) return prepared;
|
||||
|
||||
const { resolved, display } = prepared;
|
||||
const details = await stat(resolved.path).catch(() => undefined);
|
||||
if (!details) return refusal("io", `${display} does not exist, so there is nothing to edit.`);
|
||||
if (details.isDirectory()) return refusal("io", `${display} is a directory.`);
|
||||
|
||||
if (!args.oldString) return refusal("io", "edit_file needs an old_string to replace.");
|
||||
if (args.oldString === args.newString) {
|
||||
return refusal("io", "old_string and new_string are identical, so nothing would change.");
|
||||
}
|
||||
|
||||
const before = await readFile(resolved.path, "utf8").catch((error) => error);
|
||||
if (typeof before !== "string") return ioFailure(before, display);
|
||||
|
||||
const occurrences = before.split(args.oldString).length - 1;
|
||||
if (occurrences === 0) {
|
||||
return refusal(
|
||||
"io",
|
||||
`${display} does not contain that text, so nothing was changed. Read the file again and copy the exact characters, including indentation.`,
|
||||
);
|
||||
}
|
||||
if (occurrences > 1 && !args.all) {
|
||||
return refusal(
|
||||
"io",
|
||||
`That text appears ${occurrences} times in ${display}, so nothing was changed. Include more surrounding text to make it unique, or pass all: true to replace every one.`,
|
||||
);
|
||||
}
|
||||
|
||||
const request: ApprovalRequest = {
|
||||
operation: "edit",
|
||||
path: resolved.path,
|
||||
display,
|
||||
subject: path.basename(resolved.path),
|
||||
details: [
|
||||
`Replaces ${occurrences} occurrence${occurrences === 1 ? "" : "s"}.`,
|
||||
`- ${oneLine(args.oldString)}`,
|
||||
`+ ${oneLine(args.newString)}`,
|
||||
],
|
||||
irreversible: false,
|
||||
};
|
||||
if (!(await approve(request))) return declined(display);
|
||||
|
||||
const after = args.all
|
||||
? before.split(args.oldString).join(args.newString)
|
||||
: before.replace(args.oldString, args.newString);
|
||||
|
||||
try {
|
||||
await writeFile(resolved.path, after, "utf8");
|
||||
} catch (error) {
|
||||
return ioFailure(error, display);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: resolved.path,
|
||||
content: `Edited ${display}: replaced ${occurrences} occurrence${
|
||||
occurrences === 1 ? "" : "s"
|
||||
}. The file is now ${formatBytes(Buffer.byteLength(after, "utf8"))}, ${lineCount(after)} lines.`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deletePath(
|
||||
written: string,
|
||||
policy: PathPolicy,
|
||||
approve: ApproveFn,
|
||||
): Promise<FileResult> {
|
||||
const prepared = await prepareWrite(written, policy);
|
||||
if (!prepared.ok) return prepared;
|
||||
|
||||
const { resolved, display } = prepared;
|
||||
const details = await stat(resolved.path).catch(() => undefined);
|
||||
if (!details) return refusal("io", `${display} does not exist, so there is nothing to delete.`);
|
||||
|
||||
const entries = details.isDirectory()
|
||||
? await readdir(resolved.path).catch(() => [] as string[])
|
||||
: undefined;
|
||||
|
||||
// A directory with anything in it is refused outright rather than recursed into: this
|
||||
// is the one call where a misread path destroys an unbounded amount of work, and no
|
||||
// confirmation dialog makes that recoverable.
|
||||
if (entries && entries.length > 0) {
|
||||
return refusal(
|
||||
"io",
|
||||
`Refused: ${display} is a directory with ${entries.length} entr${
|
||||
entries.length === 1 ? "y" : "ies"
|
||||
}, and Lam does not delete directories recursively. List it and delete what you actually need.`,
|
||||
);
|
||||
}
|
||||
|
||||
const request: ApprovalRequest = {
|
||||
operation: "delete",
|
||||
path: resolved.path,
|
||||
display,
|
||||
subject: path.basename(resolved.path),
|
||||
details: [
|
||||
details.isDirectory()
|
||||
? "This is an empty directory."
|
||||
: `${formatBytes(details.size)}, last changed ${describeAge(details.mtime, new Date())}.`,
|
||||
"This cannot be undone.",
|
||||
],
|
||||
irreversible: true,
|
||||
};
|
||||
if (!(await approve(request))) return declined(display);
|
||||
|
||||
try {
|
||||
if (details.isDirectory()) await rmdir(resolved.path);
|
||||
else await unlink(resolved.path);
|
||||
} catch (error) {
|
||||
return ioFailure(error, display);
|
||||
}
|
||||
|
||||
return { ok: true, path: resolved.path, content: `Deleted ${display}.` };
|
||||
}
|
||||
|
||||
export async function movePath(
|
||||
args: { from: string; to: string },
|
||||
policy: PathPolicy,
|
||||
approve: ApproveFn,
|
||||
): Promise<FileResult> {
|
||||
const source = await prepareWrite(args.from, policy);
|
||||
if (!source.ok) return source;
|
||||
|
||||
const target = await prepareWrite(args.to, policy);
|
||||
if (!target.ok) return target;
|
||||
|
||||
const details = await stat(source.resolved.path).catch(() => undefined);
|
||||
if (!details) return refusal("io", `${source.display} does not exist, so there is nothing to move.`);
|
||||
|
||||
const occupied = await stat(target.resolved.path).catch(() => undefined);
|
||||
if (occupied) {
|
||||
return refusal(
|
||||
"io",
|
||||
`Refused: ${target.display} already exists. Nothing was moved, and Lam does not overwrite a file to move another one onto it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const request: ApprovalRequest = {
|
||||
operation: "move",
|
||||
path: source.resolved.path,
|
||||
display: `${source.display} to ${target.display}`,
|
||||
subject: `${path.basename(source.resolved.path)} to ${path.basename(target.resolved.path)}`,
|
||||
details: [
|
||||
details.isDirectory()
|
||||
? "This is a directory, and everything in it moves with it."
|
||||
: `Moves ${formatBytes(details.size)}, last changed ${describeAge(details.mtime, new Date())}.`,
|
||||
"This cannot be undone.",
|
||||
],
|
||||
irreversible: true,
|
||||
};
|
||||
if (!(await approve(request))) return declined(request.display);
|
||||
|
||||
try {
|
||||
await mkdir(path.dirname(target.resolved.path), { recursive: true });
|
||||
await rename(source.resolved.path, target.resolved.path);
|
||||
} catch (error) {
|
||||
return ioFailure(error, source.display);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: target.resolved.path,
|
||||
content: `Moved ${source.display} to ${target.display}.`,
|
||||
};
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
import { LocalStorage } from "@vicinae/api";
|
||||
import { z } from "zod";
|
||||
import { errorMessage } from "./utils";
|
||||
|
||||
/**
|
||||
* IP geolocation, opt-in.
|
||||
*
|
||||
* This is the one place in the extension that tells a third party anything about
|
||||
* the user. The README said no geolocation service ever saw the user's address,
|
||||
* and this is what makes that conditional, so it is off unless asked for, it is
|
||||
* skipped entirely when the user has already said where they are, and the tool
|
||||
* output names the provider so the model can qualify what it repeats.
|
||||
*
|
||||
* How accurate is it? Less than the name suggests, and the honest answer is that
|
||||
* an IP address locates the point where the traffic left the ISP, not the person.
|
||||
* Measured from one machine on Globe Telecom, an IPv4 query resolved to Cebu City
|
||||
* and an IPv6 query, moments later, to Talisay in Calabarzon: about 500km apart.
|
||||
* The cache is therefore not just a privacy measure, and the tool output calls
|
||||
* the result a rough guess rather than a city.
|
||||
*
|
||||
* The answer is cached for hours rather than looked up per call. A city does not
|
||||
* change between questions, and every lookup hands the provider another copy of
|
||||
* the user's IP address.
|
||||
*
|
||||
* Imports Vicinae for `LocalStorage` only, which is the same footing as
|
||||
* `sessions.ts` and `vision.ts`. Everything else about it is plain `fetch`, so
|
||||
* the parsing and the cache are unit tested without a runtime.
|
||||
*/
|
||||
|
||||
/** Where the lookup goes. Swapping providers means changing this and the parser below. */
|
||||
export const GEO_PROVIDER = "https://ipwho.is/";
|
||||
export const GEO_PROVIDER_NAME = "ipwho.is";
|
||||
|
||||
/** How long a resolved place is trusted. Long enough to not re-leak the IP on every question. */
|
||||
export const GEO_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/** An IP lookup is a nice-to-have, so it must never hold up an answer. */
|
||||
const GEO_TIMEOUT_MS = 8_000;
|
||||
|
||||
const STORAGE_KEY = "lam.geo";
|
||||
|
||||
export interface GeoPlace {
|
||||
/** Ready to show, e.g. "Cebu City, Central Visayas, Philippines". */
|
||||
place: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
country?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
timezone?: string;
|
||||
/** Named in the tool output, so the model can say where this came from. */
|
||||
source: string;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export type GeoLookup = { status: "ok"; place: GeoPlace } | { status: "failed"; message: string };
|
||||
|
||||
export interface GeoOptions {
|
||||
signal?: AbortSignal;
|
||||
/** Injected so the cache is deterministic under test. */
|
||||
now?: number;
|
||||
/** Ignores a cached answer. */
|
||||
refresh?: boolean;
|
||||
}
|
||||
|
||||
const CachedSchema = z.object({
|
||||
place: z.string(),
|
||||
city: z.string().optional(),
|
||||
region: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
latitude: z.number().optional(),
|
||||
longitude: z.number().optional(),
|
||||
timezone: z.string().optional(),
|
||||
source: z.string(),
|
||||
fetchedAt: z.number(),
|
||||
});
|
||||
|
||||
/** The subset of the provider's response this needs, with every field optional so a shape change degrades instead of throwing. */
|
||||
const ResponseSchema = z.object({
|
||||
success: z.boolean().optional(),
|
||||
message: z.string().optional(),
|
||||
city: z.string().nullable().optional(),
|
||||
region: z.string().nullable().optional(),
|
||||
country: z.string().nullable().optional(),
|
||||
latitude: z.number().nullable().optional(),
|
||||
longitude: z.number().nullable().optional(),
|
||||
// ipwho.is nests the timezone; some providers hand back a plain string.
|
||||
timezone: z
|
||||
.union([z.string(), z.object({ id: z.string().optional() })])
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Joins the parts of a place, dropping duplicates.
|
||||
*
|
||||
* Providers often return the city twice (once as `city`, once as `region`) for
|
||||
* city-states and single-city regions, and "Singapore, Singapore, Singapore"
|
||||
* reads like a bug.
|
||||
*/
|
||||
export function buildPlace(parts: { city?: string; region?: string; country?: string }): string {
|
||||
const seen = new Set<string>();
|
||||
const kept: string[] = [];
|
||||
|
||||
for (const part of [parts.city, parts.region, parts.country]) {
|
||||
const trimmed = part?.trim();
|
||||
const key = trimmed?.toLowerCase();
|
||||
if (!trimmed || !key || seen.has(key)) continue;
|
||||
|
||||
seen.add(key);
|
||||
kept.push(trimmed);
|
||||
}
|
||||
|
||||
return kept.join(", ");
|
||||
}
|
||||
|
||||
/** Aborts on either the caller's signal or a timeout, whichever comes first. */
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const forward = () => controller.abort();
|
||||
|
||||
signal?.addEventListener("abort", forward, { once: true });
|
||||
|
||||
try {
|
||||
return await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
// Named rather than anonymous. Node's default UA is bare, and "who is
|
||||
// asking" is the one thing a geolocation provider has a fair interest in.
|
||||
"User-Agent": "lam-agent-vicinae",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", forward);
|
||||
}
|
||||
}
|
||||
|
||||
async function readCache(now: number): Promise<GeoPlace | undefined> {
|
||||
try {
|
||||
const raw = await LocalStorage.getItem<string>(STORAGE_KEY);
|
||||
if (!raw) return undefined;
|
||||
|
||||
const parsed = CachedSchema.safeParse(JSON.parse(raw));
|
||||
if (!parsed.success) return undefined;
|
||||
if (now - parsed.data.fetchedAt > GEO_TTL_MS) return undefined;
|
||||
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCache(place: GeoPlace): Promise<void> {
|
||||
await LocalStorage.setItem(STORAGE_KEY, JSON.stringify(place)).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the user's rough place from their IP address.
|
||||
*
|
||||
* Never throws: a lookup failure leaves the region-level answer intact, and the
|
||||
* message is passed on so the model can say what went wrong rather than quietly
|
||||
* presenting a coarser answer as though it were the same thing.
|
||||
*/
|
||||
export async function ipPlace(options: GeoOptions = {}): Promise<GeoLookup> {
|
||||
const now = options.now ?? Date.now();
|
||||
|
||||
if (!options.refresh) {
|
||||
const cached = await readCache(now);
|
||||
if (cached) return { status: "ok", place: cached };
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchWithTimeout(GEO_PROVIDER, options.signal, GEO_TIMEOUT_MS);
|
||||
} catch (thrown) {
|
||||
return { status: "failed", message: `could not reach ${GEO_PROVIDER_NAME} (${errorMessage(thrown)})` };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { status: "failed", message: `${GEO_PROVIDER_NAME} answered HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
return { status: "failed", message: `${GEO_PROVIDER_NAME} did not answer with JSON` };
|
||||
}
|
||||
|
||||
const parsed = ResponseSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return { status: "failed", message: `${GEO_PROVIDER_NAME} answered in an unexpected shape` };
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
if (data.success === false) {
|
||||
return {
|
||||
status: "failed",
|
||||
message: data.message ?? `${GEO_PROVIDER_NAME} refused the lookup`,
|
||||
};
|
||||
}
|
||||
|
||||
const city = data.city ?? undefined;
|
||||
const region = data.region ?? undefined;
|
||||
const country = data.country ?? undefined;
|
||||
const place = buildPlace({ city, region, country });
|
||||
|
||||
if (!place) {
|
||||
return { status: "failed", message: `${GEO_PROVIDER_NAME} returned no usable place` };
|
||||
}
|
||||
|
||||
const timezone = typeof data.timezone === "string" ? data.timezone : data.timezone?.id;
|
||||
|
||||
const resolved: GeoPlace = {
|
||||
place,
|
||||
city,
|
||||
region,
|
||||
country,
|
||||
latitude: data.latitude ?? undefined,
|
||||
longitude: data.longitude ?? undefined,
|
||||
timezone: timezone ?? undefined,
|
||||
source: GEO_PROVIDER_NAME,
|
||||
fetchedAt: now,
|
||||
};
|
||||
|
||||
await writeCache(resolved);
|
||||
return { status: "ok", place: resolved };
|
||||
}
|
||||
|
||||
/** Drops the cached place, so a move or a provider change is not pinned for hours. */
|
||||
export async function forgetGeoPlace(): Promise<void> {
|
||||
await LocalStorage.removeItem(STORAGE_KEY).catch(() => undefined);
|
||||
}
|
||||
+110
-5
@@ -1,5 +1,5 @@
|
||||
import { DEFAULT_SYSTEM_PROMPT, RECENCY_HINT } from "./constants";
|
||||
import type { ApiMessage, ChatTurn } from "./types";
|
||||
import { DEFAULT_SYSTEM_PROMPT, FILE_TOOL_GUIDANCE, RECENCY_HINT } from "./constants";
|
||||
import type { ApiMessage, ChatTurn, VisionSupport } from "./types";
|
||||
|
||||
/**
|
||||
* Turns stored turns into the message array sent to the model.
|
||||
@@ -34,9 +34,114 @@ export function buildMessages(
|
||||
return messages;
|
||||
}
|
||||
|
||||
/** Uses the user's prompt if they set one, otherwise Lam's default. */
|
||||
export function composeSystemPrompt(customPrompt: string, webEnabled: boolean): string {
|
||||
/**
|
||||
* What Lam knows about the setup it is running in.
|
||||
*
|
||||
* Gathered from preferences and the capability check rather than asked for, so
|
||||
* the model can answer questions about itself instead of guessing. The guesses
|
||||
* were the problem: with none of this in the prompt, a local model described
|
||||
* Vicinae as "a desktop launcher for Ollama", reported its own web tools as not
|
||||
* configured, and reasoned out loud about whether it could see an image it had
|
||||
* just been sent.
|
||||
*/
|
||||
export interface RuntimeContext {
|
||||
model: string;
|
||||
host: string;
|
||||
/** True when the host is a server the user runs rather than ollama.com. */
|
||||
selfHosted: boolean;
|
||||
webTools: boolean;
|
||||
vision: VisionSupport;
|
||||
/**
|
||||
* Whether the file tools are available this session.
|
||||
*
|
||||
* Optional, so a caller that predates file access, and every test literal, keeps
|
||||
* describing a session where Lam cannot read files, which is what it could not.
|
||||
*/
|
||||
fileTools?: boolean;
|
||||
/** The roots, tilde shortened, for the paragraph that names them. */
|
||||
fileRoots?: { read: string[]; write: string[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The paragraph that tells the model what it is and what it can do.
|
||||
*
|
||||
* Kept factual and short. Every claim here is one the extension can stand
|
||||
* behind, because a model that is told something wrong about itself will state
|
||||
* it confidently, and the user has no way to tell that apart from an answer.
|
||||
*
|
||||
* The `:cloud` convention is stated as a convention rather than as a lookup. The
|
||||
* authoritative field is `remote_host` on `GET /api/tags`, which the Models
|
||||
* command reads and shows as a `cloud` tag; fetching it here would mean a second
|
||||
* request on every model change for one sentence. The convention is Ollama's own
|
||||
* and holds on this machine, where both `:cloud` entries report
|
||||
* `remote_host: https://ollama.com` and the local model reports nothing.
|
||||
*/
|
||||
export function describeRuntime(context: RuntimeContext): string {
|
||||
const served = context.selfHosted
|
||||
? `the Ollama server at ${context.host}, which is reachable on this machine`
|
||||
: "Ollama Cloud";
|
||||
|
||||
const lines = [
|
||||
"What you are, for questions about your own setup. Do not speculate beyond this:",
|
||||
`- You are Lam, running inside the Vicinae launcher on the user's desktop, and answering as \`${context.model}\` served by ${served}.`,
|
||||
// The claim has to match the session, because a model told it cannot read files
|
||||
// while holding file tools will refuse work it could do, and one told the reverse
|
||||
// will promise things no tool here can do.
|
||||
context.fileTools
|
||||
? "- You see what the user types, what they attach, and what you look at with your file tools. You cannot read their screen, clipboard or calendar, and nothing on the machine changes except through a file change the user approved."
|
||||
: "- You see only what the user types or attaches. You cannot read their screen, files, clipboard, calendar or anything else on the machine, and you cannot act on it.",
|
||||
];
|
||||
|
||||
if (context.selfHosted) {
|
||||
lines.push(
|
||||
"- A model name ending in `:cloud` is proxied by that server to ollama.com, so the conversation leaves this machine when it is used. Anything else is running locally.",
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
context.webTools
|
||||
? "- You have `web_search` and `web_fetch`. Use them rather than guessing about anything recent, and say so if they fail."
|
||||
: "- You have no web tools in this session. Say that plainly rather than offering to look something up.",
|
||||
);
|
||||
|
||||
if (context.fileTools) {
|
||||
const read = context.fileRoots?.read ?? ["~"];
|
||||
const write = context.fileRoots?.write ?? [];
|
||||
|
||||
lines.push(
|
||||
"- You have file tools: `list_directory`, `search_files`, `read_file`, `write_file`, `edit_file`, `delete_path` and `move_path`.",
|
||||
`- You may read ${andList(read)} and change ${andList(write)}. Paths outside those are refused outright, and credential stores such as ~/.ssh are never readable.`,
|
||||
`- ${FILE_TOOL_GUIDANCE}`,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
context.vision === "yes"
|
||||
? "- You can read images. A screenshot attached to a question is yours to describe, so describe it."
|
||||
: context.vision === "no"
|
||||
? "- You cannot read images. If a question depends on one, say so plainly."
|
||||
: "- Whether you can read images has not been confirmed. If one is attached with a question, describe what you see rather than reasoning about whether you are able to.",
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the user's prompt if they set one, otherwise Lam's default.
|
||||
*
|
||||
* The runtime paragraph is appended either way. It is not a matter of style, so
|
||||
* replacing the default prompt should not silently remove it, and a user writing
|
||||
* their own prompt has even less reason to expect the model to know what it is.
|
||||
*/ export function composeSystemPrompt(customPrompt: string, context: RuntimeContext): string {
|
||||
const parts = [customPrompt.trim() || DEFAULT_SYSTEM_PROMPT];
|
||||
if (webEnabled) parts.push(RECENCY_HINT);
|
||||
if (context.webTools) parts.push(RECENCY_HINT);
|
||||
parts.push(describeRuntime(context));
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
/** "a, b and c", so a list of roots reads as a sentence rather than as data. */
|
||||
function andList(items: string[]): string {
|
||||
if (items.length === 0) return "nothing";
|
||||
if (items.length === 1) return items[0];
|
||||
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
||||
}
|
||||
|
||||
+115
-12
@@ -1,12 +1,18 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { environment } from "@vicinae/api";
|
||||
import { runAgent } from "../agent";
|
||||
import { confirmWithAlert, createApprovalLedger } from "../approval";
|
||||
import { toStoredAttachment } from "../attachments";
|
||||
import { createPolicy, displayPath } from "../files";
|
||||
import { ipPlace } from "../geo";
|
||||
import { buildMessages, composeSystemPrompt } from "../history";
|
||||
import { isSelfHosted } from "../ollama";
|
||||
import { OllamaError } from "../ollama";
|
||||
import type { ResolvedPreferences } from "../preferences";
|
||||
import { createSession, createTurn, saveSession, withTitleFromFirstTurn } from "../sessions";
|
||||
import { EMPTY_TRACE, afterToolCall, withFinalTrace, withThinking, type ThinkingTrace } from "../thinking";
|
||||
import { toolsFor } from "../tools";
|
||||
import type { PendingAttachment, Session } from "../types";
|
||||
import type { PendingAttachment, Session, VisionSupport } from "../types";
|
||||
import { errorMessage, isAbortError } from "../utils";
|
||||
|
||||
/**
|
||||
@@ -30,17 +36,54 @@ const FLUSH_INTERVAL_MS = 60;
|
||||
|
||||
export interface UseChatOptions {
|
||||
preferences: ResolvedPreferences;
|
||||
/** Restores an existing conversation, e.g. when reopening from the sessions command. */
|
||||
/** Restores an existing conversation, e.g. when reopening from the Chat History command. */
|
||||
initialSession?: Session;
|
||||
/**
|
||||
* Whether the model can read images, for the system prompt.
|
||||
*
|
||||
* Passed in rather than checked here because the answer is already known to the
|
||||
* view: the snip action is gated on the same value, and asking twice would mean
|
||||
* two capability requests per model change.
|
||||
*/
|
||||
vision?: VisionSupport;
|
||||
}
|
||||
|
||||
/** Which tool is running right now, and how stale it is getting. */
|
||||
export interface ToolActivity {
|
||||
/** Human-readable label, e.g. "Searching the web". */
|
||||
label: string;
|
||||
/**
|
||||
* How many times this turn has now started this *identical* call.
|
||||
*
|
||||
* Counted by the agent over name and arguments together, so two different
|
||||
* searches are two tools doing their job rather than a repeat.
|
||||
*/
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface UseChat {
|
||||
session: Session;
|
||||
isStreaming: boolean;
|
||||
streamingResponse: string;
|
||||
/**
|
||||
* The reasoning to show right now: only the part since the last tool call.
|
||||
*
|
||||
* Deliberately not the whole trace. The full one is carried on the turn for
|
||||
* storage; this is the live view, and it is cleared each time a tool starts so
|
||||
* a long research turn does not stack every round's thinking on screen.
|
||||
*/
|
||||
streamingThinking: string;
|
||||
/** Short activity line such as "Searching the web", shown while a turn is in flight. */
|
||||
status?: string;
|
||||
/**
|
||||
* The tool running right now.
|
||||
*
|
||||
* Separate from {@link UseChat.status} because `status` goes quiet as soon as
|
||||
* the answer starts arriving, and the whole point of this one is to stay on
|
||||
* screen: a model that has started circling looks identical to a model that is
|
||||
* working, unless the tool and its repeat count stay visible.
|
||||
*/
|
||||
activity?: ToolActivity;
|
||||
error?: string;
|
||||
ask: (prompt: string, attachment?: PendingAttachment) => void;
|
||||
cancel: () => void;
|
||||
@@ -57,18 +100,27 @@ export function useChat(options: UseChatOptions): UseChat {
|
||||
const [streamingResponse, setStreamingResponse] = useState("");
|
||||
const [streamingThinking, setStreamingThinking] = useState("");
|
||||
const [status, setStatus] = useState<string | undefined>(undefined);
|
||||
const [activity, setActivity] = useState<ToolActivity | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const sessionRef = useRef(session);
|
||||
const preferencesRef = useRef(options.preferences);
|
||||
/**
|
||||
* Read through a ref for the same reason as the preferences: `ask` is a stable
|
||||
* callback, and the capability check lands after the first render.
|
||||
*/
|
||||
const visionRef = useRef<VisionSupport>(options.vision ?? "unknown");
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const liveRef = useRef(false);
|
||||
const responseRef = useRef("");
|
||||
const thinkingRef = useRef("");
|
||||
const thinkingRef = useRef<ThinkingTrace>(EMPTY_TRACE);
|
||||
const statusRef = useRef<string | undefined>(undefined);
|
||||
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
preferencesRef.current = options.preferences;
|
||||
// Only ever forwards. The check starts as "unknown", and a value arriving is
|
||||
// news; going back to "unknown" would be the view telling us less than before.
|
||||
if (options.vision) visionRef.current = options.vision;
|
||||
|
||||
/** Updates state and the ref together, so `ask` never reads a stale session. */
|
||||
const commit = useCallback((next: Session) => {
|
||||
@@ -90,14 +142,14 @@ export function useChat(options: UseChatOptions): UseChat {
|
||||
flushTimerRef.current = null;
|
||||
if (!liveRef.current) return;
|
||||
setStreamingResponse(responseRef.current);
|
||||
setStreamingThinking(thinkingRef.current);
|
||||
setStreamingThinking(thinkingRef.current.current);
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
}, []);
|
||||
|
||||
const flushNow = useCallback(() => {
|
||||
clearFlushTimer();
|
||||
setStreamingResponse(responseRef.current);
|
||||
setStreamingThinking(thinkingRef.current);
|
||||
setStreamingThinking(thinkingRef.current.current);
|
||||
}, [clearFlushTimer]);
|
||||
|
||||
const setStatusValue = useCallback((value: string | undefined) => {
|
||||
@@ -144,10 +196,11 @@ export function useChat(options: UseChatOptions): UseChat {
|
||||
commit(started);
|
||||
|
||||
responseRef.current = "";
|
||||
thinkingRef.current = "";
|
||||
thinkingRef.current = EMPTY_TRACE;
|
||||
setStreamingResponse("");
|
||||
setStreamingThinking("");
|
||||
setError(undefined);
|
||||
setActivity(undefined);
|
||||
setStatusValue("Thinking");
|
||||
liveRef.current = true;
|
||||
setIsStreaming(true);
|
||||
@@ -155,8 +208,31 @@ export function useChat(options: UseChatOptions): UseChat {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
// Built per turn rather than per session: the ledger inside it is the record of
|
||||
// what the user approved while answering this one question, and the next question
|
||||
// starts by asking again. The policy is cheap to rebuild and holds no state.
|
||||
const policy = createPolicy({
|
||||
supportPath: environment.supportPath,
|
||||
readRoots: preferences.extraReadRoots,
|
||||
writeRoots: preferences.extraWriteRoots,
|
||||
});
|
||||
const fileAccess = preferences.fileEnabled
|
||||
? { policy, approve: createApprovalLedger(confirmWithAlert).approve }
|
||||
: undefined;
|
||||
|
||||
const messages = buildMessages(
|
||||
composeSystemPrompt(preferences.systemPrompt, preferences.webEnabled),
|
||||
composeSystemPrompt(preferences.systemPrompt, {
|
||||
model: preferences.model,
|
||||
host: preferences.host,
|
||||
selfHosted: isSelfHosted(preferences.host),
|
||||
webTools: preferences.webEnabled,
|
||||
vision: visionRef.current,
|
||||
fileTools: preferences.fileEnabled,
|
||||
fileRoots: {
|
||||
read: policy.readRoots.map((root) => displayPath(root, policy.home)),
|
||||
write: policy.writeRoots.map((root) => displayPath(root, policy.home)),
|
||||
},
|
||||
}),
|
||||
base.turns,
|
||||
prompt,
|
||||
attachment ? [attachment.base64] : undefined,
|
||||
@@ -172,9 +248,24 @@ export function useChat(options: UseChatOptions): UseChat {
|
||||
apiKey: preferences.apiKey,
|
||||
model: preferences.model,
|
||||
think: preferences.think,
|
||||
// `undefined` is "unlimited" in the preferences, and `null` is what the
|
||||
// agent reads as "no cap": an omitted option there means the default
|
||||
// cap, so the two conventions meet here.
|
||||
maxToolRounds: preferences.maxToolRounds ?? null,
|
||||
messages,
|
||||
tools: toolsFor({ webEnabled: preferences.webEnabled }),
|
||||
toolContext: { host: preferences.host, apiKey: preferences.apiKey },
|
||||
tools: toolsFor({
|
||||
webEnabled: preferences.webEnabled,
|
||||
fileEnabled: preferences.fileEnabled,
|
||||
}),
|
||||
toolContext: {
|
||||
host: preferences.host,
|
||||
apiKey: preferences.apiKey,
|
||||
place: preferences.place,
|
||||
// Only wired up when the user switched it on, so an off setting
|
||||
// cannot reach the network for a location at all.
|
||||
ipPlace: preferences.ipGeolocation ? ipPlace : undefined,
|
||||
files: fileAccess,
|
||||
},
|
||||
signal: controller.signal,
|
||||
events: {
|
||||
onContentDelta: (delta) => {
|
||||
@@ -183,10 +274,18 @@ export function useChat(options: UseChatOptions): UseChat {
|
||||
scheduleFlush();
|
||||
},
|
||||
onThinkingDelta: (delta) => {
|
||||
thinkingRef.current += delta;
|
||||
thinkingRef.current = withThinking(thinkingRef.current, delta);
|
||||
scheduleFlush();
|
||||
},
|
||||
onStatus: (value) => setStatusValue(value),
|
||||
onToolStart: (_name, label, count) => {
|
||||
// The reasoning that produced this call is spent, so the live
|
||||
// segment is cleared and the tool line takes its place. The full
|
||||
// trace is untouched, because the stored turn keeps all of it.
|
||||
thinkingRef.current = afterToolCall(thinkingRef.current);
|
||||
setActivity({ label, count });
|
||||
flushNow();
|
||||
},
|
||||
onToolCall: (record) => {
|
||||
toolCalls = [...toolCalls, record];
|
||||
},
|
||||
@@ -194,7 +293,7 @@ export function useChat(options: UseChatOptions): UseChat {
|
||||
});
|
||||
|
||||
responseRef.current = result.content;
|
||||
thinkingRef.current = result.thinking;
|
||||
thinkingRef.current = withFinalTrace(result.thinking);
|
||||
toolCalls = result.toolCalls;
|
||||
} catch (thrown) {
|
||||
if (isAbortError(thrown)) {
|
||||
@@ -208,7 +307,7 @@ export function useChat(options: UseChatOptions): UseChat {
|
||||
const finished = {
|
||||
...turn,
|
||||
response: responseRef.current,
|
||||
thinking: thinkingRef.current,
|
||||
thinking: thinkingRef.current.full,
|
||||
toolCalls,
|
||||
error: failure,
|
||||
};
|
||||
@@ -224,6 +323,7 @@ export function useChat(options: UseChatOptions): UseChat {
|
||||
commit(updated);
|
||||
setIsStreaming(false);
|
||||
setStatusValue(undefined);
|
||||
setActivity(undefined);
|
||||
setError(failure);
|
||||
flushNow();
|
||||
|
||||
@@ -241,9 +341,11 @@ export function useChat(options: UseChatOptions): UseChat {
|
||||
liveRef.current = false;
|
||||
setIsStreaming(false);
|
||||
setStatusValue(undefined);
|
||||
setActivity(undefined);
|
||||
setError(undefined);
|
||||
setStreamingResponse("");
|
||||
setStreamingThinking("");
|
||||
thinkingRef.current = EMPTY_TRACE;
|
||||
commit(next);
|
||||
},
|
||||
[commit, setStatusValue],
|
||||
@@ -266,6 +368,7 @@ export function useChat(options: UseChatOptions): UseChat {
|
||||
streamingResponse,
|
||||
streamingThinking,
|
||||
status,
|
||||
activity,
|
||||
error,
|
||||
ask,
|
||||
cancel,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { loadPreferences, type ResolvedPreferences } from "../preferences";
|
||||
|
||||
/**
|
||||
* The preferences a view should use, or `undefined` until they have loaded.
|
||||
*
|
||||
* Every reader goes through `loadPreferences`, which layers the model chosen in
|
||||
* the Manage Models command on top of what Vicinae holds, and that read is asynchronous
|
||||
* because the selection lives in LocalStorage. A hook rather than a synchronous
|
||||
* call so that the one reader is also the only way to get preferences into a
|
||||
* component, and a view that forgets to handle the loading case fails to compile
|
||||
* rather than quietly using a stale model.
|
||||
*
|
||||
* `loadPreferences` never rejects (an unreadable selection means no selection),
|
||||
* so there is no error state to render here.
|
||||
*/
|
||||
export interface PreferencesState {
|
||||
preferences?: ResolvedPreferences;
|
||||
/** Re-reads them, for a view that has just changed the selection itself. */
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export function usePreferences(): PreferencesState {
|
||||
const [preferences, setPreferences] = useState<ResolvedPreferences | undefined>(undefined);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void loadPreferences().then((loaded) => {
|
||||
if (!cancelled) setPreferences(loaded);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
const reload = useCallback(() => setAttempt((count) => count + 1), []);
|
||||
|
||||
return { preferences, reload };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ResolvedPreferences } from "../preferences";
|
||||
import type { VisionSupport } from "../types";
|
||||
import { visionSupport } from "../vision";
|
||||
|
||||
/**
|
||||
@@ -17,12 +18,22 @@ import { visionSupport } from "../vision";
|
||||
export interface VisionState {
|
||||
/** True only once Ollama has confirmed the model reports the `vision` capability. */
|
||||
canSeeImages: boolean;
|
||||
/**
|
||||
* The same answer without the fail-closed collapse.
|
||||
*
|
||||
* Carried because the system prompt needs the difference: telling a model it
|
||||
* cannot see when the check merely failed is a false claim about the user's
|
||||
* setup, and telling it nothing is what made it reason out loud about its own
|
||||
* capability instead of describing the image.
|
||||
*/
|
||||
support: VisionSupport;
|
||||
/** True while an answer for this model is still outstanding. */
|
||||
isChecking: boolean;
|
||||
}
|
||||
|
||||
export function useVisionSupport(preferences: ResolvedPreferences): VisionState {
|
||||
const [canSeeImages, setCanSeeImages] = useState(false);
|
||||
const [support, setSupport] = useState<VisionSupport>("unknown");
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,8 +48,10 @@ export function useVisionSupport(preferences: ResolvedPreferences): VisionState
|
||||
model: preferences.model,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((support) => {
|
||||
if (!cancelled) setCanSeeImages(support === "yes");
|
||||
.then((answer) => {
|
||||
if (cancelled) return;
|
||||
setSupport(answer);
|
||||
setCanSeeImages(answer === "yes");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsChecking(false);
|
||||
@@ -50,5 +63,5 @@ export function useVisionSupport(preferences: ResolvedPreferences): VisionState
|
||||
};
|
||||
}, [preferences.host, preferences.apiKey, preferences.model]);
|
||||
|
||||
return { canSeeImages, isChecking };
|
||||
return { canSeeImages, support, isChecking };
|
||||
}
|
||||
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
import { mapOutsideCodeSpans, markdownLink } from "./code";
|
||||
|
||||
/**
|
||||
* Custom HTML rendering for the detail panel, and the fence around it.
|
||||
*
|
||||
* Raw HTML is not merely tolerated by Vicinae's markdown renderer, it is the
|
||||
* only route to anything beyond what GFM offers. `markdown-model.cpp` passes
|
||||
* `CMARK_NODE_HTML_INLINE` through unescaped, and `MdParagraph.qml` hands the
|
||||
* result to a `TextEdit` with `textFormat: RichText`, so a `<span>` with a
|
||||
* colour, a `<sup>`, or a small `<table>` really does draw. That is the
|
||||
* extension point for a model that wants to show a status chip or a compact
|
||||
* comparison instead of describing one.
|
||||
*
|
||||
* It is also a hole, because the same route carries whatever else the model
|
||||
* writes: a `<script>` block, an `<img>` pointing anywhere, a `style` attribute
|
||||
* with a `url()` in it. Qt's rich text engine does not run JavaScript, so this
|
||||
* is not the web's threat model, but a stripped tag or a silently swallowed
|
||||
* block still turns a working answer into a confusing one. So the pass is
|
||||
* deliberately two-sided:
|
||||
*
|
||||
* - What the renderer can draw is kept, with attributes and style declarations
|
||||
* filtered to the ones that draw something.
|
||||
* - What it cannot draw is kept as *text* rather than dropped. A model that
|
||||
* wrote `<div class="card">` gets the tags shown literally, which is ugly but
|
||||
* honest, and beats a mystery about where the markup went.
|
||||
* - The one thing removed with its content is content that is not content:
|
||||
* `script`, `style` and the other element types whose body would otherwise
|
||||
* land on screen as prose.
|
||||
*
|
||||
* Two rewrites are worth calling out because they make HTML the *worse* choice
|
||||
* less often. An `<a href>` becomes a markdown link, because Vicinae only makes
|
||||
* a link clickable when the parser sees a markdown destination. An `<img>`
|
||||
* becomes a markdown image, because that is the path that actually resolves a
|
||||
* `file:` or `data:` source and gets an image on screen.
|
||||
*
|
||||
* Callers pass text that contains no fenced code. Fences are the caller's job
|
||||
* because the same scan has to serve the maths pass in `render.ts`, and inline
|
||||
* code spans are skipped here because a `` `<b>` `` is a thing being shown, not
|
||||
* a thing being applied.
|
||||
*/
|
||||
|
||||
/** The tags that draw something in a rich text paragraph. */
|
||||
const ALLOWED_TAGS = new Set([
|
||||
"a",
|
||||
"b",
|
||||
"big",
|
||||
"blockquote",
|
||||
"br",
|
||||
"center",
|
||||
"cite",
|
||||
"code",
|
||||
"dd",
|
||||
"dfn",
|
||||
"div",
|
||||
"dl",
|
||||
"dt",
|
||||
"em",
|
||||
"font",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"i",
|
||||
"img",
|
||||
"ins",
|
||||
"kbd",
|
||||
"li",
|
||||
"mark",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"q",
|
||||
"s",
|
||||
"samp",
|
||||
"small",
|
||||
"span",
|
||||
"strike",
|
||||
"strong",
|
||||
"sub",
|
||||
"sup",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"tfoot",
|
||||
"th",
|
||||
"thead",
|
||||
"tr",
|
||||
"tt",
|
||||
"u",
|
||||
"ul",
|
||||
"var",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Element types removed with everything inside them.
|
||||
*
|
||||
* These are the ones whose body is markup, code or fallback text rather than
|
||||
* something to read. `svg` and `math` are here because Qt cannot draw either,
|
||||
* and their children would otherwise spill out as a wall of coordinates.
|
||||
*/
|
||||
const STRIPPED_WITH_CONTENT = new Set([
|
||||
"applet",
|
||||
"audio",
|
||||
"canvas",
|
||||
"dialog",
|
||||
"form",
|
||||
"frameset",
|
||||
"iframe",
|
||||
"math",
|
||||
"noscript",
|
||||
"object",
|
||||
"picture",
|
||||
"script",
|
||||
"select",
|
||||
"style",
|
||||
"svg",
|
||||
"template",
|
||||
"textarea",
|
||||
"title",
|
||||
"video",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Element types removed on their own.
|
||||
*
|
||||
* These have no content of their own, so looking for a closing tag would find
|
||||
* whatever the document happens to contain next and swallow it. `embed` and
|
||||
* `source` sit here for the same reason even though they can be written paired.
|
||||
*/
|
||||
const STRIPPED_EMPTY = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"col",
|
||||
"embed",
|
||||
"frame",
|
||||
"head",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
|
||||
/** Attributes worth keeping, which is the ones that change how something draws. */
|
||||
const ALLOWED_ATTRIBUTES = new Set([
|
||||
"align",
|
||||
"alt",
|
||||
"bgcolor",
|
||||
"border",
|
||||
"cellpadding",
|
||||
"cellspacing",
|
||||
"color",
|
||||
"colspan",
|
||||
"dir",
|
||||
"face",
|
||||
"height",
|
||||
"href",
|
||||
"rowspan",
|
||||
"size",
|
||||
"src",
|
||||
"style",
|
||||
"title",
|
||||
"valign",
|
||||
"width",
|
||||
]);
|
||||
|
||||
/** Attributes whose value has to be a number, optionally a percentage for the two sizes. */
|
||||
const NUMERIC_ATTRIBUTES = new Set([
|
||||
"border",
|
||||
"cellpadding",
|
||||
"cellspacing",
|
||||
"colspan",
|
||||
"height",
|
||||
"rowspan",
|
||||
"size",
|
||||
"width",
|
||||
]);
|
||||
|
||||
/** Style declarations Qt's rich text engine acts on. */
|
||||
const ALLOWED_STYLE_PROPERTIES = new Set([
|
||||
"background",
|
||||
"background-color",
|
||||
"border",
|
||||
"border-bottom",
|
||||
"border-color",
|
||||
"border-left",
|
||||
"border-right",
|
||||
"border-style",
|
||||
"border-top",
|
||||
"border-width",
|
||||
"color",
|
||||
"font",
|
||||
"font-family",
|
||||
"font-size",
|
||||
"font-style",
|
||||
"font-weight",
|
||||
"letter-spacing",
|
||||
"line-height",
|
||||
"margin",
|
||||
"margin-left",
|
||||
"margin-right",
|
||||
"padding",
|
||||
"padding-left",
|
||||
"padding-right",
|
||||
"text-align",
|
||||
"text-decoration",
|
||||
"vertical-align",
|
||||
"white-space",
|
||||
"width",
|
||||
"height",
|
||||
]);
|
||||
|
||||
/**
|
||||
* The shape of a style value that can do some good.
|
||||
*
|
||||
* Anything reaching outside the document is the point of this test: `url()`,
|
||||
* `expression()` and `javascript:` all pull something in, and none of them can
|
||||
* make a paragraph look better.
|
||||
*/
|
||||
const UNSAFE_STYLE_VALUE = /(url\s*\(|expression\s*\(|javascript:|@import|[<>\\])/i;
|
||||
|
||||
/** A tag, tolerating `>` inside a quoted attribute value. */
|
||||
const TAG = /^<(\/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:\s+(?:[^<>"']|"[^"]*"|'[^']*')*)?)(\/?)>/;
|
||||
|
||||
const ATTRIBUTE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
||||
|
||||
/** A destination a link may point at. */
|
||||
const SAFE_URL = /^(?:https?:|mailto:|tel:|#)/i;
|
||||
|
||||
/**
|
||||
* A source an image may point at.
|
||||
*
|
||||
* Narrower than a link, and deliberately so. A `data:` source counts only when
|
||||
* it carries an image, because `data:text/html` is a document rather than
|
||||
* something to draw, and the extractor that turns an `<img>` inside an HTML
|
||||
* block into a real image block reads this attribute directly.
|
||||
*/
|
||||
const SAFE_IMAGE_URL = /^(?:https?:|file:|data:image\/)/i;
|
||||
|
||||
/** Escape for an HTML attribute, which is also escape enough for an HTML text node. */
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** A tag kept as it was written, shown as text rather than applied. */
|
||||
function asText(raw: string): string {
|
||||
return escapeHtml(raw);
|
||||
}
|
||||
|
||||
function sanitizeStyle(value: string): string | undefined {
|
||||
const kept: string[] = [];
|
||||
|
||||
for (const declaration of value.split(";")) {
|
||||
const colon = declaration.indexOf(":");
|
||||
if (colon === -1) continue;
|
||||
|
||||
const property = declaration.slice(0, colon).trim().toLowerCase();
|
||||
const raw = declaration.slice(colon + 1).trim();
|
||||
|
||||
if (!ALLOWED_STYLE_PROPERTIES.has(property)) continue;
|
||||
if (raw.length === 0 || raw.length > 120) continue;
|
||||
if (UNSAFE_STYLE_VALUE.test(raw)) continue;
|
||||
|
||||
kept.push(`${property}:${raw}`);
|
||||
}
|
||||
|
||||
return kept.length > 0 ? kept.join(";") : undefined;
|
||||
}
|
||||
|
||||
function sanitizeAttribute(name: string, value: string): string | undefined {
|
||||
if (name.startsWith("on")) return undefined;
|
||||
if (!ALLOWED_ATTRIBUTES.has(name)) return undefined;
|
||||
|
||||
if (name === "style") return sanitizeStyle(value);
|
||||
|
||||
if (name === "href") {
|
||||
const trimmed = value.trim();
|
||||
return SAFE_URL.test(trimmed) ? trimmed : undefined;
|
||||
}
|
||||
|
||||
if (name === "src") {
|
||||
const trimmed = value.trim();
|
||||
return SAFE_IMAGE_URL.test(trimmed) ? trimmed : undefined;
|
||||
}
|
||||
|
||||
if (NUMERIC_ATTRIBUTES.has(name)) {
|
||||
const trimmed = value.trim();
|
||||
if (/^\d+$/.test(trimmed)) return trimmed;
|
||||
if (/^\d+%$/.test(trimmed) && (name === "width" || name === "height")) return trimmed;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return undefined;
|
||||
|
||||
return escapeHtml(trimmed.slice(0, 120));
|
||||
}
|
||||
|
||||
function renderAttributes(raw: string): string {
|
||||
const attributes: string[] = [];
|
||||
|
||||
for (const match of raw.matchAll(ATTRIBUTE)) {
|
||||
const name = match[1].toLowerCase();
|
||||
const value = match[2] ?? match[3] ?? match[4] ?? "";
|
||||
const sanitized = sanitizeAttribute(name, value);
|
||||
if (sanitized === undefined) continue;
|
||||
attributes.push(`${name}="${sanitized}"`);
|
||||
}
|
||||
|
||||
return attributes.length > 0 ? ` ${attributes.join(" ")}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* One attribute, exactly as it was written.
|
||||
*
|
||||
* Read straight from the tag rather than out of {@link renderAttributes}, because
|
||||
* that pass escapes `&` for HTML and a link destination is not HTML: a query
|
||||
* string should reach the parser as it was, not as `&` and then as an
|
||||
* entity to decode back.
|
||||
*/
|
||||
function readAttribute(raw: string, wanted: string): string | undefined {
|
||||
for (const match of raw.matchAll(ATTRIBUTE)) {
|
||||
if (match[1].toLowerCase() === wanted) return (match[2] ?? match[3] ?? match[4] ?? "").trim();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface Tag {
|
||||
/** The tag exactly as written. */
|
||||
raw: string;
|
||||
/** Lower case name, with no leading slash. */
|
||||
name: string;
|
||||
closing: boolean;
|
||||
/** Everything between the name and the closing bracket. */
|
||||
attributes: string;
|
||||
/** Index just past the `>`. */
|
||||
end: number;
|
||||
selfClosing: boolean;
|
||||
}
|
||||
|
||||
function readTag(text: string, start: number): Tag | undefined {
|
||||
const match = TAG.exec(text.slice(start));
|
||||
if (!match) return undefined;
|
||||
|
||||
const raw = match[0];
|
||||
// A `>` inside a quoted value is part of the value, so the match is trusted
|
||||
// and the index is computed from its length rather than from indexOf.
|
||||
if (raw.length > 2000) return undefined;
|
||||
|
||||
return {
|
||||
raw,
|
||||
name: match[2].toLowerCase(),
|
||||
closing: match[1] === "/",
|
||||
attributes: match[3],
|
||||
selfClosing: match[4] === "/",
|
||||
end: start + raw.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The text between an opening tag and its matching close, or the rest of the text. */
|
||||
function findClose(text: string, name: string, start: number): { inner: string; end: number } {
|
||||
const closer = new RegExp(`</${name}\\s*>`, "i");
|
||||
const match = closer.exec(text.slice(start));
|
||||
if (!match) return { inner: text.slice(start), end: text.length };
|
||||
|
||||
return { inner: text.slice(start, start + match.index), end: start + match.index + match[0].length };
|
||||
}
|
||||
|
||||
function renderTag(text: string, tag: Tag): { text: string; end: number } {
|
||||
const { name } = tag;
|
||||
|
||||
if (STRIPPED_EMPTY.has(name)) return { text: "", end: tag.end };
|
||||
|
||||
if (STRIPPED_WITH_CONTENT.has(name)) {
|
||||
if (tag.selfClosing) return { text: "", end: tag.end };
|
||||
const closed = findClose(text, name, tag.end);
|
||||
return { text: "", end: closed.end };
|
||||
}
|
||||
|
||||
// A link is clickable in Vicinae only as a markdown destination, so an anchor
|
||||
// is rewritten rather than passed through. A stray `</a>` is dropped, because
|
||||
// a well-formed pair was already consumed by its opening tag.
|
||||
if (name === "a") {
|
||||
if (tag.closing) return { text: "", end: tag.end };
|
||||
|
||||
const href = readAttribute(tag.attributes, "href");
|
||||
const closed = findClose(text, "a", tag.end);
|
||||
const label = sanitizeInlineHtml(closed.inner).trim();
|
||||
|
||||
if (!href || !SAFE_URL.test(href)) return { text: label, end: closed.end };
|
||||
return { text: markdownLink(label, href), end: closed.end };
|
||||
}
|
||||
|
||||
// An `<img>` needs no branch of its own. It is allowed, so it passes through
|
||||
// with its attributes filtered, and the one place an image written as HTML is
|
||||
// loaded for real is the extractor for an HTML block, which reads that
|
||||
// attribute rather than any markdown this pass could write.
|
||||
if (!ALLOWED_TAGS.has(name)) return { text: asText(tag.raw), end: tag.end };
|
||||
|
||||
const slash = tag.closing ? "/" : "";
|
||||
const attributes = tag.closing ? "" : renderAttributes(tag.attributes);
|
||||
const close = tag.selfClosing ? "/" : "";
|
||||
|
||||
return { text: `<${slash}${name}${attributes}${close}>`, end: tag.end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters raw HTML down to what the detail panel can actually draw.
|
||||
*
|
||||
* The result is markdown, so the tags it keeps are read back by the parser as
|
||||
* inline HTML, and the tags it drops are entities that read as text.
|
||||
*/
|
||||
export function sanitizeInlineHtml(text: string): string {
|
||||
return mapOutsideCodeSpans(text, sanitizeMarkup);
|
||||
}
|
||||
|
||||
/** The tag pass, run on a run of text that holds no code spans. */
|
||||
function sanitizeMarkup(text: string): string {
|
||||
let out = "";
|
||||
let index = 0;
|
||||
|
||||
while (index < text.length) {
|
||||
if (text.startsWith("<!--", index)) {
|
||||
const close = text.indexOf("-->", index + 4);
|
||||
index = close === -1 ? text.length : close + 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (text[index] === "<") {
|
||||
const tag = readTag(text, index);
|
||||
if (tag) {
|
||||
const rendered = renderTag(text, tag);
|
||||
out += rendered.text;
|
||||
index = rendered.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
out += text[index];
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,963 @@
|
||||
/**
|
||||
* LaTeX rendering for the detail panel.
|
||||
*
|
||||
* Vicinae has no maths renderer at all. `MarkdownModel` maps cmark nodes to a
|
||||
* fixed set of block components (`MdHeading`, `MdCodeBlock`, `MdTable`,
|
||||
* `MdImage` and friends) and there is no maths block among them, so a formula
|
||||
* the model writes as `$...$` reaches the pane as literal TeX with its
|
||||
* backslashes and braces intact.
|
||||
*
|
||||
* Two facts about the renderer decide the whole design:
|
||||
*
|
||||
* - The output is markdown, and the parts of it written as raw HTML are handed
|
||||
* straight to a `TextEdit` with `textFormat: RichText` (`MdParagraph.qml`
|
||||
* sets exactly that, and `markdown-model.cpp` passes `CMARK_NODE_HTML_INLINE`
|
||||
* through untouched). Qt's rich text engine supports `sup`, `sub`, `b` and
|
||||
* `i`, which is enough for scripts and for the font commands.
|
||||
* - The pane is a launcher-width column. A formula has to read linearly, so
|
||||
* stacked fractions and matrix layouts are out and `a/b` and `√(x)` are in.
|
||||
*
|
||||
* So this is a linearisation rather than a typesetter: it keeps the meaning and
|
||||
* gives up the two-dimensional layout, because a two-dimensional layout cannot
|
||||
* survive a narrow column in the first place.
|
||||
*
|
||||
* Emitted text is escaped for markdown as it is produced, and the tags this
|
||||
* module adds are handed over as HTML, which is what makes a formula safe to
|
||||
* drop into a table cell or a blockquote.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Characters that mean something to markdown, and that a formula must not.
|
||||
*
|
||||
* A backslash escape is the one form the parser strips everywhere: it works in
|
||||
* a paragraph, inside a blockquote, and inside a GFM table cell, where a bare
|
||||
* `|` would end the cell. Everything here is ASCII punctuation, which is
|
||||
* exactly the set CommonMark allows to be escaped.
|
||||
*
|
||||
* Only characters that mean something *anywhere* are here. The ones that only
|
||||
* matter as the first thing on a line (a heading `#`, a list `-`, a setext
|
||||
* underline `=`) are handled by {@link guardBlockStart} instead, so that an
|
||||
* ordinary `a + b` does not come out as `a \+ b`.
|
||||
*/
|
||||
const MARKDOWN_ESCAPE = /[\\`*_[\]<>|&]/g;
|
||||
|
||||
function literal(text: string): string {
|
||||
return text.replace(MARKDOWN_ESCAPE, (char) => `\\${char}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markers that open a block when they are the first thing on a line.
|
||||
*
|
||||
* A display formula is emitted on its own line, so `- 1` would otherwise turn
|
||||
* into a list and `= x` would turn the line above it into a heading, which is a
|
||||
* lot of damage for a minus sign. A marker needs whitespace or the end of the
|
||||
* line after it, so `-1` and `1.5` are left exactly as they are.
|
||||
*/
|
||||
const BLOCK_START = /^[#>=\-+*](?=\s|$)/;
|
||||
|
||||
/** An ordered list marker, where it is the full stop that has to be escaped. */
|
||||
const ORDERED_START = /^(\d+)([.)])(?=\s|$)/;
|
||||
|
||||
/** A line made only of rule characters, which is a horizontal rule or a setext underline. */
|
||||
const RULE_LINE = /^[-=*_][-=*_ \t]*$/;
|
||||
|
||||
/** Escapes a leading marker that would otherwise be read as block syntax. */
|
||||
function guardBlockStart(formula: string): string {
|
||||
if (RULE_LINE.test(formula)) return `\\${formula}`;
|
||||
if (BLOCK_START.test(formula)) return `\\${formula}`;
|
||||
|
||||
const ordered = ORDERED_START.exec(formula);
|
||||
if (ordered) return `${ordered[1]}\\${ordered[2]}${formula.slice(ordered[0].length)}`;
|
||||
|
||||
return formula;
|
||||
}
|
||||
|
||||
/**
|
||||
* TeX command to character, for the commands that take no argument.
|
||||
*
|
||||
* Functions such as `\sin` map to their own name rather than to a character,
|
||||
* which is the right answer twice over: it is what TeX prints, and the output is
|
||||
* plain text anyway so there is no italic to switch off.
|
||||
*/
|
||||
const SYMBOLS: Record<string, string> = {
|
||||
// Greek, lower case. A capital Greek letter is written as the Latin capital in
|
||||
// TeX, so only the ones whose glyph is not the Latin letter are listed.
|
||||
alpha: "α",
|
||||
beta: "β",
|
||||
gamma: "γ",
|
||||
delta: "δ",
|
||||
epsilon: "ϵ",
|
||||
varepsilon: "ε",
|
||||
zeta: "ζ",
|
||||
eta: "η",
|
||||
theta: "θ",
|
||||
vartheta: "ϑ",
|
||||
iota: "ι",
|
||||
kappa: "κ",
|
||||
varkappa: "ϰ",
|
||||
lambda: "λ",
|
||||
mu: "μ",
|
||||
nu: "ν",
|
||||
xi: "ξ",
|
||||
omicron: "ο",
|
||||
pi: "π",
|
||||
varpi: "ϖ",
|
||||
rho: "ρ",
|
||||
varrho: "ϱ",
|
||||
sigma: "σ",
|
||||
varsigma: "ς",
|
||||
tau: "τ",
|
||||
upsilon: "υ",
|
||||
phi: "ϕ",
|
||||
varphi: "φ",
|
||||
chi: "χ",
|
||||
psi: "ψ",
|
||||
omega: "ω",
|
||||
Gamma: "Γ",
|
||||
Delta: "Δ",
|
||||
Theta: "Θ",
|
||||
Lambda: "Λ",
|
||||
Xi: "Ξ",
|
||||
Pi: "Π",
|
||||
Sigma: "Σ",
|
||||
Upsilon: "Υ",
|
||||
Phi: "Φ",
|
||||
Psi: "Ψ",
|
||||
Omega: "Ω",
|
||||
|
||||
// Binary operators and relations.
|
||||
times: "×",
|
||||
div: "÷",
|
||||
cdot: "·",
|
||||
pm: "±",
|
||||
mp: "∓",
|
||||
ast: "∗",
|
||||
star: "⋆",
|
||||
circ: "∘",
|
||||
bullet: "•",
|
||||
oplus: "⊕",
|
||||
ominus: "⊖",
|
||||
otimes: "⊗",
|
||||
oslash: "⊘",
|
||||
leq: "≤",
|
||||
le: "≤",
|
||||
geq: "≥",
|
||||
ge: "≥",
|
||||
neq: "≠",
|
||||
ne: "≠",
|
||||
equiv: "≡",
|
||||
approx: "≈",
|
||||
sim: "∼",
|
||||
simeq: "≃",
|
||||
cong: "≅",
|
||||
propto: "∝",
|
||||
ll: "≪",
|
||||
gg: "≫",
|
||||
in: "∈",
|
||||
notin: "∉",
|
||||
ni: "∋",
|
||||
subset: "⊂",
|
||||
subseteq: "⊆",
|
||||
supset: "⊃",
|
||||
supseteq: "⊇",
|
||||
cup: "∪",
|
||||
cap: "∩",
|
||||
setminus: "∖",
|
||||
emptyset: "∅",
|
||||
varnothing: "∅",
|
||||
forall: "∀",
|
||||
exists: "∃",
|
||||
nexists: "∄",
|
||||
neg: "¬",
|
||||
lnot: "¬",
|
||||
not: "¬",
|
||||
land: "∧",
|
||||
wedge: "∧",
|
||||
lor: "∨",
|
||||
vee: "∨",
|
||||
perp: "⊥",
|
||||
parallel: "∥",
|
||||
angle: "∠",
|
||||
triangle: "△",
|
||||
square: "□",
|
||||
therefore: "∴",
|
||||
because: "∵",
|
||||
|
||||
// Big operators and the functions that read as words.
|
||||
sum: "∑",
|
||||
prod: "∏",
|
||||
coprod: "∐",
|
||||
int: "∫",
|
||||
iint: "∬",
|
||||
iiint: "∭",
|
||||
oint: "∮",
|
||||
bigcup: "⋃",
|
||||
bigcap: "⋂",
|
||||
bigoplus: "⨁",
|
||||
bigotimes: "⨂",
|
||||
bigvee: "⋁",
|
||||
bigwedge: "⋀",
|
||||
lim: "lim",
|
||||
sup: "sup",
|
||||
inf: "inf",
|
||||
max: "max",
|
||||
min: "min",
|
||||
log: "log",
|
||||
ln: "ln",
|
||||
exp: "exp",
|
||||
sin: "sin",
|
||||
cos: "cos",
|
||||
tan: "tan",
|
||||
cot: "cot",
|
||||
sec: "sec",
|
||||
csc: "csc",
|
||||
arcsin: "arcsin",
|
||||
arccos: "arccos",
|
||||
arctan: "arctan",
|
||||
sinh: "sinh",
|
||||
cosh: "cosh",
|
||||
tanh: "tanh",
|
||||
det: "det",
|
||||
dim: "dim",
|
||||
ker: "ker",
|
||||
gcd: "gcd",
|
||||
mod: "mod",
|
||||
bmod: "mod",
|
||||
pmod: "(mod ",
|
||||
|
||||
// Arrows.
|
||||
to: "→",
|
||||
rightarrow: "→",
|
||||
leftarrow: "←",
|
||||
gets: "←",
|
||||
leftrightarrow: "↔",
|
||||
Rightarrow: "⇒",
|
||||
Leftarrow: "⇐",
|
||||
Leftrightarrow: "⇔",
|
||||
implies: "⟹",
|
||||
iff: "⟺",
|
||||
mapsto: "↦",
|
||||
uparrow: "↑",
|
||||
downarrow: "↓",
|
||||
updownarrow: "↕",
|
||||
nearrow: "↗",
|
||||
searrow: "↘",
|
||||
swarrow: "↙",
|
||||
nwarrow: "↖",
|
||||
longrightarrow: "⟶",
|
||||
longleftarrow: "⟵",
|
||||
hookrightarrow: "↪",
|
||||
rightleftharpoons: "⇌",
|
||||
|
||||
// Everything else worth a glyph.
|
||||
infty: "∞",
|
||||
partial: "∂",
|
||||
nabla: "∇",
|
||||
ldots: "…",
|
||||
dots: "…",
|
||||
cdots: "⋯",
|
||||
vdots: "⋮",
|
||||
ddots: "⋱",
|
||||
degree: "°",
|
||||
deg: "°",
|
||||
prime: "′",
|
||||
ell: "ℓ",
|
||||
hbar: "ℏ",
|
||||
Re: "ℜ",
|
||||
Im: "ℑ",
|
||||
aleph: "ℵ",
|
||||
wp: "℘",
|
||||
imath: "ı",
|
||||
jmath: "ȷ",
|
||||
checkmark: "✓",
|
||||
dagger: "†",
|
||||
ddagger: "‡",
|
||||
S: "§",
|
||||
P: "¶",
|
||||
copyright: "©",
|
||||
langle: "⟨",
|
||||
rangle: "⟩",
|
||||
lfloor: "⌊",
|
||||
rfloor: "⌋",
|
||||
lceil: "⌈",
|
||||
rceil: "⌉",
|
||||
lvert: "|",
|
||||
rvert: "|",
|
||||
mid: "|",
|
||||
vert: "|",
|
||||
lVert: "‖",
|
||||
rVert: "‖",
|
||||
Vert: "‖",
|
||||
"%": "%",
|
||||
$: "$",
|
||||
"&": "&",
|
||||
"#": "#",
|
||||
_: "_",
|
||||
"{": "{",
|
||||
"}": "}",
|
||||
"|": "‖",
|
||||
",": "\u2009",
|
||||
";": "\u2005",
|
||||
":": "\u2005",
|
||||
"!": "",
|
||||
" ": " ",
|
||||
"\\": " ",
|
||||
};
|
||||
|
||||
/**
|
||||
* Commands that ask for a delimiter next, so `\left(` prints as `(`.
|
||||
*
|
||||
* `\left.` is the invisible delimiter TeX uses to pair a one-sided bracket, and
|
||||
* it maps to nothing rather than to a full stop.
|
||||
*/
|
||||
const DELIMITERS: Record<string, string> = {
|
||||
"(": "(",
|
||||
")": ")",
|
||||
"[": "[",
|
||||
"]": "]",
|
||||
"|": "|",
|
||||
".": "",
|
||||
"/": "/",
|
||||
"\\{": "{",
|
||||
"\\}": "}",
|
||||
"\\|": "‖",
|
||||
"\\langle": "⟨",
|
||||
"\\rangle": "⟩",
|
||||
"\\lvert": "|",
|
||||
"\\rvert": "|",
|
||||
"\\lVert": "‖",
|
||||
"\\rVert": "‖",
|
||||
"\\lfloor": "⌊",
|
||||
"\\rfloor": "⌋",
|
||||
"\\lceil": "⌈",
|
||||
"\\rceil": "⌉",
|
||||
};
|
||||
|
||||
/** Two-character ASCII spellings of an operator, which models write in a hurry. */
|
||||
const OPERATOR_SEQUENCES: Record<string, string> = {
|
||||
"<=": "≤",
|
||||
">=": "≥",
|
||||
"!=": "≠",
|
||||
"->": "→",
|
||||
"=>": "⇒",
|
||||
};
|
||||
|
||||
/** Combining marks, applied to the character before them. */
|
||||
const ACCENTS: Record<string, string> = {
|
||||
hat: "\u0302",
|
||||
widehat: "\u0302",
|
||||
bar: "\u0304",
|
||||
overline: "\u0305",
|
||||
vec: "\u20d7",
|
||||
tilde: "\u0303",
|
||||
widetilde: "\u0303",
|
||||
dot: "\u0307",
|
||||
ddot: "\u0308",
|
||||
acute: "\u0301",
|
||||
grave: "\u0300",
|
||||
check: "\u030c",
|
||||
breve: "\u0306",
|
||||
};
|
||||
|
||||
/** Double-struck capitals, which is what `\mathbb` is nearly always used for. */
|
||||
const DOUBLE_STRUCK: Record<string, string> = {
|
||||
A: "𝔸",
|
||||
B: "𝔹",
|
||||
C: "ℂ",
|
||||
D: "𝔻",
|
||||
E: "𝔼",
|
||||
F: "𝔽",
|
||||
G: "𝔾",
|
||||
H: "ℍ",
|
||||
I: "𝕀",
|
||||
J: "𝕁",
|
||||
K: "𝕂",
|
||||
L: "𝕃",
|
||||
M: "𝕄",
|
||||
N: "ℕ",
|
||||
O: "𝕆",
|
||||
P: "ℙ",
|
||||
Q: "ℚ",
|
||||
R: "ℝ",
|
||||
S: "𝕊",
|
||||
T: "𝕋",
|
||||
U: "𝕌",
|
||||
V: "𝕍",
|
||||
W: "𝕎",
|
||||
X: "𝕏",
|
||||
Y: "𝕐",
|
||||
Z: "ℤ",
|
||||
};
|
||||
|
||||
/** The script capitals Unicode actually has. The rest fall back to plain letters. */
|
||||
const SCRIPT: Record<string, string> = {
|
||||
B: "ℬ",
|
||||
E: "ℰ",
|
||||
F: "ℱ",
|
||||
H: "ℋ",
|
||||
I: "ℐ",
|
||||
L: "ℒ",
|
||||
M: "ℳ",
|
||||
R: "ℛ",
|
||||
};
|
||||
|
||||
const SUPERSCRIPTS: Record<string, string> = {
|
||||
"0": "⁰",
|
||||
"1": "¹",
|
||||
"2": "²",
|
||||
"3": "³",
|
||||
"4": "⁴",
|
||||
"5": "⁵",
|
||||
"6": "⁶",
|
||||
"7": "⁷",
|
||||
"8": "⁸",
|
||||
"9": "⁹",
|
||||
"+": "⁺",
|
||||
"−": "⁻",
|
||||
"-": "⁻",
|
||||
"=": "⁼",
|
||||
"(": "⁽",
|
||||
")": "⁾",
|
||||
a: "ᵃ",
|
||||
b: "ᵇ",
|
||||
c: "ᶜ",
|
||||
d: "ᵈ",
|
||||
e: "ᵉ",
|
||||
f: "ᶠ",
|
||||
g: "ᵍ",
|
||||
h: "ʰ",
|
||||
i: "ⁱ",
|
||||
j: "ʲ",
|
||||
k: "ᵏ",
|
||||
l: "ˡ",
|
||||
m: "ᵐ",
|
||||
n: "ⁿ",
|
||||
o: "ᵒ",
|
||||
p: "ᵖ",
|
||||
r: "ʳ",
|
||||
s: "ˢ",
|
||||
t: "ᵗ",
|
||||
u: "ᵘ",
|
||||
v: "ᵛ",
|
||||
w: "ʷ",
|
||||
x: "ˣ",
|
||||
y: "ʸ",
|
||||
z: "ᶻ",
|
||||
};
|
||||
|
||||
const SUBSCRIPTS: Record<string, string> = {
|
||||
"0": "₀",
|
||||
"1": "₁",
|
||||
"2": "₂",
|
||||
"3": "₃",
|
||||
"4": "₄",
|
||||
"5": "₅",
|
||||
"6": "₆",
|
||||
"7": "₇",
|
||||
"8": "₈",
|
||||
"9": "₉",
|
||||
"+": "₊",
|
||||
"−": "₋",
|
||||
"-": "₋",
|
||||
"=": "₌",
|
||||
"(": "₍",
|
||||
")": "₎",
|
||||
a: "ₐ",
|
||||
e: "ₑ",
|
||||
h: "ₕ",
|
||||
i: "ᵢ",
|
||||
j: "ⱼ",
|
||||
k: "ₖ",
|
||||
l: "ₗ",
|
||||
m: "ₘ",
|
||||
n: "ₙ",
|
||||
o: "ₒ",
|
||||
p: "ₚ",
|
||||
r: "ᵣ",
|
||||
s: "ₛ",
|
||||
t: "ₜ",
|
||||
u: "ᵤ",
|
||||
v: "ᵥ",
|
||||
x: "ₓ",
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the inside of a pair of dollar signs is plausibly maths.
|
||||
*
|
||||
* Without this the guard would turn `x=$a$b` and `cd $A; echo $B` into formulas,
|
||||
* which is the one way this feature can make an answer worse than leaving the
|
||||
* dollar signs alone. The test asks for a positive signal rather than merely the
|
||||
* absence of prose, so a bare `$x$` staying as written is the accepted cost.
|
||||
*/
|
||||
export function looksLikeMath(body: string): boolean {
|
||||
if (/\\[a-zA-Z]+/.test(body)) return true; // a TeX command
|
||||
if (/[\^_{}]/.test(body)) return true; // a script or a group
|
||||
if (/[=<>≤≥≠≈∈∑∫√∞∂∇]/.test(body)) return true; // an operator or a symbol
|
||||
if (/\d\s*[-+*/]|[-+*/]\s*\d/.test(body)) return true; // arithmetic on numbers
|
||||
return false;
|
||||
}
|
||||
|
||||
interface Slice {
|
||||
body: string;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface Command {
|
||||
name: string;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Reads `\name` or `\x`, where `x` is anything that is not a letter. */
|
||||
function readCommand(source: string, start: number): Command {
|
||||
const index = start + 1;
|
||||
if (index >= source.length) return { name: "", end: index };
|
||||
|
||||
const letters = /^[a-zA-Z]+/.exec(source.slice(index));
|
||||
if (letters) return { name: letters[0], end: index + letters[0].length };
|
||||
|
||||
return { name: source[index], end: index + 1 };
|
||||
}
|
||||
|
||||
/** Reads a balanced `{...}`, tolerating escapes and an unclosed brace. */
|
||||
function readGroup(source: string, start: number): Slice {
|
||||
let depth = 0;
|
||||
let index = start;
|
||||
|
||||
while (index < source.length) {
|
||||
const char = source[index];
|
||||
|
||||
if (char === "\\") {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "{") depth += 1;
|
||||
else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return { body: source.slice(start + 1, index), end: index + 1 };
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
// An unbalanced brace is a malformed formula, not a reason to drop the rest of
|
||||
// the answer, so the remainder is treated as the argument.
|
||||
return { body: source.slice(start + 1), end: source.length };
|
||||
}
|
||||
|
||||
/** The next argument: a braced group, a control sequence, or one character. */
|
||||
function readArgument(source: string, start: number): Slice {
|
||||
let index = start;
|
||||
while (index < source.length && /\s/.test(source[index])) index += 1;
|
||||
if (index >= source.length) return { body: "", end: index };
|
||||
|
||||
if (source[index] === "{") return readGroup(source, index);
|
||||
|
||||
if (source[index] === "\\") {
|
||||
const command = readCommand(source, index);
|
||||
return { body: source.slice(index, command.end), end: command.end };
|
||||
}
|
||||
|
||||
return { body: source[index], end: index + 1 };
|
||||
}
|
||||
|
||||
/** Reads `[...]`, the optional argument `\sqrt` uses for the degree. */
|
||||
function readOptional(source: string, start: number): Slice | undefined {
|
||||
let index = start;
|
||||
while (index < source.length && /\s/.test(source[index])) index += 1;
|
||||
if (source[index] !== "[") return undefined;
|
||||
|
||||
const close = source.indexOf("]", index + 1);
|
||||
if (close === -1) return undefined;
|
||||
|
||||
return { body: source.slice(index + 1, close), end: close + 1 };
|
||||
}
|
||||
|
||||
/** Whether an argument needs no brackets beside a slash or a root sign. */
|
||||
function isAtomic(source: string): boolean {
|
||||
const trimmed = source.trim();
|
||||
if (trimmed.length === 0) return true;
|
||||
if (/^[A-Za-z0-9]$/.test(trimmed)) return true;
|
||||
if (/^\d+(\.\d+)?$/.test(trimmed)) return true;
|
||||
return /^\\[a-zA-Z]+$/.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a rendered script to Unicode, or gives up and returns `undefined`.
|
||||
*
|
||||
* The fallback matters: `x^{n+1}` fits in Unicode and reads better for it, while
|
||||
* `x^{abc}` has no Unicode form at all and needs the tag. Backslashes are
|
||||
* stripped before mapping so that an escaped `=` still finds its `⁼`, which is
|
||||
* safe because no Unicode script character is markdown punctuation.
|
||||
*/
|
||||
function mapScript(rendered: string, table: Record<string, string>): string | undefined {
|
||||
const plain = rendered.replace(/\\(.)/g, "$1");
|
||||
let mapped = "";
|
||||
|
||||
for (const char of plain) {
|
||||
const script = table[char];
|
||||
if (!script) return undefined;
|
||||
mapped += script;
|
||||
}
|
||||
|
||||
return mapped.length > 0 ? mapped : undefined;
|
||||
}
|
||||
|
||||
function superscript(rendered: string): string {
|
||||
return mapScript(rendered, SUPERSCRIPTS) ?? `<sup>${rendered}</sup>`;
|
||||
}
|
||||
|
||||
function subscript(rendered: string): string {
|
||||
return mapScript(rendered, SUBSCRIPTS) ?? `<sub>${rendered}</sub>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Characters that split a fraction when a formula is read left to right.
|
||||
*/
|
||||
const OPERAND_SPLIT = /[+−±∓]/;
|
||||
|
||||
/** Prefixes that apply to what follows them, so they are not an operand of their own. */
|
||||
const PREFIX_OPERATORS = new Set(["√", "∛", "∜"]);
|
||||
|
||||
/**
|
||||
* Whether a rendered operand needs brackets beside the fraction slash.
|
||||
*
|
||||
* The rendered text is what a reader sees, so that is what this looks at. Two
|
||||
* cases need brackets and nothing else does: a sum, because the slash would
|
||||
* otherwise divide only its last term, and a denominator that is more than one
|
||||
* thing, because `1/2a` is read as `1/(2a)` by some readers and `(1/2)a` by
|
||||
* others.
|
||||
*
|
||||
* Asking whether an operand is a single token instead, which is what this used
|
||||
* to do, brackets almost everything: `n(n+1)/2` came out as `(n(n+1))/2` and the
|
||||
* quadratic formula as a wall of parentheses.
|
||||
*/
|
||||
function needsFractionBrackets(rendered: string, side: "numerator" | "denominator"): boolean {
|
||||
// A script is skipped rather than read, because `x^{a+b}/y` is already
|
||||
// unambiguous: the sum is above the line by construction.
|
||||
const plain = rendered
|
||||
.replace(/<(?:sup|sub)>[\s\S]*?<\/(?:sup|sub)>/g, "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.trim();
|
||||
|
||||
let depth = 0;
|
||||
let operands = 0;
|
||||
|
||||
for (let index = 0; index < plain.length; index += 1) {
|
||||
const char = plain[index];
|
||||
|
||||
if (char === "(" || char === "[" || char === "{") {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === ")" || char === "]" || char === "}") {
|
||||
depth = Math.max(0, depth - 1);
|
||||
continue;
|
||||
}
|
||||
if (depth > 0) continue;
|
||||
|
||||
// A space at the top level is function application or a product, and either
|
||||
// one is clearer bracketed: `(sin x) / x` rather than `sin x/x`.
|
||||
if (char === " ") return true;
|
||||
|
||||
if (OPERAND_SPLIT.test(char)) {
|
||||
// A leading sign belongs to the term rather than splitting the fraction.
|
||||
if (index !== 0) return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// `√π` is one thing; `2π` is two multiplied together.
|
||||
if (PREFIX_OPERATORS.has(char)) continue;
|
||||
|
||||
operands += 1;
|
||||
}
|
||||
|
||||
return side === "denominator" && operands > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* `\frac{a}{b}` as a slash, with brackets only where they change the meaning.
|
||||
*
|
||||
* A stacked fraction is the thing this cannot do, and `1/2` is what a reader
|
||||
* reconstructs from it anyway. The space around the slash appears only when
|
||||
* something beside it is bracketed, where the brackets would otherwise crowd it.
|
||||
*/
|
||||
function renderFraction(numerator: string, denominator: string, display: boolean): string {
|
||||
const top = convert(numerator, display);
|
||||
const bottom = convert(denominator, display);
|
||||
|
||||
const bracketedTop = needsFractionBrackets(top, "numerator");
|
||||
const bracketedBottom = needsFractionBrackets(bottom, "denominator");
|
||||
const slash = bracketedTop || bracketedBottom ? " / " : "/";
|
||||
|
||||
return `${bracketedTop ? `(${top})` : top}${slash}${bracketedBottom ? `(${bottom})` : bottom}`;
|
||||
}
|
||||
|
||||
/** `\sqrt{x}` and `\sqrt[3]{x}`, where only a cube and a fourth root have a glyph. */
|
||||
function renderRoot(degree: string | undefined, body: string, display: boolean): string {
|
||||
const radicand = convert(body, display);
|
||||
const wrapped = isAtomic(body) ? radicand : `(${radicand})`;
|
||||
|
||||
if (degree === undefined || degree.trim().length === 0) return `√${wrapped}`;
|
||||
|
||||
const value = degree.trim();
|
||||
if (value === "3") return `∛${wrapped}`;
|
||||
if (value === "4") return `∜${wrapped}`;
|
||||
|
||||
return `${superscript(convert(value, display))}√${wrapped}`;
|
||||
}
|
||||
|
||||
function renderAccent(mark: string, body: string, display: boolean): string {
|
||||
const rendered = convert(body, display);
|
||||
// A combining mark needs a base character in front of it. An empty argument
|
||||
// would put the mark on whatever came before, which is why it is dropped.
|
||||
return rendered.length > 0 ? `${rendered}${mark}` : "";
|
||||
}
|
||||
|
||||
function readDelimiter(source: string, start: number): Slice {
|
||||
let index = start;
|
||||
while (index < source.length && /\s/.test(source[index])) index += 1;
|
||||
if (index >= source.length) return { body: "", end: index };
|
||||
|
||||
if (source[index] === "\\") {
|
||||
const command = readCommand(source, index);
|
||||
const spelled = `\\${command.name}`;
|
||||
return { body: DELIMITERS[spelled] ?? command.name, end: command.end };
|
||||
}
|
||||
|
||||
return { body: DELIMITERS[source[index]] ?? source[index], end: index + 1 };
|
||||
}
|
||||
|
||||
/** The two-character operator spellings, checked before a single character is emitted. */
|
||||
function readOperatorSequence(source: string, index: number): string | undefined {
|
||||
return OPERATOR_SEQUENCES[source.slice(index, index + 2)];
|
||||
}
|
||||
|
||||
function renderCommand(source: string, command: Command, display: boolean): Slice {
|
||||
const { name, end } = command;
|
||||
|
||||
if (name in ACCENTS) {
|
||||
const argument = readArgument(source, end);
|
||||
return { body: renderAccent(ACCENTS[name], argument.body, display), end: argument.end };
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "frac":
|
||||
case "dfrac":
|
||||
case "tfrac":
|
||||
case "cfrac": {
|
||||
const numerator = readArgument(source, end);
|
||||
const denominator = readArgument(source, numerator.end);
|
||||
return { body: renderFraction(numerator.body, denominator.body, display), end: denominator.end };
|
||||
}
|
||||
|
||||
case "sqrt": {
|
||||
const optional = readOptional(source, end);
|
||||
const argument = readArgument(source, optional ? optional.end : end);
|
||||
return { body: renderRoot(optional?.body, argument.body, display), end: argument.end };
|
||||
}
|
||||
|
||||
case "text":
|
||||
case "textrm":
|
||||
case "textnormal":
|
||||
case "textup":
|
||||
case "mbox":
|
||||
case "hbox":
|
||||
case "textsuperscript":
|
||||
case "textsubscript": {
|
||||
// Prose inside maths is prose, so the commands in it are not TeX to
|
||||
// convert: `\text{a_b}` means the literal characters.
|
||||
const argument = readArgument(source, end);
|
||||
const prose = argument.body.replace(/\\([a-zA-Z]+)\s*/g, "$1");
|
||||
return { body: literal(prose), end: argument.end };
|
||||
}
|
||||
|
||||
case "mathbf":
|
||||
case "boldsymbol":
|
||||
case "bm":
|
||||
case "bf":
|
||||
case "pmb": {
|
||||
const argument = readArgument(source, end);
|
||||
return { body: `<b>${convert(argument.body, display)}</b>`, end: argument.end };
|
||||
}
|
||||
|
||||
case "mathrm":
|
||||
case "mathsf":
|
||||
case "mathtt":
|
||||
case "mathord":
|
||||
case "mathop":
|
||||
case "mathbin":
|
||||
case "mathrel":
|
||||
case "mathpunct":
|
||||
case "operatorname": {
|
||||
// Upright by definition, and the panel has no italic to switch off, so
|
||||
// the argument is converted for its symbols and left as it reads.
|
||||
const argument = readArgument(source, end);
|
||||
return { body: convert(argument.body, display), end: argument.end };
|
||||
}
|
||||
|
||||
case "mathit":
|
||||
case "textit":
|
||||
case "it":
|
||||
case "emph": {
|
||||
const argument = readArgument(source, end);
|
||||
return { body: `<i>${convert(argument.body, display)}</i>`, end: argument.end };
|
||||
}
|
||||
|
||||
case "mathbb": {
|
||||
const argument = readArgument(source, end);
|
||||
const trimmed = argument.body.trim();
|
||||
const glyph = DOUBLE_STRUCK[trimmed];
|
||||
return { body: glyph ? literal(glyph) : convert(argument.body, display), end: argument.end };
|
||||
}
|
||||
|
||||
case "mathcal":
|
||||
case "mathscr":
|
||||
case "mathfrak": {
|
||||
const argument = readArgument(source, end);
|
||||
const trimmed = argument.body.trim();
|
||||
const glyph = SCRIPT[trimmed];
|
||||
return { body: glyph ? literal(glyph) : convert(argument.body, display), end: argument.end };
|
||||
}
|
||||
|
||||
case "left":
|
||||
case "right":
|
||||
case "middle": {
|
||||
const delimiter = readDelimiter(source, end);
|
||||
return { body: literal(delimiter.body), end: delimiter.end };
|
||||
}
|
||||
|
||||
case "\\":
|
||||
// A line break in a display formula, and a space when the same source is
|
||||
// read inline, where breaking the line would split a sentence.
|
||||
return { body: display ? "<br/>" : " ", end };
|
||||
|
||||
case "begin":
|
||||
case "end":
|
||||
case "displaystyle":
|
||||
case "textstyle":
|
||||
case "scriptstyle":
|
||||
case "scriptscriptstyle":
|
||||
case "limits":
|
||||
case "nolimits":
|
||||
case "nonumber":
|
||||
case "notag":
|
||||
case "quad":
|
||||
case "qquad":
|
||||
case "enspace":
|
||||
case "thinspace":
|
||||
case "negthinspace":
|
||||
case "allowbreak":
|
||||
return { body: "", end: name === "begin" || name === "end" ? readArgument(source, end).end : end };
|
||||
|
||||
case "hspace":
|
||||
case "mspace":
|
||||
case "kern":
|
||||
case "phantom":
|
||||
case "hphantom":
|
||||
case "vphantom":
|
||||
return { body: " ", end: readArgument(source, end).end };
|
||||
|
||||
case "overset":
|
||||
case "underset":
|
||||
case "stackrel": {
|
||||
// Two arguments and no way to stack them here, so the annotation is
|
||||
// printed after the thing it annotates rather than over it.
|
||||
const annotation = readArgument(source, end);
|
||||
const base = readArgument(source, annotation.end);
|
||||
return {
|
||||
body: `${convert(base.body, display)}${superscript(convert(annotation.body, display))}`,
|
||||
end: base.end,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (name in SYMBOLS) return { body: literal(SYMBOLS[name]), end };
|
||||
|
||||
// An unknown command is a command this codebase does not know, not a reason to
|
||||
// lose the formula: the name is printed so the reader still sees what was
|
||||
// written. `\foo{x}` prints as `foo`, and the argument follows on its own.
|
||||
return { body: literal(name), end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts one run of TeX. Recursive, because an argument is TeX too.
|
||||
*
|
||||
* `display` only changes how `\\` is read: in a block it is a line break, and
|
||||
* inline it would split a sentence in half.
|
||||
*/
|
||||
function convert(source: string, display: boolean): string {
|
||||
let out = "";
|
||||
let index = 0;
|
||||
|
||||
while (index < source.length) {
|
||||
const char = source[index];
|
||||
|
||||
if (char === "\\") {
|
||||
const command = readCommand(source, index);
|
||||
const rendered = renderCommand(source, command, display);
|
||||
out += rendered.body;
|
||||
index = rendered.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "{") {
|
||||
const group = readGroup(source, index);
|
||||
out += convert(group.body, display);
|
||||
index = group.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "}") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "^" || char === "_") {
|
||||
const argument = readArgument(source, index + 1);
|
||||
const rendered = convert(argument.body, display);
|
||||
out += char === "^" ? superscript(rendered) : subscript(rendered);
|
||||
index = argument.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
// `&` is a column separator in an alignment, and dropping it is right for
|
||||
// the `x &= 1` spelling because the spaces around it are already there.
|
||||
if (char === "&") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const sequence = readOperatorSequence(source, index);
|
||||
if (sequence) {
|
||||
out += literal(sequence);
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
out += literal(char === "-" ? "−" : char);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface LatexOptions {
|
||||
/** Display maths stands on its own line, so `\\` becomes a line break. */
|
||||
display?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a LaTeX fragment as inline markup the detail panel can draw.
|
||||
*
|
||||
* The result is markdown, not plain text: it contains HTML tags for anything
|
||||
* Unicode cannot express, and backslash escapes around everything the formula
|
||||
* must not let the markdown parser read. Callers insert it as-is.
|
||||
*/
|
||||
export function renderLatex(source: string, options: LatexOptions = {}): string {
|
||||
const rendered = convert(source, options.display ?? false).trim();
|
||||
return options.display ? guardBlockStart(rendered) : rendered;
|
||||
}
|
||||
+69
-13
@@ -1,30 +1,76 @@
|
||||
/**
|
||||
* 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.
|
||||
* `get_location` is timezone-derived rather than a GPS or IP lookup, so the
|
||||
* user's IP is never shipped to a third-party geolocation service on every tool
|
||||
* call. It is also pure, so it is cheap to test.
|
||||
*
|
||||
* The correction that matters here is that a timezone name is **not** a city.
|
||||
* The IANA database names a zone after one representative place, and a single
|
||||
* zone can cover a whole country: "Asia/Manila" is the Philippines,
|
||||
* "Asia/Kolkata" is India, "Europe/Oslo" is Norway, "Australia/Brisbane" is
|
||||
* Queensland. Reporting "Manila" as the user's city is simply wrong for anyone
|
||||
* in Cebu, and being confidently wrong is worse than being vague, because a
|
||||
* model asked about the weather will use it without hesitating. So the zone's
|
||||
* label is still reported, but named for what it is instead of being dressed up
|
||||
* as the user's city.
|
||||
*
|
||||
* The only exact answer available without asking a third party is the Location
|
||||
* preference, which the user fills in themselves. It is reported verbatim and
|
||||
* preferred over anything inferred.
|
||||
*/
|
||||
|
||||
export interface LocationContext {
|
||||
timezone: string;
|
||||
utcOffset: string;
|
||||
locale: string;
|
||||
/** Country code from the locale, e.g. "PH". The user's own setting rather than an inference. */
|
||||
regionCode?: string;
|
||||
/** Readable name for that region, e.g. "Philippines". */
|
||||
region?: string;
|
||||
city?: string;
|
||||
/**
|
||||
* The timezone's own area label, e.g. "Manila".
|
||||
*
|
||||
* Deliberately not called a city. See the note at the top of this file.
|
||||
*/
|
||||
timezoneArea?: string;
|
||||
/** The user's configured Location preference, verbatim. The only exact value here. */
|
||||
place?: string;
|
||||
}
|
||||
|
||||
/** Splits an IANA zone like "Asia/Manila" or "America/Argentina/Salta" into readable parts. */
|
||||
export function describeTimezone(timeZone: string): { region?: string; city?: string } {
|
||||
/**
|
||||
* Splits an IANA zone like "Asia/Manila" or "America/Argentina/Salta".
|
||||
*
|
||||
* Only the last segment is kept, and the continent grouping is dropped: "Asia"
|
||||
* next to a locale region of "Philippines" reads as two answers to the same
|
||||
* question, and the zone's grouping answers a question nobody asked.
|
||||
*/
|
||||
export function describeTimezoneArea(timeZone: string): { area?: 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 { area: parts[parts.length - 1].replace(/_/g, " ") || undefined };
|
||||
}
|
||||
|
||||
return { region, city: city || undefined };
|
||||
/**
|
||||
* The region from the locale subtag.
|
||||
*
|
||||
* This is a declaration rather than an inference: a locale of "en-PH" says the
|
||||
* user set their region to the Philippines. That makes it a better answer than
|
||||
* anything read out of a zone name, and it costs nothing.
|
||||
*/
|
||||
export function localeRegion(locale: string): { code: string; name: string } | undefined {
|
||||
try {
|
||||
const code = new Intl.Locale(locale).region;
|
||||
if (!code) return undefined;
|
||||
|
||||
const name = new Intl.DisplayNames(["en"], { type: "region" }).of(code);
|
||||
return { code, name: name ?? code };
|
||||
} catch {
|
||||
// A malformed locale, or an ICU build without region display names. Not
|
||||
// worth failing the whole answer over.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** "GMT+08:00" on a modern V8; falls back to a short name if `longOffset` is unavailable. */
|
||||
@@ -56,7 +102,11 @@ function resolveTimeZone(timeZone?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocationContext(date: Date = new Date(), timeZone?: string): LocationContext {
|
||||
export function getLocationContext(
|
||||
date: Date = new Date(),
|
||||
timeZone?: string,
|
||||
place?: string,
|
||||
): LocationContext {
|
||||
const zone = resolveTimeZone(timeZone);
|
||||
|
||||
let locale = "en-US";
|
||||
@@ -66,11 +116,17 @@ export function getLocationContext(date: Date = new Date(), timeZone?: string):
|
||||
// Keep the fallback locale.
|
||||
}
|
||||
|
||||
const region = localeRegion(locale);
|
||||
const configured = place?.trim();
|
||||
|
||||
return {
|
||||
timezone: zone,
|
||||
utcOffset: utcOffsetLabel(date, zone),
|
||||
locale,
|
||||
...describeTimezone(zone),
|
||||
regionCode: region?.code,
|
||||
region: region?.name,
|
||||
timezoneArea: describeTimezoneArea(zone).area,
|
||||
place: configured && configured.length > 0 ? configured : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+162
-24
@@ -1,6 +1,8 @@
|
||||
import { imageSource } from "./attachments";
|
||||
import { repairCodeBlocks } from "./code";
|
||||
import type { Attachment, ChatTurn, ThreadMessage } from "./types";
|
||||
import { citedIndices, collectSources, renderSources } from "./citations";
|
||||
import { renderInlineContent, renderModelOutput, type RenderedMarkdown } from "./render";
|
||||
import { describeActivity } from "./thread";
|
||||
import type { Attachment, ChatTurn, ThreadMessage, ToolCallRecord } from "./types";
|
||||
|
||||
/**
|
||||
* Markdown rendering for the detail panel.
|
||||
@@ -57,47 +59,174 @@ export function quote(text: string): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** One italic line, well below the weight of the answer it belongs to. */
|
||||
function renderTools(message: ThreadMessage): string {
|
||||
const names = message.toolCalls
|
||||
.map((call) => `\`${call.name}\`${call.isError ? " (failed)" : ""}`)
|
||||
.join(", ");
|
||||
/** How many calls failed for a reason other than being refused as a repeat. */
|
||||
function countFailures(calls: ToolCallRecord[]): number {
|
||||
// A refused repeat is an `isError` too, but it did not fail: it is the loop
|
||||
// guard noticing a rut, which is a different thing and is said differently.
|
||||
return calls.filter((call) => call.isError && !call.repeated).length;
|
||||
}
|
||||
|
||||
return `_Tools: ${names}_`;
|
||||
/** Whether the turn was cut short because a call kept repeating itself. */
|
||||
function wasStopped(calls: ToolCallRecord[]): boolean {
|
||||
return calls.some((call) => call.repeated);
|
||||
}
|
||||
|
||||
/**
|
||||
* One italic line naming the tools a turn used, each mentioned once.
|
||||
*
|
||||
* Listing every call in order said nothing a name does not, and on a research
|
||||
* turn it said it twenty times: `_Tools: web_search, web_search, web_search,
|
||||
* web_fetch (failed), web_search…_` ran to three lines of the same two words.
|
||||
* Names in order of first use, then a count of what went wrong, which is the
|
||||
* only part of that repetition that carries information.
|
||||
*/
|
||||
function renderTools(message: ThreadMessage): string {
|
||||
const names: string[] = [];
|
||||
for (const call of message.toolCalls) {
|
||||
if (!names.includes(call.name)) names.push(call.name);
|
||||
}
|
||||
|
||||
const listed = names.map((name) => `\`${name}\``).join(", ");
|
||||
const failed = countFailures(message.toolCalls);
|
||||
|
||||
const notes: string[] = [];
|
||||
if (failed > 0) notes.push(`${failed} failed`);
|
||||
if (wasStopped(message.toolCalls)) notes.push("stopped: the same call kept repeating");
|
||||
|
||||
const suffix = notes.length > 0 ? ` (${notes.join(", ")})` : "";
|
||||
return `_Tools: ${listed}${suffix}_`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one line shown when tool calls are hidden but something went wrong.
|
||||
*
|
||||
* Hiding the list is the user's choice, and a list of names is what they turned
|
||||
* off. A turn where a call failed or was refused as a repeat is different: the
|
||||
* answer alone would not account for it, so it is said in a sentence rather than
|
||||
* dropped, and the names stay hidden.
|
||||
*/
|
||||
function renderToolFailureNote(message: ThreadMessage): string | undefined {
|
||||
const failed = countFailures(message.toolCalls);
|
||||
const stopped = wasStopped(message.toolCalls);
|
||||
if (failed === 0 && !stopped) return undefined;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (failed > 0) parts.push(`${failed} tool call${failed === 1 ? "" : "s"} failed`);
|
||||
if (stopped) parts.push("a call kept repeating, so the turn was cut short");
|
||||
|
||||
return `_${parts.join(" and ")}._`;
|
||||
}
|
||||
|
||||
function renderMessage(message: ThreadMessage, options: TranscriptOptions): string {
|
||||
// The question is identified by its styling alone.
|
||||
// The question is identified by its styling alone. It goes through the same
|
||||
// rendering pass as an answer, because a person pastes LaTeX into a question
|
||||
// as readily as a model writes it, and a question full of raw HTML should be
|
||||
// put behind the same fence.
|
||||
if (message.role === "user") {
|
||||
const images = renderAttachments(message.attachments);
|
||||
const asked = quote(message.text);
|
||||
const asked = quote(renderInlineContent(message.text));
|
||||
|
||||
return images ? `${asked}\n\n${images}` : asked;
|
||||
}
|
||||
|
||||
const thinking = message.thinking.trim();
|
||||
/**
|
||||
* Live reasoning is shown while a turn is in flight whether or not the
|
||||
* preference is on: it is the only sign of progress when a model is thinking
|
||||
* rather than writing, and waiting on a spinner is worse than reading the
|
||||
* thought. Once the turn ends it stays only if the user asked for it, so
|
||||
* `Show Reasoning` still governs the finished transcript.
|
||||
*/
|
||||
const liveThinking = message.isStreaming && thinking.length > 0;
|
||||
/** The stored trace, kept as an appendix under the answer it produced. */
|
||||
const storedThinking = !message.isStreaming && options.showReasoning && thinking.length > 0;
|
||||
const showThinking = liveThinking || storedThinking;
|
||||
|
||||
// While a tool is running there is no answer text yet, so the activity line
|
||||
// stands in and the user can see what Lam is actually doing.
|
||||
const placeholder = message.status ? `_${message.status}…_` : "_Thinking…_";
|
||||
const body = message.text.trim()
|
||||
? // Fences without a language render as a plain dark panel, and a lone
|
||||
// inline command renders as a chip with no Copy button at all. Both are
|
||||
// repaired before the markdown reaches the renderer.
|
||||
repairCodeBlocks(message.text.trim())
|
||||
: message.isStreaming
|
||||
? placeholder
|
||||
: "_No response._";
|
||||
const sections = [message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
|
||||
// stands in and the user can see what Lam is actually doing. It is shown
|
||||
// whether or not tokens have arrived, because a tool that starts after the
|
||||
// answer has begun would otherwise be invisible for the rest of the turn.
|
||||
const activityLine =
|
||||
message.isStreaming && message.activity ? `_${describeActivity(message.activity)}…_` : undefined;
|
||||
|
||||
/** The answer, or whatever stands in for it while there is none. */
|
||||
const answered = message.text.trim();
|
||||
/**
|
||||
* The turn's sources, read back off its records rather than out of a tool result
|
||||
* string, so a reopened conversation draws the same list with the same numbers.
|
||||
*/
|
||||
const sources = collectSources(message.toolCalls);
|
||||
const drawn: RenderedMarkdown = answered
|
||||
? // One pass, in one place: fences get their languages and links recovered, then
|
||||
// TeX becomes something drawable, a citation marker becomes a link to the page
|
||||
// it names, and the HTML around all of it is filtered to the subset the
|
||||
// renderer acts on. See `render.ts` for the order and why.
|
||||
renderModelOutput(answered, { sources })
|
||||
: { text: "", cited: new Set<number>() };
|
||||
const body = answered
|
||||
? drawn.text
|
||||
: !message.isStreaming
|
||||
? "_No response._"
|
||||
: activityLine
|
||||
? // The activity line above is already saying what is being waited on, so
|
||||
// printing the placeholder as well put the same sentence on screen
|
||||
// twice: once as a note above the transcript and once as the stand-in.
|
||||
undefined
|
||||
: liveThinking
|
||||
? // The reasoning below is already saying the model is working, so
|
||||
// "Thinking…" on top of it would only repeat it.
|
||||
undefined
|
||||
: message.status
|
||||
? `_${message.status}…_`
|
||||
: "_Thinking…_";
|
||||
|
||||
const sections: string[] = [];
|
||||
const activityIndex = activityLine ? sections.push(activityLine) - 1 : -1;
|
||||
const bodyIndex = body ? sections.push(body) - 1 : -1;
|
||||
|
||||
// The one thing allowed to break the plain-text rule, because it means
|
||||
// something went wrong and should not be skimmed past.
|
||||
if (message.error) sections.push("", `**Error:** ${message.error}`);
|
||||
|
||||
/**
|
||||
* Sources belong to the answer rather than to the tool list, so they are drawn
|
||||
* whether or not the user asked to see tool calls. Hiding the activity line is
|
||||
* about chrome; a citation list is the answer's own provenance.
|
||||
*/
|
||||
const list = renderSources(sources, drawn.cited, message.isStreaming);
|
||||
if (list) sections.push("", list);
|
||||
|
||||
/**
|
||||
* Tool calls are a display preference. When they are on, every tool is named
|
||||
* once. When they are off, nothing is listed; a failure or a refused repeat
|
||||
* still gets one quiet sentence, because those are the cases where hiding the
|
||||
* line would leave the user with an answer they cannot account for.
|
||||
*/
|
||||
if (options.showToolCalls && message.toolCalls.length > 0) {
|
||||
sections.push("", renderTools(message));
|
||||
} else if (!options.showToolCalls) {
|
||||
const note = renderToolFailureNote(message);
|
||||
if (note) sections.push("", note);
|
||||
}
|
||||
|
||||
if (options.showReasoning && message.thinking.trim().length > 0) {
|
||||
sections.push("", "_Reasoning_", "", quote(message.thinking.trim()));
|
||||
if (showThinking) {
|
||||
// Only live reasoning can be the first thing in a message, so the blank
|
||||
// separator is conditional: otherwise the transcript opens on an empty line.
|
||||
if (sections.length > 0) sections.push("");
|
||||
sections.push("_Reasoning_", "", quote(thinking));
|
||||
}
|
||||
|
||||
/**
|
||||
* The cursor marks what is arriving. The answer wins when there is one, then
|
||||
* the live reasoning, and only then the activity line: reasoning on screen
|
||||
* means the model is thinking, which is newer than the tool it called to get
|
||||
* there. It is placed before the quiet sections so it never lands on a tool
|
||||
* list or an appendix.
|
||||
*/
|
||||
if (message.isStreaming) {
|
||||
const reasoningIndex = showThinking && liveThinking ? sections.length - 1 : -1;
|
||||
const target = bodyIndex >= 0 ? bodyIndex : reasoningIndex >= 0 ? reasoningIndex : activityIndex;
|
||||
if (target >= 0) sections[target] = `${sections[target]} ${STREAMING_CURSOR}`;
|
||||
}
|
||||
|
||||
return sections.join("\n");
|
||||
@@ -165,7 +294,16 @@ export function sessionToPlainText(turns: ChatTurn[]): string {
|
||||
const attached = turn.attachments.length > 0 ? "\n\n_(screenshot attached)_" : "";
|
||||
const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_");
|
||||
const failure = turn.error ? `\n\n**[error]** ${turn.error}` : "";
|
||||
return `${question}${attached}\n\n**Lam**\n\n${answer}${failure}`;
|
||||
/**
|
||||
* The same list the transcript draws, worked out from a plain scan rather than
|
||||
* from the panel's own set. A copied conversation is markdown on its way
|
||||
* somewhere else, where a `[1]` inside a snippet counting as a citation costs a
|
||||
* reader nothing.
|
||||
*/
|
||||
const sources = collectSources(turn.toolCalls);
|
||||
const list = renderSources(sources, citedIndices(turn.response, sources), false);
|
||||
const cited = list ? `\n\n${list}` : "";
|
||||
return `${question}${attached}\n\n**Lam**\n\n${answer}${failure}${cited}`;
|
||||
})
|
||||
.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
+328
-18
@@ -1,4 +1,5 @@
|
||||
import { DEFAULT_HOST } from "./constants";
|
||||
import { CLOUD_HOSTNAMES, DEFAULT_HOST } from "./constants";
|
||||
import { formatBytes } from "./utils";
|
||||
import type { ApiMessage, ModelInfo, ToolCall, ToolDefinition } from "./types";
|
||||
|
||||
/**
|
||||
@@ -33,6 +34,23 @@ function authHeaders(apiKey: string): Record<string, string> {
|
||||
return key ? { Authorization: `Bearer ${key}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the host is a server whose models can be pulled and deleted.
|
||||
*
|
||||
* Decided from the hostname rather than the scheme or the port: a local server
|
||||
* reached over https is still local, and Ollama Cloud is the one host that
|
||||
* cannot be managed this way. A host that will not parse is treated as local,
|
||||
* because every request against it fails on its own terms anyway, and the caller
|
||||
* hides nothing it might have needed.
|
||||
*/
|
||||
export function isSelfHosted(host: string | undefined): boolean {
|
||||
try {
|
||||
return !CLOUD_HOSTNAMES.has(new URL(normalizeHost(host)).hostname);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function safeText(response: Response): Promise<string> {
|
||||
try {
|
||||
return await response.text();
|
||||
@@ -54,7 +72,7 @@ function describeHttpError(status: number, bodyText: string, host: string): Olla
|
||||
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.",
|
||||
hint: "The model name may be wrong, or this host may not expose that endpoint.",
|
||||
});
|
||||
case 429:
|
||||
return new OllamaError("Ollama is rate limiting this key.", {
|
||||
@@ -193,7 +211,14 @@ export interface ListModelsOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Lists the models this API key can reach. On Ollama Cloud this reflects the account's entitlements. */
|
||||
/**
|
||||
* Lists the models this host can reach.
|
||||
*
|
||||
* On Ollama Cloud this reflects the account's entitlements. On a local server it
|
||||
* reflects what is installed, which mixes real weights with `:cloud` entries the
|
||||
* daemon proxies to ollama.com, so the two are told apart by `remoteHost` rather
|
||||
* than by anything in the name.
|
||||
*/
|
||||
export async function listModels(options: ListModelsOptions): Promise<ModelInfo[]> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
@@ -207,18 +232,255 @@ export async function listModels(options: ListModelsOptions): Promise<ModelInfo[
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
models?: Array<{ name?: string; model?: string; details?: { parameter_size?: string } }>;
|
||||
models?: Array<{
|
||||
name?: string;
|
||||
model?: string;
|
||||
size?: number;
|
||||
remote_host?: string;
|
||||
capabilities?: string[];
|
||||
details?: { parameter_size?: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
return (payload.models ?? [])
|
||||
.map((model) => ({
|
||||
name: model.name ?? model.model ?? "",
|
||||
parameterSize: model.details?.parameter_size,
|
||||
size: typeof model.size === "number" ? model.size : undefined,
|
||||
// Every one of these is optional because older servers simply do not send
|
||||
// it, and a field that is missing must not be guessed at.
|
||||
capabilities: Array.isArray(model.capabilities) ? model.capabilities : undefined,
|
||||
remoteHost: typeof model.remote_host === "string" && model.remote_host ? model.remote_host : undefined,
|
||||
}))
|
||||
.filter((model) => model.name.length > 0)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export interface DeleteModelOptions {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* A model the server has in memory right now, as reported by `GET /api/ps`.
|
||||
*
|
||||
* Distinct from {@link ModelInfo}, which is what is on disk. A model can be
|
||||
* installed without being loaded, and loaded models are the ones holding VRAM.
|
||||
*/
|
||||
export interface RunningModel {
|
||||
name: string;
|
||||
parameterSize?: string;
|
||||
/**
|
||||
* Bytes held in memory. Equal to the on-disk size when the model is fully
|
||||
* offloaded to the GPU, and less when part of it is running on the CPU.
|
||||
*/
|
||||
sizeVram?: number;
|
||||
/** When the server will drop it on its own, as a Unix timestamp. */
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export interface ListRunningModelsOptions {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* The models holding memory on this server.
|
||||
*
|
||||
* Resolves to an empty list rather than throwing. Nothing here is worth failing
|
||||
* over: a server that does not serve `/api/ps` (older builds answer 404) simply
|
||||
* has nothing known to be loaded, and taking the whole model list down because
|
||||
* the *running* list is unavailable would be a bad trade.
|
||||
*/
|
||||
export async function listRunningModels(options: ListRunningModelsOptions): Promise<RunningModel[]> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}/api/ps`, {
|
||||
headers: authHeaders(options.apiKey),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) return [];
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
models?: Array<{
|
||||
name?: string;
|
||||
model?: string;
|
||||
size_vram?: number;
|
||||
expires_at?: string;
|
||||
details?: { parameter_size?: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
return (payload.models ?? [])
|
||||
.map((model) => ({
|
||||
name: model.name ?? model.model ?? "",
|
||||
parameterSize: model.details?.parameter_size,
|
||||
sizeVram: typeof model.size_vram === "number" ? model.size_vram : undefined,
|
||||
expiresAt: model.expires_at ? Date.parse(model.expires_at) : undefined,
|
||||
}))
|
||||
.filter((model) => model.name.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface UnloadModelOptions {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a model out of memory, freeing the VRAM it was holding.
|
||||
*
|
||||
* `POST /api/generate` with `keep_alive: 0` and no prompt is how Ollama says
|
||||
* "release this now"; its own `ollama stop` does the same thing. Probed against
|
||||
* 0.35.1: the reply is `{"done":true,"done_reason":"unload"}` and `/api/ps`
|
||||
* comes back empty. The `DELETE` form of the same route answers 405, so there is
|
||||
* no delete-shaped way to do this.
|
||||
*
|
||||
* Nothing is deleted, so a mistake costs a reload. That is why this needs no
|
||||
* confirmation while `deleteModel` does.
|
||||
*/
|
||||
export async function unloadModel(options: UnloadModelOptions): Promise<void> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
const response = await fetch(`${host}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
|
||||
// No `prompt`, so this never generates anything, and `stream: false` because
|
||||
// there is exactly one reply to read.
|
||||
body: JSON.stringify({ model: options.model, keep_alive: 0, stream: false }),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw describeHttpError(response.status, await safeText(response), host);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a model from a server the user runs.
|
||||
*
|
||||
* `DELETE`, not `POST`: Ollama 0.35.1 answers the POST form of this route with
|
||||
* `405 method not allowed`, which is the kind of change that is invisible until a
|
||||
* user tries it.
|
||||
*
|
||||
* There is no undo and no trash, so the caller confirms first. For the same
|
||||
* reason a 404 is not swallowed into a success: "no model called that" is worth
|
||||
* saying out loud, because it usually means something else already removed it, or
|
||||
* that the host is not what the user thinks it is.
|
||||
*/
|
||||
export async function deleteModel(options: DeleteModelOptions): Promise<void> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
const response = await fetch(`${host}/api/delete`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
|
||||
body: JSON.stringify({ model: options.model }),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new OllamaError(`${host} has no model called ${options.model}.`, {
|
||||
status: 404,
|
||||
hint: "It may have been removed already, or by another machine sharing this server.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw describeHttpError(response.status, await safeText(response), host);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of pull progress, exactly as Ollama reports it.
|
||||
*
|
||||
* `total` and `completed` are only present once a download starts, so the caller
|
||||
* has to render a status with no numbers in it for the first few ticks.
|
||||
*/
|
||||
export interface PullProgress {
|
||||
status: string;
|
||||
total?: number;
|
||||
completed?: number;
|
||||
}
|
||||
|
||||
export interface PullModelOptions {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls a model onto a server the user runs, yielding progress as it arrives.
|
||||
*
|
||||
* Streaming rather than a blocking request, because a download is minutes long
|
||||
* and a request that returns nothing until it is finished leaves the window with
|
||||
* no way to tell a slow pull from a hung one.
|
||||
*
|
||||
* A bad model name comes back as **HTTP 200 with an `error` field in the body**
|
||||
* (verified against 0.35.1), so the status line is not enough to decide whether
|
||||
* this worked: the stream has to be read and checked, exactly as the chat stream
|
||||
* already does.
|
||||
*/
|
||||
export async function* pullModel(options: PullModelOptions): AsyncGenerator<PullProgress> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
const response = await fetch(`${host}/api/pull`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
|
||||
body: JSON.stringify({ model: options.model, stream: true }),
|
||||
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<{
|
||||
status?: string;
|
||||
total?: number;
|
||||
completed?: number;
|
||||
error?: string;
|
||||
}>(response.body)) {
|
||||
if (event.error) throw new OllamaError(event.error);
|
||||
if (!event.status) continue;
|
||||
|
||||
yield { status: event.status, total: event.total, completed: event.completed };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line describing a pull, for the toast that stays up while it runs.
|
||||
*
|
||||
* Lives here rather than in the view because it is a reading of Ollama's own
|
||||
* payload rather than a layout decision, and because the arithmetic is the part
|
||||
* of a progress bar that goes wrong silently: a total of zero, or a `completed`
|
||||
* that arrives before the `total` it is measured against, would otherwise print
|
||||
* `NaN%` or `Infinity%` at the user.
|
||||
*/
|
||||
export function describePullProgress(progress: PullProgress): string {
|
||||
const { status, total, completed } = progress;
|
||||
|
||||
if (!total || total <= 0 || completed === undefined || !Number.isFinite(completed)) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const percent = Math.min(100, Math.max(0, Math.round((completed / total) * 100)));
|
||||
|
||||
return `${status} · ${percent}% · ${formatBytes(completed)} / ${formatBytes(total)}`;
|
||||
}
|
||||
|
||||
export interface ModelCapabilitiesOptions {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
@@ -257,6 +519,53 @@ export function supportsVision(capabilities: string[]): boolean {
|
||||
return capabilities.includes("vision");
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts to a web tool, at the path this kind of host actually serves it from.
|
||||
*
|
||||
* The two kinds of host expose the same two operations at different paths, and
|
||||
* there is no path that works for both. Measured, not looked up:
|
||||
*
|
||||
* - `https://ollama.com/api/web_search` answers 401 without a key, so the route
|
||||
* exists, and its `/api/experimental/` alias is a 404.
|
||||
* - A local 0.35.1 daemon answers 404 to that path, and serves
|
||||
* `/api/experimental/web_search` with real results.
|
||||
*
|
||||
* This was a live bug: pointing the extension at a local server made every web
|
||||
* tool call a 404, and the model, handed a failure and no explanation, told the
|
||||
* user its web tools simply were not configured.
|
||||
*
|
||||
* The other path is tried when the first answers 404, because that is a server
|
||||
* built the other way round rather than a request being refused. A 404 body is
|
||||
* drained before the retry so the connection is not left hanging.
|
||||
*/
|
||||
async function postWebTool(options: {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
kind: "search" | "fetch";
|
||||
body: Record<string, unknown>;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<Response> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
const [preferred, fallback] = isSelfHosted(host)
|
||||
? [`/api/experimental/web_${options.kind}`, `/api/web_${options.kind}`]
|
||||
: [`/api/web_${options.kind}`, `/api/experimental/web_${options.kind}`];
|
||||
|
||||
const post = (path: string) =>
|
||||
fetch(`${host}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
|
||||
body: JSON.stringify(options.body),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
const response = await post(preferred);
|
||||
if (response.status !== 404) return response;
|
||||
|
||||
await response.text().catch(() => "");
|
||||
return post(fallback);
|
||||
}
|
||||
|
||||
export interface WebSearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
@@ -272,20 +581,19 @@ export interface WebSearchOptions {
|
||||
}
|
||||
|
||||
export async function webSearch(options: WebSearchOptions): Promise<WebSearchResult[]> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
const body: Record<string, unknown> = { 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),
|
||||
const response = await postWebTool({
|
||||
host: options.host,
|
||||
apiKey: options.apiKey,
|
||||
kind: "search",
|
||||
body,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw describeHttpError(response.status, await safeText(response), host);
|
||||
throw describeHttpError(response.status, await safeText(response), normalizeHost(options.host));
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { results?: WebSearchResult[] };
|
||||
@@ -306,19 +614,21 @@ export interface WebFetchOptions {
|
||||
}
|
||||
|
||||
export async function webFetch(options: WebFetchOptions): Promise<WebFetchResult> {
|
||||
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 }),
|
||||
const response = await postWebTool({
|
||||
host: options.host,
|
||||
apiKey: options.apiKey,
|
||||
kind: "fetch",
|
||||
body: { url: options.url },
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw describeHttpError(response.status, await safeText(response), host);
|
||||
throw describeHttpError(response.status, await safeText(response), normalizeHost(options.host));
|
||||
}
|
||||
|
||||
// `links` comes back as null from a local daemon rather than as an empty list,
|
||||
// which `??` covers. (Probed directly: a local `web_fetch` answered
|
||||
// `{"title":...,"content":...,"links":null}`.)
|
||||
const payload = (await response.json()) as Partial<WebFetchResult>;
|
||||
return {
|
||||
title: payload.title ?? options.url,
|
||||
|
||||
+206
-7
@@ -1,5 +1,6 @@
|
||||
import { getPreferenceValues } from "@vicinae/api";
|
||||
import { DEFAULT_HOST, DEFAULT_MODEL } from "./constants";
|
||||
import { getPreferenceValues, LocalStorage } from "@vicinae/api";
|
||||
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL, STORAGE_MODEL_KEY } from "./constants";
|
||||
import { splitRoots } from "./files";
|
||||
|
||||
/**
|
||||
* Preferences, read and normalised.
|
||||
@@ -15,20 +16,72 @@ export interface LamPreferences {
|
||||
model?: string;
|
||||
ollamaHost?: string;
|
||||
systemPrompt?: string;
|
||||
location?: string;
|
||||
ipGeolocation?: boolean;
|
||||
webSearchEnabled?: boolean;
|
||||
thinking?: string;
|
||||
showToolCalls?: boolean;
|
||||
showReasoning?: boolean;
|
||||
resumeTimeout?: string;
|
||||
autoDeleteAfter?: string;
|
||||
maxToolRounds?: string;
|
||||
fileAccess?: boolean;
|
||||
readRoots?: string;
|
||||
writeRoots?: string;
|
||||
}
|
||||
|
||||
/** Where the model in use came from, so the UI can explain a divergence from the preference. */
|
||||
export type ModelSource = "preference" | "selection";
|
||||
|
||||
/** Preferences with every optional value filled in, so callers never branch on undefined. */
|
||||
export interface ResolvedPreferences {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
/**
|
||||
* Whether {@link ResolvedPreferences.model} came from the preference or from a
|
||||
* pick in the Manage Models command.
|
||||
*
|
||||
* Carried rather than inferred, because the two disagree silently otherwise:
|
||||
* the setup screen would show one model while the chat used another, and the
|
||||
* user would have no way to tell which one to change.
|
||||
*/
|
||||
modelSource: ModelSource;
|
||||
/**
|
||||
* What the `Model` preference says, whatever is in use.
|
||||
*
|
||||
* Kept alongside `model` so a screen can answer the question a selection
|
||||
* raises: what would happen if I reset it?
|
||||
*/
|
||||
preferenceModel: string;
|
||||
host: string;
|
||||
systemPrompt: string;
|
||||
/**
|
||||
* The place the user says they are, verbatim, or an empty string.
|
||||
*
|
||||
* The only exact location this extension has. Everything else is inferred from
|
||||
* the device timezone and locale, which is accurate to a region rather than a
|
||||
* city, and a timezone name is not a city at all.
|
||||
*/
|
||||
place: string;
|
||||
/** Whether to resolve a rough place from the IP address. Off unless asked for. */
|
||||
ipGeolocation: boolean;
|
||||
webEnabled: boolean;
|
||||
/**
|
||||
* Whether the file tools are offered at all.
|
||||
*
|
||||
* Off by default, and off means the model never learns the tools exist rather than
|
||||
* being refused when it calls one. The read side is the one that cannot show a
|
||||
* dialog before it happens, so it is the side that decides this default; the write
|
||||
* side raises a dialog every time and could reasonably have defaulted on.
|
||||
*/
|
||||
fileEnabled: boolean;
|
||||
/** Extra read roots from the preference, already absolute. */
|
||||
extraReadRoots: string[];
|
||||
/** Extra write roots from the preference, already absolute. */
|
||||
extraWriteRoots: string[];
|
||||
/** Entries from the field that were not absolute paths, for the setup screen to report. */
|
||||
rejectedReadRoots: string[];
|
||||
rejectedWriteRoots: string[];
|
||||
/** Undefined when reasoning is off, which is what the API client expects. */
|
||||
think?: string;
|
||||
/** Whether the transcript lists the tools Lam called. */
|
||||
@@ -37,6 +90,22 @@ export interface ResolvedPreferences {
|
||||
showReasoning: boolean;
|
||||
/** Idle time after which opening the command starts a fresh chat. Undefined means never. */
|
||||
resumeTimeoutMs?: number;
|
||||
/**
|
||||
* Age past which a conversation is deleted the next time a command runs.
|
||||
*
|
||||
* Undefined means the sweep is off entirely, which is the default. Every path
|
||||
* that cannot answer the question confidently resolves here, so a preference
|
||||
* that is missing, empty, or unrecognised deletes nothing.
|
||||
*/
|
||||
autoDeleteMs?: number;
|
||||
/**
|
||||
* Rounds of tool calls one turn may spend. Undefined means unlimited.
|
||||
*
|
||||
* Note the opposite convention to {@link ResolvedPreferences.autoDeleteMs}:
|
||||
* there, "unrecognised" falls back to doing nothing, and here it falls back to
|
||||
* the cap. See {@link resolveMaxToolRounds}.
|
||||
*/
|
||||
maxToolRounds?: number;
|
||||
}
|
||||
|
||||
const MINUTE = 60_000;
|
||||
@@ -56,43 +125,173 @@ export function resolveResumeTimeout(value: string | undefined): number | undefi
|
||||
return RESUME_TIMEOUTS[(value ?? "").trim()];
|
||||
}
|
||||
|
||||
/** Keep in sync with the `autoDeleteAfter` dropdown in `package.json`. */
|
||||
const AUTO_DELETE_WINDOWS: Record<string, number> = {
|
||||
"1d": DAY,
|
||||
"7d": 7 * DAY,
|
||||
"30d": 30 * DAY,
|
||||
"90d": 90 * DAY,
|
||||
};
|
||||
|
||||
/**
|
||||
* `undefined` for "disabled" and for anything unrecognised.
|
||||
*
|
||||
* Nothing here can turn an unrecognised value into a deletion: the fallback is
|
||||
* the off switch, which is the only safe reading of a preference this codebase
|
||||
* does not understand.
|
||||
*/
|
||||
export function resolveAutoDelete(value: string | undefined): number | undefined {
|
||||
return AUTO_DELETE_WINDOWS[(value ?? "").trim().toLowerCase()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Round limits, keyed by the preference values in `package.json`.
|
||||
*
|
||||
* `undefined` here means unlimited, so the table cannot be indexed the way the
|
||||
* other two are: an absent key and an "unlimited" key would be indistinguishable.
|
||||
*/
|
||||
const TOOL_ROUNDS: Record<string, number | undefined> = {
|
||||
"4": 4,
|
||||
"8": 8,
|
||||
"16": 16,
|
||||
unlimited: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
* The tool-call round cap, where `undefined` means unlimited.
|
||||
*
|
||||
* Falls back to the default cap rather than to unlimited, unlike
|
||||
* {@link resolveResumeTimeout} and {@link resolveAutoDelete}, which both treat an
|
||||
* unrecognised value as "off". Those two are safe when they do nothing; this one
|
||||
* guards a loop, so the safe reading of a value this codebase does not recognise
|
||||
* is the cap and not the absence of one.
|
||||
*/
|
||||
export function resolveMaxToolRounds(value: string | undefined): number | undefined {
|
||||
const key = (value ?? "").trim().toLowerCase();
|
||||
return key in TOOL_ROUNDS ? TOOL_ROUNDS[key] : DEFAULT_MAX_TOOL_ROUNDS;
|
||||
}
|
||||
|
||||
/** Human-readable form of {@link ResolvedPreferences.autoDeleteMs}, for the setup screen. */
|
||||
export function describeAutoDelete(milliseconds: number | undefined): string {
|
||||
if (milliseconds === undefined) return "Disabled (conversations are kept)";
|
||||
|
||||
return `After ${describeSpan(milliseconds)}`;
|
||||
}
|
||||
|
||||
/** Human-readable form of {@link ResolvedPreferences.maxToolRounds}, for the setup screen. */
|
||||
export function describeMaxToolRounds(rounds: number | undefined): string {
|
||||
if (rounds === undefined) return "Unlimited (no cap on tool calls)";
|
||||
return `${rounds} round${rounds === 1 ? "" : "s"} per answer`;
|
||||
}
|
||||
|
||||
/** Human-readable form of {@link ResolvedPreferences.resumeTimeoutMs}, for the setup screen. */
|
||||
export function describeResumeTimeout(milliseconds: number | undefined): string {
|
||||
if (milliseconds === undefined) return "Never (always reopen the last chat)";
|
||||
|
||||
return `After ${describeSpan(milliseconds)}`;
|
||||
}
|
||||
|
||||
/** "15 minutes", "6 hours", "7 days". Shared so the two windows phrase themselves the same way. */
|
||||
function describeSpan(milliseconds: number): string {
|
||||
const minutes = Math.round(milliseconds / MINUTE);
|
||||
if (minutes < 60) return `After ${minutes} minutes`;
|
||||
if (minutes < 60) return `${minutes} minutes`;
|
||||
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `After ${hours} hour${hours === 1 ? "" : "s"}`;
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"}`;
|
||||
|
||||
const days = Math.round(hours / 24);
|
||||
return `After ${days} day${days === 1 ? "" : "s"}`;
|
||||
return `${days} day${days === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
|
||||
const thinking = (raw.thinking ?? "").trim();
|
||||
const model = (raw.model ?? "").trim() || DEFAULT_MODEL;
|
||||
|
||||
return {
|
||||
apiKey: (raw.apiKey ?? "").trim(),
|
||||
model: (raw.model ?? "").trim() || DEFAULT_MODEL,
|
||||
model,
|
||||
// Anything read straight from Vicinae is the preference by definition. The
|
||||
// selection is layered on top in `loadPreferences`.
|
||||
modelSource: "preference",
|
||||
preferenceModel: model,
|
||||
host: (raw.ollamaHost ?? "").trim() || DEFAULT_HOST,
|
||||
systemPrompt: (raw.systemPrompt ?? "").trim(),
|
||||
place: (raw.location ?? "").trim(),
|
||||
// Defaults to off: this is the one setting that sends something about the
|
||||
// user to a third party, so it is a deliberate choice rather than a default.
|
||||
ipGeolocation: raw.ipGeolocation ?? false,
|
||||
// Defaults to on: the preference is a checkbox the user has to actively untick.
|
||||
webEnabled: raw.webSearchEnabled ?? true,
|
||||
// Off unless the user ticks the box: the read side of this feature sends file
|
||||
// contents to the model, and nothing else in this extension reads the machine.
|
||||
fileEnabled: raw.fileAccess ?? false,
|
||||
extraReadRoots: splitRoots(raw.readRoots).roots,
|
||||
extraWriteRoots: splitRoots(raw.writeRoots).roots,
|
||||
rejectedReadRoots: splitRoots(raw.readRoots).rejected,
|
||||
rejectedWriteRoots: splitRoots(raw.writeRoots).rejected,
|
||||
think: thinking && thinking !== "off" ? thinking : undefined,
|
||||
showToolCalls: raw.showToolCalls ?? true,
|
||||
showReasoning: raw.showReasoning ?? true,
|
||||
// Falls back to the manifest's declared default, so a missing preference
|
||||
// behaves the same whether Vicinae substitutes it or not.
|
||||
resumeTimeoutMs: resolveResumeTimeout(raw.resumeTimeout ?? "1h"),
|
||||
// Defaults to off, the one setting here that destroys data.
|
||||
autoDeleteMs: resolveAutoDelete(raw.autoDeleteAfter ?? "disabled"),
|
||||
maxToolRounds: resolveMaxToolRounds(raw.maxToolRounds ?? String(DEFAULT_MAX_TOOL_ROUNDS)),
|
||||
};
|
||||
}
|
||||
|
||||
export function getLamPreferences(): ResolvedPreferences {
|
||||
/**
|
||||
* Vicinae's own preferences, resolved and normalised. Not exported: everything
|
||||
* outside this module should go through {@link loadPreferences}, which also
|
||||
* applies the model chosen in the Manage Models command.
|
||||
*/
|
||||
function getLamPreferences(): ResolvedPreferences {
|
||||
return resolvePreferences(getPreferenceValues<LamPreferences>());
|
||||
}
|
||||
|
||||
/**
|
||||
* The model chosen in the Manage Models command, if there is one.
|
||||
*
|
||||
* Never throws. Storage that cannot be read means no selection rather than a
|
||||
* broken command, because the fallback (the preference) is always a valid
|
||||
* answer.
|
||||
*/
|
||||
export async function readModelSelection(): Promise<string | undefined> {
|
||||
const stored = await LocalStorage.getItem<string>(STORAGE_MODEL_KEY).catch(() => undefined);
|
||||
const trimmed = (stored ?? "").trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
/** Remembers a model as the one to use, taking precedence over the preference. */
|
||||
export async function selectModel(model: string): Promise<void> {
|
||||
await LocalStorage.setItem(STORAGE_MODEL_KEY, model.trim());
|
||||
}
|
||||
|
||||
/** Drops the selection, handing the model back to the `Model` preference. */
|
||||
export async function clearModelSelection(): Promise<void> {
|
||||
await LocalStorage.removeItem(STORAGE_MODEL_KEY).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* The preferences the rest of the extension should actually use.
|
||||
*
|
||||
* The only reader anyone else needs: it applies the model chosen in the Models
|
||||
* command on top of the preferences Vicinae holds. A second, synchronous reader
|
||||
* would be a trap, because it looks right and silently ignores the selection,
|
||||
* which is why `getLamPreferences` above is not exported.
|
||||
*
|
||||
* The model is the only thing a selection can override. Everything else here has
|
||||
* a single home in Vicinae's own settings, and adding a shadow copy of those
|
||||
* would make "where do I change this" a question with two answers.
|
||||
*/
|
||||
export async function loadPreferences(): Promise<ResolvedPreferences> {
|
||||
const base = getLamPreferences();
|
||||
const selected = await readModelSelection();
|
||||
|
||||
return selected ? { ...base, model: selected, modelSource: "selection" } : base;
|
||||
}
|
||||
|
||||
/** True when the extension cannot work until the user supplies a key. */
|
||||
export function needsSetup(preferences: ResolvedPreferences): boolean {
|
||||
return preferences.apiKey.length === 0;
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import { linkCitations } from "./citations";
|
||||
import { CLOSING_FENCE, OPENING_FENCE, mapOutsideCodeSpans, readCodeSpan, repairCodeBlocks } from "./code";
|
||||
import { sanitizeInlineHtml } from "./html";
|
||||
import { looksLikeMath, renderLatex } from "./latex";
|
||||
import type { Source } from "./types";
|
||||
|
||||
/**
|
||||
* The rendering pass for model output.
|
||||
*
|
||||
* `markdown.ts` builds the transcript, but an answer is not markdown that the
|
||||
* renderer can draw as written. Models write LaTeX, raw HTML, and diagrams made
|
||||
* of spaces, and Vicinae draws none of those: it has no maths block, block level
|
||||
* HTML is reduced to its text, and a paragraph wraps. Each of those is turned
|
||||
* into something the renderer already knows how to draw here, in one place, on
|
||||
* the way to the pane.
|
||||
*
|
||||
* The order is the whole design:
|
||||
*
|
||||
* 1. {@link repairCodeBlocks} runs first, because it decides where the fences
|
||||
* are. Everything after this pass is fence aware and skips code.
|
||||
* 2. Maths runs next, so a formula becomes inline markup before the HTML pass
|
||||
* looks at markup at all.
|
||||
* 3. HTML runs last, so the tags the maths pass adds are checked by the same
|
||||
* allowlist as the ones the model wrote. It is a second opinion on this
|
||||
* module's own output, and it costs nothing to take.
|
||||
*
|
||||
* Nothing here reads preferences or touches Vicinae, so the whole pipeline is
|
||||
* unit testable and the clipboard copy in `markdown.ts` keeps the source text
|
||||
* rather than the rendering.
|
||||
*/
|
||||
|
||||
/** Fence labels that mean "this body is maths". */
|
||||
const MATH_LANGUAGES = new Set(["math", "maths", "latex", "tex", "stex", "katex"]);
|
||||
|
||||
interface Segment {
|
||||
/** Fenced code, which every pass here leaves alone unless it is maths. */
|
||||
fence: boolean;
|
||||
/** Lower-cased language on the opening fence, empty when there is none. */
|
||||
language: string;
|
||||
/** For a fence, the body lines only. For text, the lines themselves. */
|
||||
lines: string[];
|
||||
/** A fence exactly as written, so it can be copied through untouched. */
|
||||
raw?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a document into text and fences.
|
||||
*
|
||||
* Both passes need the same answer to "is this code", and this is the one place
|
||||
* that decides. A fence with no closing marker runs to the end of the document,
|
||||
* which is how the parser reads it too.
|
||||
*/
|
||||
function splitSegments(markdown: string): Segment[] {
|
||||
const lines = markdown.split("\n");
|
||||
const segments: Segment[] = [];
|
||||
|
||||
let text: string[] = [];
|
||||
const flushText = () => {
|
||||
if (text.length > 0) segments.push({ fence: false, language: "", lines: text });
|
||||
text = [];
|
||||
};
|
||||
|
||||
let index = 0;
|
||||
while (index < lines.length) {
|
||||
const opener = OPENING_FENCE.exec(lines[index]);
|
||||
|
||||
if (!opener) {
|
||||
text.push(lines[index]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
flushText();
|
||||
|
||||
const raw = [lines[index]];
|
||||
index += 1;
|
||||
while (index < lines.length && !CLOSING_FENCE.test(lines[index])) {
|
||||
raw.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) {
|
||||
raw.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
segments.push({
|
||||
fence: true,
|
||||
language: opener[3].toLowerCase(),
|
||||
lines: raw.slice(1, raw.length - (CLOSING_FENCE.test(raw[raw.length - 1]) ? 1 : 0)),
|
||||
raw,
|
||||
});
|
||||
}
|
||||
|
||||
flushText();
|
||||
return segments;
|
||||
}
|
||||
|
||||
const DISPLAY_MARKERS = ["$$", "\\["];
|
||||
|
||||
const DISPLAY_CLOSERS: Record<string, string> = { $$: "$$", "\\[": "\\]" };
|
||||
|
||||
interface DisplayOpen {
|
||||
marker: string;
|
||||
/** The formula on this line, whether or not the marker closed it here. */
|
||||
body: string;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a display maths opener, if this line has one.
|
||||
*
|
||||
* A marker is only read as a block opener when it is the first thing on the
|
||||
* line, so `where $$x$$ is` stays a sentence. What follows the marker is the
|
||||
* formula whether or not the closer is on the same line, because a long formula
|
||||
* written across lines puts its first terms beside the opening `$$`.
|
||||
*/
|
||||
function openDisplayMath(line: string): DisplayOpen | undefined {
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
for (const marker of DISPLAY_MARKERS) {
|
||||
if (!trimmed.startsWith(marker)) continue;
|
||||
|
||||
const closer = DISPLAY_CLOSERS[marker];
|
||||
const inner = trimmed.slice(marker.length).trim();
|
||||
|
||||
if (inner.length >= closer.length && inner.endsWith(closer)) {
|
||||
return { marker, body: inner.slice(0, inner.length - closer.length), closed: true };
|
||||
}
|
||||
|
||||
return { marker, body: inner, closed: false };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The part of a line before the closer, or `undefined` when the line has none. */
|
||||
function closeDisplayMath(line: string, marker: string): string | undefined {
|
||||
const closer = DISPLAY_CLOSERS[marker];
|
||||
const trimmed = line.trimEnd();
|
||||
return trimmed.endsWith(closer) ? trimmed.slice(0, trimmed.length - closer.length) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline maths, in the order the delimiters have to be tried.
|
||||
*
|
||||
* `$$` has to be read before `$`, and both have to be read after the backslash
|
||||
* forms, which are unambiguous. The single `$` pattern is the fussy one and the
|
||||
* guard around it is deliberate: an opening `$` cannot be followed by a space
|
||||
* and a closing `$` cannot be preceded by one, which is what keeps `$5 and $10`
|
||||
* and `cd $A; echo $B` out of the renderer.
|
||||
*/
|
||||
const INLINE_MATH: Array<{ pattern: RegExp; guarded: boolean }> = [
|
||||
{ pattern: /\\\((.+?)\\\)/g, guarded: false },
|
||||
{ pattern: /\\\[(.+?)\\\]/g, guarded: false },
|
||||
{ pattern: /\$\$(.+?)\$\$/g, guarded: false },
|
||||
{ pattern: /(?<![$\\])\$(?![\s$])((?:\\.|[^\n$\\])+?)(?<!\s)\$(?!\$|\d)/g, guarded: true },
|
||||
];
|
||||
|
||||
function convertInlineMath(text: string): string {
|
||||
return mapOutsideCodeSpans(text, (chunk) => {
|
||||
let out = chunk;
|
||||
|
||||
for (const { pattern, guarded } of INLINE_MATH) {
|
||||
out = out.replace(pattern, (match: string, body: string) =>
|
||||
!guarded || looksLikeMath(body) ? renderLatex(body) : match,
|
||||
);
|
||||
}
|
||||
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The line with every code span blanked out, at the same length.
|
||||
*
|
||||
* Used to ask whether a display marker is really at the start of the line. The
|
||||
* replacement is a non-space character on purpose: spaces would be trimmed away
|
||||
* again and a marker hidden behind a code span would look like a block opener.
|
||||
*/
|
||||
function maskCodeSpans(line: string): string {
|
||||
let out = "";
|
||||
let index = 0;
|
||||
|
||||
while (index < line.length) {
|
||||
if (line[index] !== "`") {
|
||||
out += line[index];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const span = readCodeSpan(line, index);
|
||||
out += "\u0000".repeat(span.raw.length);
|
||||
index = span.end;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the maths in a run of text.
|
||||
*
|
||||
* A display formula is pulled onto its own paragraph with a blank line either
|
||||
* side, which is the only way to keep it out of the sentence around it and out
|
||||
* of the paragraph that follows.
|
||||
*/
|
||||
function renderMathLines(lines: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
const display = openDisplayMath(line);
|
||||
|
||||
// A marker inside a code span is being shown, not used, and it is the
|
||||
// opening one that decides whether this is a block at all.
|
||||
if (!display || !openDisplayMath(maskCodeSpans(line))) {
|
||||
out.push(convertInlineMath(line));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (display.closed) {
|
||||
const formula = renderLatex(display.body, { display: true });
|
||||
if (formula.length > 0) out.push("", formula, "");
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = [display.body];
|
||||
let cursor = index + 1;
|
||||
let closed = false;
|
||||
|
||||
while (cursor < lines.length) {
|
||||
const rest = closeDisplayMath(lines[cursor], display.marker);
|
||||
if (rest !== undefined) {
|
||||
body.push(rest);
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
body.push(lines[cursor]);
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
// A marker with no closer is only a formula if what it swallowed looks like
|
||||
// one. A stray `$$5 per unit` would otherwise pull the rest of the answer
|
||||
// into a single paragraph, which is a lot of damage for a typo.
|
||||
if (!closed && !looksLikeMath(body.join("\n"))) {
|
||||
out.push(convertInlineMath(line));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const formula = renderLatex(body.join("\n"), { display: true });
|
||||
if (formula.length > 0) out.push("", formula, "");
|
||||
|
||||
// An unclosed display marker swallows the rest of the run, which is what the
|
||||
// formula the model started to write actually covers.
|
||||
index = closed ? cursor + 1 : lines.length;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function mathPass(segments: Segment[]): Segment[] {
|
||||
return segments.map((segment) => {
|
||||
if (segment.fence) {
|
||||
if (!MATH_LANGUAGES.has(segment.language)) return segment;
|
||||
|
||||
const formula = renderLatex(segment.lines.join("\n"), { display: true });
|
||||
return { fence: false, language: "", lines: formula.length > 0 ? ["", formula, ""] : [] };
|
||||
}
|
||||
|
||||
return { fence: false, language: "", lines: renderMathLines(segment.lines) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags a parser reads as an HTML *block* when a line starts with one.
|
||||
*
|
||||
* This is CommonMark's type 6 list. An HTML block is not a rendering of its own
|
||||
* in Vicinae: `processHtmlNodes` in `markdown-model.cpp` walks it, pulls out
|
||||
* every `<img>` as a block image, and keeps the trimmed text of everything else
|
||||
* with the markup thrown away. So `<table><tr><td>a</td><td>b</td></tr></table>`
|
||||
* arrives as the word `ab`, and a `<div>` full of styled spans arrives as one
|
||||
* run-on sentence.
|
||||
*/
|
||||
const HTML_BLOCK_TAGS = new Set([
|
||||
"address",
|
||||
"article",
|
||||
"aside",
|
||||
"base",
|
||||
"basefont",
|
||||
"blockquote",
|
||||
"body",
|
||||
"caption",
|
||||
"center",
|
||||
"col",
|
||||
"colgroup",
|
||||
"dd",
|
||||
"details",
|
||||
"dialog",
|
||||
"dir",
|
||||
"div",
|
||||
"dl",
|
||||
"dt",
|
||||
"fieldset",
|
||||
"figcaption",
|
||||
"figure",
|
||||
"footer",
|
||||
"form",
|
||||
"frame",
|
||||
"frameset",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"head",
|
||||
"header",
|
||||
"hr",
|
||||
"html",
|
||||
"iframe",
|
||||
"legend",
|
||||
"li",
|
||||
"link",
|
||||
"main",
|
||||
"menu",
|
||||
"menuitem",
|
||||
"nav",
|
||||
"noframes",
|
||||
"ol",
|
||||
"optgroup",
|
||||
"option",
|
||||
"p",
|
||||
"param",
|
||||
"search",
|
||||
"section",
|
||||
"summary",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"tfoot",
|
||||
"th",
|
||||
"thead",
|
||||
"title",
|
||||
"tr",
|
||||
"track",
|
||||
"ul",
|
||||
]);
|
||||
|
||||
/** Tags that never need a closing tag, which the balance check has to skip. */
|
||||
const VOID_TAGS = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
|
||||
/** Whether this line would open an HTML block rather than continue a paragraph. */
|
||||
function startsHtmlBlock(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
const name = /^<([a-zA-Z][a-zA-Z0-9-]*)[\s/>]/.exec(trimmed)?.[1]?.toLowerCase();
|
||||
if (name && HTML_BLOCK_TAGS.has(name)) return true;
|
||||
|
||||
// A single complete tag with nothing but whitespace after it opens a block
|
||||
// too, which is the rule that catches a standalone `<img>` or `<span>`.
|
||||
return /^<\/?[a-zA-Z][a-zA-Z0-9-]*(\s[^<>]*)?\/?>\s*$/.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every tag in this run of HTML is closed in the right order.
|
||||
*
|
||||
* Only well-formed markup is handed to the rich text engine, because markup that
|
||||
* does not balance is markup nothing can be relied on to draw, and the text the
|
||||
* block path keeps is the better of two broken outcomes.
|
||||
*/
|
||||
function isBalancedHtml(text: string): boolean {
|
||||
const stack: string[] = [];
|
||||
|
||||
for (const match of text.matchAll(/<(\/?)([a-zA-Z][a-zA-Z0-9-]*)[^<>]*?(\/?)>/g)) {
|
||||
const name = match[2].toLowerCase();
|
||||
if (match[3] === "/" || VOID_TAGS.has(name)) continue;
|
||||
|
||||
if (match[1] === "/") {
|
||||
if (stack.pop() !== name) return false;
|
||||
} else {
|
||||
stack.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return stack.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a paragraph should be handed to the engine as inline HTML.
|
||||
*
|
||||
* The `<img>` exception is deliberate: an image inside an HTML block is the one
|
||||
* thing that path does better than this one, because it becomes a block image
|
||||
* the renderer actually loads.
|
||||
*/
|
||||
function shouldUnblock(paragraph: string[]): boolean {
|
||||
if (paragraph.length === 0) return false;
|
||||
if (!startsHtmlBlock(paragraph[0])) return false;
|
||||
|
||||
const text = paragraph.join("\n");
|
||||
if (/<img\b/i.test(text)) return false;
|
||||
|
||||
return isBalancedHtml(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a block of HTML into the paragraph the engine can see.
|
||||
*
|
||||
* Wrapping it in a `<span>` and collapsing it onto one line is what makes the
|
||||
* difference: a line that begins with `<span>` is a paragraph rather than an
|
||||
* HTML block, because `span` is not on that list, and a paragraph keeps its raw
|
||||
* HTML (`CMARK_NODE_HTML_INLINE` is passed through untouched). The same markup
|
||||
* therefore reaches the rich text engine, which is the only component in the
|
||||
* chain that can draw a table or a styled panel at all.
|
||||
*
|
||||
* Lines are joined because a continuation line starting with a block tag would
|
||||
* open a new HTML block of its own, which is how a multi-line HTML table loses
|
||||
* its markup today.
|
||||
*/
|
||||
function unblockHtml(lines: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
let end = index;
|
||||
while (end < lines.length && lines[end].trim().length > 0) end += 1;
|
||||
|
||||
const paragraph = lines.slice(index, end);
|
||||
if (shouldUnblock(paragraph)) {
|
||||
out.push(`<span>${paragraph.map((line) => line.trim()).join(" ")}</span>`);
|
||||
} else {
|
||||
out.push(...paragraph);
|
||||
}
|
||||
|
||||
if (end === index) {
|
||||
out.push(lines[end]);
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
index = end;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function htmlPass(segments: Segment[]): Segment[] {
|
||||
return segments.map((segment) => {
|
||||
if (segment.fence) return segment;
|
||||
|
||||
const unblocked = unblockHtml(segment.lines);
|
||||
return {
|
||||
fence: false,
|
||||
language: "",
|
||||
lines: sanitizeInlineHtml(unblocked.join("\n")).split("\n"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function flatten(segments: Segment[]): string {
|
||||
const lines: string[] = [];
|
||||
for (const segment of segments) lines.push(...(segment.fence ? (segment.raw ?? []) : segment.lines));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Maths and markup only, for text a person wrote.
|
||||
*
|
||||
* A question goes through this rather than the full pass: a person pastes LaTeX
|
||||
* and writes HTML, but their typing is not the thing the code-fence repair
|
||||
* exists for, so a question is left as it was typed wherever the repair would
|
||||
* have changed it.
|
||||
*/
|
||||
export function renderInlineContent(markdown: string): string {
|
||||
return flatten(htmlPass(mathPass(splitSegments(markdown))));
|
||||
}
|
||||
|
||||
/** What the pane will draw, and what the answer cited so the list can filter. */
|
||||
export interface RenderedMarkdown {
|
||||
text: string;
|
||||
/**
|
||||
* The numbers this answer cited that became links.
|
||||
*
|
||||
* Reported rather than worked out again by the caller: the pass already had to
|
||||
* decide which markers were real and which were inside code, and a second reader
|
||||
* of the same text could only disagree with it.
|
||||
*/
|
||||
cited: Set<number>;
|
||||
}
|
||||
|
||||
/** Optional input to the pass. */
|
||||
export interface RenderOptions {
|
||||
/**
|
||||
* The turn's sources, so a marker can become a link to the page it names.
|
||||
*
|
||||
* Absent for a question, and absent for an answer in a turn that consulted
|
||||
* nothing, in which case the citation pass is the identity function.
|
||||
*/
|
||||
sources?: Source[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The full rendering pass, from what the model wrote to what the pane draws.
|
||||
*
|
||||
* Takes one message's text, not a whole transcript: the transcript adds its own
|
||||
* markdown around each message (a blockquote for a question, a rule between
|
||||
* exchanges) and none of that should be read as model output.
|
||||
*/
|
||||
export function renderModelOutput(markdown: string, options: RenderOptions = {}): RenderedMarkdown {
|
||||
const sources = options.sources ?? [];
|
||||
const cited = new Set<number>();
|
||||
|
||||
/**
|
||||
* Maths first, then citations, then markup.
|
||||
*
|
||||
* The order is forced by one collision. A link label has to escape its brackets
|
||||
* or the parser ends the link text early, and `[\[1\]]` is exactly the shape of
|
||||
* LaTeX's own `\[...\]` display delimiter, which the maths pass would eat and
|
||||
* hand back as a bare `1`. So the model's maths is converted before any marker
|
||||
* becomes a link: the citation pass writes final form markdown, which is not
|
||||
* input for anything but the markup pass, and that one only reads angle brackets.
|
||||
*/
|
||||
const segments = mathPass(splitSegments(repairCodeBlocks(markdown))).map((segment) => {
|
||||
if (segment.fence || sources.length === 0) return segment;
|
||||
|
||||
const result = linkCitations(segment.lines.join("\n"), sources);
|
||||
for (const index of result.cited) cited.add(index);
|
||||
|
||||
return { fence: false, language: "", lines: result.text.split("\n") };
|
||||
});
|
||||
|
||||
return { text: flatten(htmlPass(segments)), cited };
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { SNIP_ORPHAN_GRACE_MS } from "./constants";
|
||||
import type { SessionSummary } from "./types";
|
||||
|
||||
/**
|
||||
* What gets deleted, and when.
|
||||
*
|
||||
* Two separate jobs live here, because they are the same job from the user's
|
||||
* point of view ("Lam keeps my history tidy") but nothing like each other in code:
|
||||
*
|
||||
* - Conversations unused for longer than the retention window.
|
||||
* - Screenshot files nothing refers to any more. A capture is written to disk
|
||||
* before the chat view opens, so a snip the user never sent belongs to no
|
||||
* conversation at all and no amount of session deletion would ever collect it.
|
||||
*
|
||||
* Both are pure decisions here, with every side effect injected, following
|
||||
* `startup.ts`: the alternative is discovering from a screenshot that a sweep
|
||||
* deleted a conversation it should not have. Imports nothing from Vicinae, so it
|
||||
* unit tests with no runtime.
|
||||
*
|
||||
* ## The bias is always towards deleting nothing
|
||||
*
|
||||
* This is the only code in the extension that destroys data, so every ambiguity
|
||||
* resolves towards leaving it alone. An unrecognised retention value disables the
|
||||
* sweep rather than guessing at a window; a snip is only collected once it is
|
||||
* both unreferenced *and* older than a grace period; and when the referenced set
|
||||
* cannot be established at all, the orphan pass is skipped rather than run
|
||||
* against a set it knows to be incomplete.
|
||||
*/
|
||||
|
||||
/** One file in the snip directory, with the age the sweep judges it by. */
|
||||
export interface SnipFile {
|
||||
path: string;
|
||||
/** Last modification time, which for a capture is when it was taken. */
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversations unused for at least `maxAgeMs`, oldest first.
|
||||
*
|
||||
* Age is measured from `updatedAt`, not `createdAt`, so a conversation started
|
||||
* last month but used this morning is not old. The open conversation is excluded
|
||||
* outright: it is about to be rendered, and deleting the thing the user is
|
||||
* looking at is the one outcome that would be indistinguishable from a bug.
|
||||
*
|
||||
* `undefined` means the sweep is off, which is the default. Every value that
|
||||
* cannot be resolved to a window arrives here as `undefined`.
|
||||
*/
|
||||
export function expiredSessions(
|
||||
summaries: SessionSummary[],
|
||||
maxAgeMs: number | undefined,
|
||||
now: number,
|
||||
keepId?: string,
|
||||
): SessionSummary[] {
|
||||
if (maxAgeMs === undefined) return [];
|
||||
|
||||
return summaries.filter((summary) => summary.id !== keepId && now - summary.updatedAt >= maxAgeMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snip files that nothing refers to and that have waited out the grace period.
|
||||
*
|
||||
* The wait is the whole safety story. A snip is on disk seconds after it is
|
||||
* taken and stays unreferenced until the user sends it, so without a grace
|
||||
* period the sweep would delete the file out from under a question the user is
|
||||
* still typing. A day is far longer than any real pending snip and far shorter
|
||||
* than the point at which the file matters.
|
||||
*/
|
||||
export function orphanedSnips(
|
||||
files: SnipFile[],
|
||||
referenced: Iterable<string>,
|
||||
now: number,
|
||||
graceMs: number = SNIP_ORPHAN_GRACE_MS,
|
||||
): SnipFile[] {
|
||||
const kept = new Set(referenced);
|
||||
return files.filter((file) => !kept.has(file.path) && now - file.mtimeMs >= graceMs);
|
||||
}
|
||||
|
||||
export interface SweepInput {
|
||||
/** Age past which an unused conversation is deleted. `undefined` disables everything. */
|
||||
maxAgeMs?: number;
|
||||
now: number;
|
||||
/** The conversation currently open, which is never deleted. */
|
||||
keepId?: string;
|
||||
/** How long an unreferenced snip must sit before it is collected. */
|
||||
snipGraceMs?: number;
|
||||
}
|
||||
|
||||
/** The side effects, injected so nothing here touches storage or the file system directly. */
|
||||
export interface SweepPorts {
|
||||
listSessions: () => Promise<SessionSummary[]>;
|
||||
/** Deletes conversations by id and returns every image path they referred to. */
|
||||
deleteSessions: (ids: string[]) => Promise<string[]>;
|
||||
/**
|
||||
* Image paths still referred to by conversations that remain, or `undefined`
|
||||
* when that cannot be established.
|
||||
*
|
||||
* `undefined` is not the same as an empty array and must not be treated as
|
||||
* one: it means the referenced set is unknown, and a set that is merely
|
||||
* incomplete looks exactly like a pile of orphans.
|
||||
*/
|
||||
referencedAttachments: () => Promise<string[] | undefined>;
|
||||
listSnipFiles: () => Promise<SnipFile[]>;
|
||||
/** Returns how many files it acted on. The sweep ignores the count; the setup screen reports it. */
|
||||
removeSnipFiles: (paths: string[]) => Promise<number>;
|
||||
}
|
||||
|
||||
export interface SweepOutcome {
|
||||
/** Conversations deleted. */
|
||||
sessions: number;
|
||||
/** Image files deleted, from deleted conversations and unreferenced orphans alike. */
|
||||
files: number;
|
||||
}
|
||||
|
||||
const NOTHING_DONE: SweepOutcome = { sessions: 0, files: 0 };
|
||||
|
||||
/**
|
||||
* What to tell the user was removed, or `undefined` when nothing was.
|
||||
*
|
||||
* The count names the thing rather than saying "items", because "2 conversations
|
||||
* and 4 screenshots" is the sentence a person wants to read before deciding
|
||||
* whether the tidy-up did what they expected. The background sweep treats
|
||||
* `undefined` as "stay silent"; an action the user pressed has to answer even
|
||||
* when the answer is nothing, so those callers spell that case out themselves.
|
||||
*/
|
||||
export function describeRemoval(counts: SweepOutcome): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (counts.sessions > 0) {
|
||||
parts.push(`${counts.sessions} conversation${counts.sessions === 1 ? "" : "s"}`);
|
||||
}
|
||||
if (counts.files > 0) {
|
||||
parts.push(`${counts.files} screenshot${counts.files === 1 ? "" : "s"}`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(" and ") : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs both passes.
|
||||
*
|
||||
* Disabled means disabled. When `maxAgeMs` is undefined nothing is read and
|
||||
* nothing is written, which also keeps the common case off the hot path: this
|
||||
* runs on every command open and after every answer.
|
||||
*/
|
||||
export async function sweepExpired(input: SweepInput, ports: SweepPorts): Promise<SweepOutcome> {
|
||||
if (input.maxAgeMs === undefined) return NOTHING_DONE;
|
||||
|
||||
const expired = expiredSessions(await ports.listSessions(), input.maxAgeMs, input.now, input.keepId);
|
||||
const fromSessions =
|
||||
expired.length > 0 ? await ports.deleteSessions(expired.map((summary) => summary.id)) : [];
|
||||
|
||||
const referenced = await ports.referencedAttachments();
|
||||
|
||||
// An incomplete referenced set would make every screenshot it does not happen
|
||||
// to mention look like an orphan, so an unknown set means neither pass runs.
|
||||
const kept = referenced === undefined ? undefined : new Set(referenced);
|
||||
|
||||
// A deleted conversation's files are removed explicitly rather than by the
|
||||
// grace-period rule, because a screenshot taken five minutes before its
|
||||
// conversation is deleted should go with it rather than sit on disk for a day.
|
||||
// A shared path is the exception: two conversations can refer to the same
|
||||
// capture, and the survivor still needs it.
|
||||
const fromDeleted = kept === undefined ? [] : fromSessions.filter((path) => !kept.has(path));
|
||||
|
||||
const orphans =
|
||||
kept === undefined
|
||||
? []
|
||||
: orphanedSnips(
|
||||
await ports.listSnipFiles(),
|
||||
kept,
|
||||
input.now,
|
||||
input.snipGraceMs ?? SNIP_ORPHAN_GRACE_MS,
|
||||
);
|
||||
|
||||
// Deduplicated because a file belonging to a deleted conversation can also
|
||||
// satisfy the orphan rule once that conversation has left the index.
|
||||
const paths = [...new Set([...fromDeleted, ...orphans.map((file) => file.path)])];
|
||||
if (paths.length > 0) await ports.removeSnipFiles(paths);
|
||||
|
||||
return { sessions: expired.length, files: paths.length };
|
||||
}
|
||||
@@ -7,11 +7,23 @@ import { z } from "zod";
|
||||
* older or hand-edited payload gets rejected instead of crashing a render.
|
||||
*/
|
||||
|
||||
export const SourceSchema = z.object({
|
||||
index: z.number(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
export const ToolCallRecordSchema = z.object({
|
||||
name: z.string(),
|
||||
arguments: z.record(z.string(), z.unknown()),
|
||||
result: z.string(),
|
||||
isError: z.boolean(),
|
||||
repeated: z.boolean().optional(),
|
||||
// Absent on turns stored before sources were numbered, which is honest: those
|
||||
// answers never cited a number, so there is nothing to draw for them. zod drops
|
||||
// unknown keys, so without this line the field would be lost on the first
|
||||
// reload and a reopened conversation would lose every link.
|
||||
sources: z.array(SourceSchema).optional(),
|
||||
});
|
||||
|
||||
export const AttachmentSchema = z.object({
|
||||
@@ -54,6 +66,10 @@ export const SessionSummarySchema = z.object({
|
||||
updatedAt: z.number(),
|
||||
turnCount: z.number(),
|
||||
preview: z.string(),
|
||||
// Optional rather than defaulted, so an index written before the orphan sweep
|
||||
// existed is still readable. A default of `[]` would look identical to "this
|
||||
// conversation has no screenshots", and the sweep would delete the files.
|
||||
attachmentPaths: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const SessionIndexSchema = z.array(SessionSummarySchema);
|
||||
|
||||
+200
-2
@@ -1,6 +1,8 @@
|
||||
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 { orphanedSnips, sweepExpired, type SweepOutcome } from "./retention";
|
||||
import { SessionIndexSchema, SessionSchema } from "./schemas";
|
||||
import { listSnipFiles, removeSnipFiles } from "./snip";
|
||||
import type { Attachment, ChatTurn, Session, SessionSummary } from "./types";
|
||||
import { titleFromPrompt, truncate } from "./utils";
|
||||
|
||||
@@ -41,9 +43,17 @@ function summarize(session: Session): SessionSummary {
|
||||
updatedAt: session.updatedAt,
|
||||
turnCount: session.turns.length,
|
||||
preview: lastTurn ? truncate(lastTurn.response || lastTurn.prompt, TITLE_MAX_LENGTH * 2) : "",
|
||||
attachmentPaths: attachmentPathsOf(session),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every image path a conversation refers to, deduplicated and in turn order. */
|
||||
export function attachmentPathsOf(session: Session): string[] {
|
||||
return [
|
||||
...new Set(session.turns.flatMap((turn) => turn.attachments.map((attachment) => attachment.path))),
|
||||
];
|
||||
}
|
||||
|
||||
export function createSession(model: string, now: number = Date.now()): Session {
|
||||
return {
|
||||
id: newId(),
|
||||
@@ -161,17 +171,179 @@ export async function saveSession(session: Session): Promise<void> {
|
||||
|
||||
export async function deleteSession(id: string): Promise<void> {
|
||||
const summaries = await listSessions();
|
||||
await writeIndex(summaries.filter((summary) => summary.id !== id));
|
||||
const survivors = summaries.filter((summary) => summary.id !== id);
|
||||
const owned = await attachmentPathsFor([id], summaries);
|
||||
|
||||
await writeIndex(survivors);
|
||||
|
||||
try {
|
||||
await LocalStorage.removeItem(sessionKey(id));
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
|
||||
// Deleting a conversation takes its screenshots with it, the same as an
|
||||
// expired one does. Without this a manual delete leaks image files that
|
||||
// nothing will ever collect, and the orphan pass only runs while the retention
|
||||
// setting is on.
|
||||
await removeSnipFiles(await pathsSafeToDelete(owned, survivors));
|
||||
}
|
||||
|
||||
export async function clearSessions(): Promise<void> {
|
||||
/**
|
||||
* The paths a deleted conversation held that nothing else still needs.
|
||||
*
|
||||
* Ownership alone is not enough: two conversations can refer to the same
|
||||
* capture, and removing the file would leave the survivor showing a broken
|
||||
* thumbnail. When the surviving references cannot be established, nothing is
|
||||
* removed.
|
||||
*/
|
||||
async function pathsSafeToDelete(owned: string[], survivors: SessionSummary[]): Promise<string[]> {
|
||||
if (owned.length === 0) return [];
|
||||
|
||||
const kept = await pathsKeptBy(survivors);
|
||||
return kept === undefined ? [] : owned.filter((path) => !kept.has(path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every image path the given conversations refer to, or `undefined` when that
|
||||
* cannot be established.
|
||||
*
|
||||
* Index-backed, for the reason set out on `SessionSummary.attachmentPaths`:
|
||||
* deriving this from the transcripts would mean a hundred JSON parses on every
|
||||
* sweep, and the sweep runs after every answer.
|
||||
*
|
||||
* Entries written before that field existed are filled in from their transcripts
|
||||
* and the index is written back once, so an upgrade pays for the repair on its
|
||||
* first sweep rather than on every one. A transcript that will not load is the
|
||||
* one case that returns `undefined` instead of an answer: its screenshots would
|
||||
* look exactly like orphans, and deleting someone's images because a key failed
|
||||
* to parse once is not a trade worth making.
|
||||
*/
|
||||
async function pathsKeptBy(summaries: SessionSummary[]): Promise<Set<string> | undefined> {
|
||||
if (summaries.every((summary) => summary.attachmentPaths !== undefined)) {
|
||||
return new Set(summaries.flatMap((summary) => summary.attachmentPaths ?? []));
|
||||
}
|
||||
|
||||
let unknown = false;
|
||||
|
||||
const repaired = await Promise.all(
|
||||
summaries.map(async (summary): Promise<SessionSummary> => {
|
||||
if (summary.attachmentPaths !== undefined) return summary;
|
||||
|
||||
const session = await loadSession(summary.id);
|
||||
if (!session) {
|
||||
unknown = true;
|
||||
return summary;
|
||||
}
|
||||
|
||||
return { ...summary, attachmentPaths: attachmentPathsOf(session) };
|
||||
}),
|
||||
);
|
||||
|
||||
if (unknown) return undefined;
|
||||
|
||||
try {
|
||||
await writeIndex(repaired);
|
||||
} catch {
|
||||
// A failed repair only means the next sweep reads these transcripts again.
|
||||
}
|
||||
|
||||
return new Set(repaired.flatMap((summary) => summary.attachmentPaths ?? []));
|
||||
}
|
||||
|
||||
/**
|
||||
* The image paths held by the named conversations.
|
||||
*
|
||||
* Bodies are read before their keys are removed, because the turns are the only
|
||||
* record of which screenshots belonged to them. A body that will not load
|
||||
* contributes the paths its summary already knew about, which is one of the
|
||||
* things `attachmentPaths` is stored for.
|
||||
*/
|
||||
async function attachmentPathsFor(ids: string[], summaries: SessionSummary[]): Promise<string[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const wanted = new Set(ids);
|
||||
const doomed = summaries.filter((summary) => wanted.has(summary.id));
|
||||
|
||||
const collected = await Promise.all(
|
||||
doomed.map(async (summary) => {
|
||||
const session = await loadSession(summary.id);
|
||||
return session ? attachmentPathsOf(session) : (summary.attachmentPaths ?? []);
|
||||
}),
|
||||
);
|
||||
|
||||
return [...new Set(collected.flat())];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes several conversations in one pass, returning the image paths they held.
|
||||
*
|
||||
* Separate from {@link deleteSession} rather than a loop over it because the
|
||||
* sweep can expire dozens of conversations at once, and one index rewrite per
|
||||
* conversation is dozens of writes to re-save a shrinking list.
|
||||
*
|
||||
* Bodies are read before their keys are removed, because the turns are the only
|
||||
* record of which screenshots belonged to these conversations.
|
||||
*/
|
||||
export async function deleteSessions(ids: string[]): Promise<string[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const removing = new Set(ids);
|
||||
const summaries = await listSessions();
|
||||
const paths = await attachmentPathsFor(ids, summaries);
|
||||
|
||||
await writeIndex(summaries.filter((summary) => !removing.has(summary.id)));
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await LocalStorage.removeItem(sessionKey(id));
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
|
||||
// Returned rather than removed: the sweep that calls this has already listed
|
||||
// the snip directory, and removing the files here would mean two passes over
|
||||
// it for one tidy-up.
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image paths still referred to by a conversation that exists, or `undefined`
|
||||
* when that cannot be established.
|
||||
*
|
||||
* See {@link pathsKeptBy}, which is where the decision actually lives.
|
||||
*/
|
||||
export async function referencedAttachments(): Promise<string[] | undefined> {
|
||||
const kept = await pathsKeptBy(await listSessions());
|
||||
return kept === undefined ? undefined : [...kept];
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the retention sweep against real storage and the real file system.
|
||||
*
|
||||
* The one place the pure decision in `retention.ts` meets its side effects, so a
|
||||
* command entrypoint has a single thing to call. Resolves to a zero outcome
|
||||
* rather than throwing: it runs on the way into a command the user is waiting
|
||||
* for, and a tidy-up that fails is not worth an error message.
|
||||
*/
|
||||
export async function sweepExpiredSessions(input: {
|
||||
maxAgeMs?: number;
|
||||
now?: number;
|
||||
keepId?: string;
|
||||
}): Promise<SweepOutcome> {
|
||||
return sweepExpired(
|
||||
{ maxAgeMs: input.maxAgeMs, now: input.now ?? Date.now(), keepId: input.keepId },
|
||||
{ listSessions, deleteSessions, referencedAttachments, listSnipFiles, removeSnipFiles },
|
||||
).catch(() => ({ sessions: 0, files: 0 }));
|
||||
}
|
||||
|
||||
export async function clearSessions(): Promise<SweepOutcome> {
|
||||
const summaries = await listSessions();
|
||||
const owned = await attachmentPathsFor(
|
||||
summaries.map((summary) => summary.id),
|
||||
summaries,
|
||||
);
|
||||
|
||||
for (const summary of summaries) {
|
||||
try {
|
||||
@@ -182,6 +354,32 @@ export async function clearSessions(): Promise<void> {
|
||||
}
|
||||
|
||||
await LocalStorage.removeItem(STORAGE_INDEX_KEY);
|
||||
|
||||
// Every screenshot too, for the same reason a single delete takes its own.
|
||||
// Nothing survives to share them with, so they are all safe to remove.
|
||||
const files = await removeSnipFiles(await pathsSafeToDelete(owned, []));
|
||||
|
||||
return { sessions: summaries.length, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the captures no conversation refers to any more.
|
||||
*
|
||||
* The same rule the background sweep uses, so the two can never disagree about
|
||||
* what counts as unattached: unreferenced, and older than the grace period. That
|
||||
* wait matters more here than it does in the sweep, because this runs on demand
|
||||
* while a chat could still be holding a snip the user has not sent yet.
|
||||
*
|
||||
* Returns how many files went, or `undefined` when the references could not be
|
||||
* established. `undefined` is not zero: it means the answer is unknown, and the
|
||||
* caller says so rather than reporting a clean bill of health it cannot support.
|
||||
*/
|
||||
export async function cullOrphanedSnips(now: number = Date.now()): Promise<number | undefined> {
|
||||
const referenced = await referencedAttachments();
|
||||
if (referenced === undefined) return undefined;
|
||||
|
||||
const orphans = orphanedSnips(await listSnipFiles(), new Set(referenced), now);
|
||||
return removeSnipFiles(orphans.map((file) => file.path));
|
||||
}
|
||||
|
||||
/** Recomputes a session's title from its first prompt, once there is a prompt worth naming it after. */
|
||||
|
||||
+84
-2
@@ -1,10 +1,13 @@
|
||||
import { environment } from "@vicinae/api";
|
||||
import { join } from "node:path";
|
||||
import { readdir, rm, stat } from "node:fs/promises";
|
||||
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { CapturePermissionError, NoCaptureToolError } from "./capture";
|
||||
import type { SnipFile } from "./retention";
|
||||
import { errorMessage } from "./utils";
|
||||
|
||||
/**
|
||||
* The seam between the snip command and the chat command.
|
||||
* The seam between the snip command and the chat command, and the directory they
|
||||
* both live out of.
|
||||
*
|
||||
* The capture cannot happen inside the chat view. Vicinae shows the launcher
|
||||
* window when it activates a command, so a region selector started from a view
|
||||
@@ -12,6 +15,10 @@ import { errorMessage } from "./utils";
|
||||
* a `no-view` command and handing the file over is what keeps the window out of
|
||||
* the way, which means the two commands need a small agreed contract.
|
||||
*
|
||||
* The directory also belongs here because it is the only place that knows where
|
||||
* captures go, and the retention sweep has to list and delete them without going
|
||||
* through a capture. `listSnipFiles` and `removeSnipFiles` are that half.
|
||||
*
|
||||
* Imports Vicinae for `environment.supportPath` only, and nothing else in the
|
||||
* logic layer depends on it.
|
||||
*/
|
||||
@@ -36,6 +43,81 @@ export function snipDirectory(): string {
|
||||
return join(environment.supportPath, "snips");
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file in the snip directory, with the age the orphan sweep judges it by.
|
||||
*
|
||||
* Read from the directory rather than from a stored list of captures, because
|
||||
* the directory is the only thing that cannot drift: a file whose record was
|
||||
* lost to a crash, or written before the sweep existed, is still found here.
|
||||
*/
|
||||
export async function listSnipFiles(): Promise<SnipFile[]> {
|
||||
const directory = snipDirectory();
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
// No directory yet simply means nothing has been snipped.
|
||||
return [];
|
||||
}
|
||||
|
||||
const found = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map(async (entry): Promise<SnipFile | undefined> => {
|
||||
const path = join(directory, entry.name);
|
||||
try {
|
||||
const stats = await stat(path);
|
||||
return { path, mtimeMs: stats.mtimeMs };
|
||||
} catch {
|
||||
// Deleted between the listing and the stat.
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return found.filter((file): file is SnipFile => file !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes snip files, refusing anything outside the snip directory.
|
||||
*
|
||||
* The paths arrive from stored JSON, which is data rather than code and can be
|
||||
* hand-edited or corrupted, so this cannot be a general-purpose delete-any-file
|
||||
* helper. Resolving both sides and checking containment is what keeps an edited
|
||||
* path from reaching `rm`, and a path that is refused is simply left alone.
|
||||
*
|
||||
* Best effort throughout: the sweep that calls this is a background tidy-up, so
|
||||
* a file that is already gone or that the system will not release must not fail
|
||||
* the rest of the pass.
|
||||
*
|
||||
* Returns how many paths it acted on, so a caller that reports back to the user
|
||||
* can count rather than guess. A file that was already missing counts as removed,
|
||||
* which is the honest answer to "is it still there".
|
||||
*/
|
||||
export async function removeSnipFiles(paths: string[]): Promise<number> {
|
||||
const directory = resolve(snipDirectory());
|
||||
let removed = 0;
|
||||
|
||||
for (const path of paths) {
|
||||
const target = resolve(path);
|
||||
const inside = relative(directory, target);
|
||||
|
||||
const contained =
|
||||
inside !== "" && inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside);
|
||||
if (!contained) continue;
|
||||
|
||||
try {
|
||||
await rm(target, { force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
// Nothing worth reporting: the file is either gone already or stuck.
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the handoff payload.
|
||||
*
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import { errorMessage } from "./utils";
|
||||
*/
|
||||
|
||||
export interface StartupInput {
|
||||
/** A conversation already in hand, which is how the sessions command pushes this view. */
|
||||
/** A conversation already in hand, which is how the Chat History command pushes this view. */
|
||||
hasInitialSession: boolean;
|
||||
/** A conversation to reopen by id, handed over by the snip command. */
|
||||
initialSessionId?: string;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* The live reasoning trace for a turn in flight.
|
||||
*
|
||||
* Ollama streams reasoning a round at a time: the model thinks, calls a tool,
|
||||
* thinks again, and so on. Two views of that stream are needed at once, which is
|
||||
* why this is two strings rather than one.
|
||||
*
|
||||
* - `full` is every token the model has thought this turn, across every round.
|
||||
* It is what gets stored on the turn, so `Show Reasoning` can replay the whole
|
||||
* trace when the conversation is reopened.
|
||||
* - `current` is only the reasoning since the last tool call. The live transcript
|
||||
* shows this one. A research turn that thinks before each of a dozen calls
|
||||
* would otherwise pile every round's reasoning on screen at once, and the tool
|
||||
* it just asked for would run underneath a wall of text that is no longer
|
||||
* about anything.
|
||||
*
|
||||
* Kept pure and free of React so the reset is testable without mounting the hook.
|
||||
*/
|
||||
|
||||
export interface ThinkingTrace {
|
||||
/** Every token this turn, across every round. What gets stored on the turn. */
|
||||
full: string;
|
||||
/** Just the reasoning since the last tool call. What the live transcript shows. */
|
||||
current: string;
|
||||
}
|
||||
|
||||
export const EMPTY_TRACE: ThinkingTrace = { full: "", current: "" };
|
||||
|
||||
/** Appends one streamed reasoning delta to both views. */
|
||||
export function withThinking(trace: ThinkingTrace, delta: string): ThinkingTrace {
|
||||
return { full: trace.full + delta, current: trace.current + delta };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current view, leaving the full trace alone.
|
||||
*
|
||||
* Called when a tool starts: the reasoning that produced the call has served its
|
||||
* purpose, and whatever the model thinks next is a new segment rather than a
|
||||
* continuation of the last one.
|
||||
*/
|
||||
export function afterToolCall(trace: ThinkingTrace): ThinkingTrace {
|
||||
return { full: trace.full, current: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopts the authoritative trace the turn ended with.
|
||||
*
|
||||
* The agent owns the final value, so this is not a reconstruction: it is the
|
||||
* one the stream produced, handed back whole.
|
||||
*/
|
||||
export function withFinalTrace(full: string): ThinkingTrace {
|
||||
return { full, current: "" };
|
||||
}
|
||||
+27
-1
@@ -1,4 +1,4 @@
|
||||
import type { ChatTurn, ThreadMessage } from "./types";
|
||||
import type { ChatTurn, ThreadMessage, ToolActivity } from "./types";
|
||||
|
||||
/**
|
||||
* Flattens stored turns into the flat message list the transcript renders.
|
||||
@@ -12,9 +12,31 @@ export interface LiveState {
|
||||
isStreaming: boolean;
|
||||
/** Partial answer for the turn currently in flight. */
|
||||
response: string;
|
||||
/**
|
||||
* Reasoning to show for the turn in flight.
|
||||
*
|
||||
* Only the part since the last tool call, not the whole trace: the live
|
||||
* transcript clears it each time a tool starts, so a research turn does not
|
||||
* stack every round's thinking on screen. The stored turn keeps the whole
|
||||
* trace separately.
|
||||
*/
|
||||
thinking: string;
|
||||
/** Activity line, e.g. "Searching the web". Only meaningful mid-turn. */
|
||||
status?: string;
|
||||
/** The tool being waited on right now, which supersedes the plain status when present. */
|
||||
activity?: ToolActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line describing a tool that is running.
|
||||
*
|
||||
* Says "the same call again" rather than showing a bare count, because a number
|
||||
* on its own reads as progress. This is the visible half of the loop guard in
|
||||
* `agent.ts`, and the only warning the user gets before a turn is cut short.
|
||||
*/
|
||||
export function describeActivity(activity: ToolActivity): string {
|
||||
if (activity.count <= 1) return activity.label;
|
||||
return `${activity.label} · the same call again (${activity.count})`;
|
||||
}
|
||||
|
||||
export function buildThread(turns: ChatTurn[], live: LiveState): ThreadMessage[] {
|
||||
@@ -43,6 +65,10 @@ export function buildThread(turns: ChatTurn[], live: LiveState): ThreadMessage[]
|
||||
// Only while there is nothing to read yet. Once tokens land, the
|
||||
// placeholder it feeds is gone anyway.
|
||||
status: isLive && text.length === 0 ? live.status : undefined,
|
||||
// The tool, on the other hand, is worth naming even after the answer has
|
||||
// started arriving: a tool that begins mid-answer would otherwise be
|
||||
// invisible, which was the original complaint about hidden tool calls.
|
||||
activity: isLive ? live.activity : undefined,
|
||||
// Only a question carries attachments, so the answer never has one.
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
+410
-25
@@ -1,16 +1,31 @@
|
||||
import { createSourceList, type SourceList } from "./citations";
|
||||
import {
|
||||
deletePath,
|
||||
editTextFile,
|
||||
listDirectory,
|
||||
movePath,
|
||||
readTextFile,
|
||||
searchFiles,
|
||||
writeTextFile,
|
||||
type ApproveFn,
|
||||
type FileResult,
|
||||
type PathPolicy,
|
||||
} from "./files";
|
||||
import { formatNow, getLocationContext, weekdayName } from "./location";
|
||||
import { webFetch, webSearch } from "./ollama";
|
||||
import type { ToolDefinition } from "./types";
|
||||
import type { GeoLookup } from "./geo";
|
||||
import type { Source, 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.
|
||||
* All are read-only except the file tools, which are the one family here that can
|
||||
* change the machine. They are bounded in three ways at once: they are absent from
|
||||
* the tool list unless the user switches file access on, the policy in `files.ts`
|
||||
* decides what they may touch, and every change is approved in a dialog before it
|
||||
* happens. Nothing here opens a URL, touches the clipboard or launches a program:
|
||||
* those would be side effects with no approval story of their own.
|
||||
*/
|
||||
|
||||
export interface ToolContext {
|
||||
@@ -19,11 +34,57 @@ export interface ToolContext {
|
||||
signal?: AbortSignal;
|
||||
/** Injectable clock so tests are deterministic. */
|
||||
now?: Date;
|
||||
/**
|
||||
* The user's Location preference, if they set one.
|
||||
*
|
||||
* Injected rather than read here, because this module is deliberately free of
|
||||
* Vicinae imports and preferences arrive through that API.
|
||||
*/
|
||||
place?: string;
|
||||
/**
|
||||
* Resolves a rough place from the user's IP address, when that is switched on.
|
||||
*
|
||||
* Injected for the same reason as `place`, and absent when IP geolocation is
|
||||
* off, which is what keeps this module from being able to reach the network
|
||||
* for a location on its own.
|
||||
*/
|
||||
ipPlace?: (options: { signal?: AbortSignal }) => Promise<GeoLookup>;
|
||||
/**
|
||||
* The turn's source list, when the caller keeps one.
|
||||
*
|
||||
* Optional, so a caller with no interest in citations (a unit test, or any
|
||||
* future tool that returns no sources) still gets the same numbered output: a
|
||||
* list is made on the spot when none is supplied.
|
||||
*/
|
||||
sources?: SourceList;
|
||||
/**
|
||||
* The file tools' policy and approval step, when file access is switched on.
|
||||
*
|
||||
* Both arrive together because neither is usable alone: the policy decides what may
|
||||
* be touched and the approval decides whether it happens. Absent means the tools are
|
||||
* not offered at all, and a call that arrives anyway is refused.
|
||||
*/
|
||||
files?: { policy: PathPolicy; approve: ApproveFn };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a tool returned.
|
||||
*
|
||||
* `sources` is separate from `content` on purpose. The content is prose written
|
||||
* for a model to read, while the sources are data the transcript renders, so a
|
||||
* change to one format cannot quietly break the other.
|
||||
*/
|
||||
export interface ToolResult {
|
||||
content: string;
|
||||
isError: boolean;
|
||||
/**
|
||||
* The sources this call added, for the transcript to list under the answer.
|
||||
*
|
||||
* Only what this call found, never the turn's running total: that total is the
|
||||
* flattening of the turn's records, so storing it here would write the same page
|
||||
* once per later call.
|
||||
*/
|
||||
sources?: Source[];
|
||||
}
|
||||
|
||||
/** Search snippets are truncated so a chatty result set cannot swallow the model's context window. */
|
||||
@@ -45,7 +106,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
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.",
|
||||
"Get where the user is, for local questions such as weather, business hours, or travel timing. Returns the place they configured in preferences, plus the timezone, UTC offset, locale and region. Without a configured place this is region-level, not an address, and the timezone area it reports is the timezone's own name rather than the user's city, so never treat it as one.",
|
||||
parameters: { type: "object", properties: {}, required: [] },
|
||||
},
|
||||
},
|
||||
@@ -83,26 +144,180 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_directory",
|
||||
description:
|
||||
"List what is in a folder on the user's machine: names, which entries are directories, their sizes and how long ago they changed. Use it before reading a file to get the exact name, and to see what a folder holds.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description:
|
||||
"Absolute path, or one starting with ~. A relative path is read as relative to the user's home directory.",
|
||||
},
|
||||
hidden: { type: "boolean", description: "Include dotfiles. Defaults to false." },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search_files",
|
||||
description:
|
||||
"Find files by name under a folder, optionally only those whose contents contain a string. Use it when you do not know where something is. Credential files are never returned.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Folder to search, absolute or starting with ~." },
|
||||
pattern: {
|
||||
type: "string",
|
||||
description: 'Glob for the file name, such as "*.md" or "report*". Use "*" for everything.',
|
||||
},
|
||||
contains: {
|
||||
type: "string",
|
||||
description: "Only files whose contents contain this text, case-insensitively.",
|
||||
},
|
||||
},
|
||||
required: ["path", "pattern"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description:
|
||||
"Read a text file. Lines come back numbered, and a long file comes back in parts: use offset and limit to continue from where the last read stopped rather than asking for the whole thing. Binary files are refused.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "File to read, absolute or starting with ~." },
|
||||
offset: { type: "integer", description: "First line to return, counting from 1." },
|
||||
limit: { type: "integer", description: "How many lines to return. Defaults to 200." },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "write_file",
|
||||
description:
|
||||
"Create a file, or replace one entirely, with the content you supply. The user approves every change in a dialog before it happens, and a refusal is final: do not retry it, say what you could not do instead. Prefer edit_file for a small change to an existing file.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "File to write, absolute or starting with ~." },
|
||||
content: { type: "string", description: "The complete new contents of the file." },
|
||||
},
|
||||
required: ["path", "content"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "edit_file",
|
||||
description:
|
||||
"Replace an exact string in an existing file, leaving the rest of it alone. old_string must appear exactly once, or pass all to replace every occurrence. Include enough surrounding text to make it unique, and never guess at the contents: read the file first.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "File to edit, absolute or starting with ~." },
|
||||
old_string: {
|
||||
type: "string",
|
||||
description: "The exact text to replace, copied from the file.",
|
||||
},
|
||||
new_string: { type: "string", description: "The text to put in its place." },
|
||||
all: {
|
||||
type: "boolean",
|
||||
description: "Replace every occurrence rather than requiring one.",
|
||||
},
|
||||
},
|
||||
required: ["path", "old_string", "new_string"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_path",
|
||||
description:
|
||||
"Delete one file, or one empty folder. A folder with anything in it is refused, because Lam never deletes recursively. The user approves every deletion and it cannot be undone.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "File or empty folder to delete." },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "move_path",
|
||||
description:
|
||||
"Move or rename a file or folder. Both ends must be somewhere Lam may change, the destination must not already exist, and the user approves every move.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
from: { type: "string", description: "Current path." },
|
||||
to: { type: "string", description: "New path, which must not exist yet." },
|
||||
},
|
||||
required: ["from", "to"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** 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"]);
|
||||
|
||||
/** Tools that read or change files, offered only when the user has switched file access on. */
|
||||
export const FILE_TOOL_NAMES = new Set([
|
||||
"list_directory",
|
||||
"search_files",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"delete_path",
|
||||
"move_path",
|
||||
]);
|
||||
|
||||
/** Human-readable labels for the activity line in the UI. */
|
||||
export const TOOL_LABELS: Record<string, string> = {
|
||||
get_datetime: "Checking the time",
|
||||
get_location: "Checking your location",
|
||||
web_search: "Searching the web",
|
||||
web_fetch: "Reading a page",
|
||||
list_directory: "Listing a folder",
|
||||
search_files: "Searching your files",
|
||||
read_file: "Reading a file",
|
||||
write_file: "Writing a file",
|
||||
edit_file: "Editing a file",
|
||||
delete_path: "Deleting a file",
|
||||
move_path: "Moving a file",
|
||||
};
|
||||
|
||||
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));
|
||||
/** Filters out the tools the user has switched off. */
|
||||
export function toolsFor(options: { webEnabled: boolean; fileEnabled?: boolean }): ToolDefinition[] {
|
||||
return TOOL_DEFINITIONS.filter((tool) => {
|
||||
const name = tool.function.name;
|
||||
if (WEB_TOOL_NAMES.has(name)) return options.webEnabled;
|
||||
if (FILE_TOOL_NAMES.has(name)) return options.fileEnabled ?? false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
@@ -121,6 +336,42 @@ function isWebTool(name: string): boolean {
|
||||
return WEB_TOOL_NAMES.has(name);
|
||||
}
|
||||
|
||||
/** The path argument every file tool needs, as the model wrote it. */
|
||||
function requirePath(args: Record<string, unknown>): string {
|
||||
return typeof args.path === "string" ? args.path : "";
|
||||
}
|
||||
|
||||
function missing(tool: string, argument: string): ToolResult {
|
||||
return { content: `${tool} requires a "${argument}".`, isError: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one file operation, once the context is known to have a policy.
|
||||
*
|
||||
* The missing-policy branch is not dead code: `toolsFor` keeps these tools out of the
|
||||
* list when file access is off, so a call only arrives here if a model invents one, and
|
||||
* then the honest answer is that the tool is not available.
|
||||
*/
|
||||
async function runFileTool(
|
||||
context: ToolContext,
|
||||
run: (files: { policy: PathPolicy; approve: ApproveFn }) => Promise<FileResult | ToolResult>,
|
||||
): Promise<ToolResult> {
|
||||
const files = context.files;
|
||||
if (!files) {
|
||||
return {
|
||||
content: "File tools are not available in this session. The user has not switched file access on.",
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await run(files);
|
||||
// A missing argument is answered here rather than by the policy, so the callback is
|
||||
// allowed to return either shape and this is where the two meet.
|
||||
if ("isError" in result) return result;
|
||||
|
||||
return { content: result.content, isError: !result.ok };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one tool call and always resolves.
|
||||
*
|
||||
@@ -140,7 +391,7 @@ export async function executeTool(
|
||||
return { content: describeNow(context.now), isError: false };
|
||||
|
||||
case "get_location":
|
||||
return { content: describeLocation(context.now), isError: false };
|
||||
return { content: await describeLocation(context), isError: false };
|
||||
|
||||
case "web_search":
|
||||
return await runWebSearch(args, context);
|
||||
@@ -148,12 +399,79 @@ export async function executeTool(
|
||||
case "web_fetch":
|
||||
return await runWebFetch(args, context);
|
||||
|
||||
case "list_directory":
|
||||
return await runFileTool(context, (files) =>
|
||||
listDirectory(requirePath(args), files.policy, { hidden: args.hidden === true }),
|
||||
);
|
||||
|
||||
case "search_files":
|
||||
return await runFileTool(context, (files) => {
|
||||
const pattern = asString(args.pattern);
|
||||
if (!pattern) return Promise.resolve(missing("search_files", "pattern"));
|
||||
return searchFiles(
|
||||
{ path: requirePath(args), pattern, contains: asString(args.contains) },
|
||||
files.policy,
|
||||
);
|
||||
});
|
||||
|
||||
case "read_file":
|
||||
return await runFileTool(context, (files) =>
|
||||
readTextFile(
|
||||
{
|
||||
path: requirePath(args),
|
||||
offset: asCount(args.offset, 1, Number.MAX_SAFE_INTEGER),
|
||||
limit: asCount(args.limit, 1, 5000),
|
||||
},
|
||||
files.policy,
|
||||
),
|
||||
);
|
||||
|
||||
case "write_file":
|
||||
return await runFileTool(context, (files) =>
|
||||
writeTextFile(
|
||||
{
|
||||
path: requirePath(args),
|
||||
content: typeof args.content === "string" ? args.content : "",
|
||||
},
|
||||
files.policy,
|
||||
files.approve,
|
||||
),
|
||||
);
|
||||
|
||||
case "edit_file":
|
||||
return await runFileTool(context, (files) => {
|
||||
const oldString = typeof args.old_string === "string" ? args.old_string : undefined;
|
||||
const newString = typeof args.new_string === "string" ? args.new_string : "";
|
||||
if (oldString === undefined) return Promise.resolve(missing("edit_file", "old_string"));
|
||||
return editTextFile(
|
||||
{ path: requirePath(args), oldString, newString, all: args.all === true },
|
||||
files.policy,
|
||||
files.approve,
|
||||
);
|
||||
});
|
||||
|
||||
case "delete_path":
|
||||
return await runFileTool(context, (files) =>
|
||||
deletePath(requirePath(args), files.policy, files.approve),
|
||||
);
|
||||
|
||||
case "move_path":
|
||||
return await runFileTool(context, (files) => {
|
||||
// `from` rather than `path`: this is the one file tool with two paths, and
|
||||
// reading the shared helper here would have sent an empty source path.
|
||||
const from = asString(args.from);
|
||||
const to = asString(args.to);
|
||||
if (!from) return Promise.resolve(missing("move_path", "from"));
|
||||
if (!to) return Promise.resolve(missing("move_path", "to"));
|
||||
return movePath({ from, to }, files.policy, files.approve);
|
||||
});
|
||||
|
||||
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."
|
||||
? " Web search needs Ollama Cloud, or a local Ollama server new enough to serve it (0.35 and later)."
|
||||
: "";
|
||||
return { content: `${errorMessage(error)}${suffix}`, isError: true };
|
||||
}
|
||||
@@ -172,18 +490,68 @@ function describeNow(now?: Date): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function describeLocation(now?: Date): string {
|
||||
const date = now ?? new Date();
|
||||
const location = getLocationContext(date);
|
||||
async function describeLocation(context: ToolContext): Promise<string> {
|
||||
const date = context.now ?? new Date();
|
||||
const configured = context.place?.trim() || undefined;
|
||||
|
||||
const lines = [
|
||||
// A place the user typed wins outright, and while it is set there is no reason
|
||||
// to hand their IP address to anyone.
|
||||
const lookup =
|
||||
configured || !context.ipPlace ? undefined : await context.ipPlace({ signal: context.signal });
|
||||
const ip = lookup?.status === "ok" ? lookup.place : undefined;
|
||||
|
||||
const location = getLocationContext(date, undefined, configured ?? ip?.place);
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// The place comes first because it is the only answer that is about where the
|
||||
// user actually is, rather than about which zone the clock is set to.
|
||||
if (location.place) lines.push(`Place: ${location.place}`);
|
||||
|
||||
lines.push(
|
||||
`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.");
|
||||
);
|
||||
|
||||
if (location.region) {
|
||||
lines.push(`Region: ${location.region}${location.regionCode ? ` (${location.regionCode})` : ""}`);
|
||||
}
|
||||
if (location.timezoneArea) {
|
||||
lines.push(`Timezone area: ${location.timezoneArea}`);
|
||||
}
|
||||
|
||||
// A mismatch between the clock and the IP is worth surfacing: it usually means
|
||||
// a VPN or a device still set to somewhere the user has left.
|
||||
if (ip?.timezone && ip.timezone !== location.timezone) {
|
||||
lines.push(`IP-reported timezone: ${ip.timezone}, which differs from the device timezone`);
|
||||
}
|
||||
|
||||
if (configured) {
|
||||
lines.push(
|
||||
"Note: Place is the user's own setting, so prefer it for anything that depends on exactly where they are.",
|
||||
);
|
||||
} else if (ip) {
|
||||
lines.push(
|
||||
`Note: Place came from an IP address lookup via ${ip.source}, so treat it as a rough guess rather than the user's city. An IP address locates where the request left the ISP's network, which can be hundreds of kilometres away, and it can be out of date if they have moved since.`,
|
||||
);
|
||||
} else if (lookup?.status === "failed") {
|
||||
// Deliberately not the "no Location is set" wording below. A failed lookup
|
||||
// and an unset preference are different problems, and reporting the wrong
|
||||
// one sends the user looking in the wrong place. This is also the only
|
||||
// channel that reaches them: the model paraphrases, so it is told to say so.
|
||||
lines.push(
|
||||
`Note: IP geolocation is switched on but the lookup failed (${lookup.message}), so no place could be resolved. Tell the user this plainly. Setting Location in preferences avoids the lookup and the network entirely. Everything below is derived from the device timezone and locale, so it is region-level.`,
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
"Note: no Location is set in preferences. Everything above is derived from the device timezone and locale, so it is accurate to a region and not to a city. Ask which city they are in when an exact place matters.",
|
||||
);
|
||||
}
|
||||
|
||||
if (location.timezoneArea) {
|
||||
lines.push("The timezone area is the timezone's own name, not the user's city.");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -206,8 +574,11 @@ async function runWebSearch(args: Record<string, unknown>, context: ToolContext)
|
||||
return { content: `No web results for "${query}".`, isError: false };
|
||||
}
|
||||
|
||||
const list = context.sources ?? createSourceList();
|
||||
const added: Source[] = [];
|
||||
|
||||
const rendered = results
|
||||
.map((result, index) => {
|
||||
.map((result) => {
|
||||
const collapsed = result.content.replace(/\s+/g, " ").trim();
|
||||
// Mark the cut rather than silently dropping the tail: the model should
|
||||
// know a snippet was trimmed, and this matches what web_fetch does.
|
||||
@@ -215,11 +586,19 @@ async function runWebSearch(args: Record<string, unknown>, context: ToolContext)
|
||||
collapsed.length > MAX_SNIPPET_CHARS
|
||||
? `${collapsed.slice(0, MAX_SNIPPET_CHARS)} …[truncated]`
|
||||
: collapsed;
|
||||
return `${index + 1}. ${result.title}\n ${result.url}\n ${snippet}`;
|
||||
|
||||
const { index, source } = list.add(result.title, result.url);
|
||||
if (source) added.push(source);
|
||||
|
||||
// The number is what the model cites, so it is printed rather than left to be
|
||||
// inferred from position: three searches in one turn would otherwise each
|
||||
// start again at 1, and a marker in the answer would point at three pages.
|
||||
const marker = index > 0 ? `[${index}] ` : "";
|
||||
return `${marker}${result.title}\n ${result.url}\n ${snippet}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return { content: `Web results for "${query}":\n\n${rendered}`, isError: false };
|
||||
return { content: `Web results for "${query}":\n\n${rendered}`, isError: false, sources: added };
|
||||
}
|
||||
|
||||
async function runWebFetch(args: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
|
||||
@@ -239,10 +618,16 @@ async function runWebFetch(args: Record<string, unknown>, context: ToolContext):
|
||||
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];
|
||||
const { index, source } = (context.sources ?? createSourceList()).add(page.title, url);
|
||||
const heading = index > 0 ? `[${index}] ${page.title}` : page.title;
|
||||
|
||||
// The address keeps its label here, unlike in a search result: the body after it
|
||||
// can run for thousands of characters, and a reader scrolling that wants to be
|
||||
// able to find the page's own URL again.
|
||||
const sections = [heading, `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 };
|
||||
return { content: sections.join("\n"), isError: false, sources: source ? [source] : [] };
|
||||
}
|
||||
|
||||
@@ -84,12 +84,47 @@ export interface PendingAttachment extends Attachment {
|
||||
/** Base64 of the image, with no `data:` URL prefix. Ollama wants it bare. */
|
||||
base64: string;
|
||||
}
|
||||
/**
|
||||
* One numbered source, as a web tool found it.
|
||||
*
|
||||
* The number is the whole point: it is assigned once per turn, printed to the
|
||||
* model in the tool result, and rendered as the marker the reader clicks. The
|
||||
* model and the reader therefore agree on what `[3]` means without either of them
|
||||
* being asked to remember a URL.
|
||||
*/
|
||||
export interface Source {
|
||||
/** The number the model cites. One-based, and never reused for a different page. */
|
||||
index: number;
|
||||
/** Page title, or the URL when the page had none. */
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** What a tool actually returned during a turn, kept for display and debugging. */
|
||||
export interface ToolCallRecord {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result: string;
|
||||
isError: boolean;
|
||||
/**
|
||||
* The sources this call added to the turn, if it consulted any.
|
||||
*
|
||||
* Only what this call found, never the turn's running total: the whole list is
|
||||
* the flattening of a turn's records, so storing the total here would write
|
||||
* the same page once per later call.
|
||||
*
|
||||
* Absent on turns stored before sources were numbered, which is honest rather
|
||||
* than empty: those answers never cited a number, so there is nothing to draw.
|
||||
*/
|
||||
sources?: Source[];
|
||||
/**
|
||||
* Set when the call was refused because the turn had already made it twice.
|
||||
*
|
||||
* Stored rather than inferred from the message text, so the transcript can say
|
||||
* what happened in its own words, and so a repeat that ended a turn stays
|
||||
* visible even when the user has hidden tool calls from the transcript.
|
||||
*/
|
||||
repeated?: boolean;
|
||||
}
|
||||
|
||||
/** One question and its answer. The unit the transcript is rendered from. */
|
||||
@@ -125,12 +160,75 @@ export interface SessionSummary {
|
||||
updatedAt: number;
|
||||
turnCount: number;
|
||||
preview: string;
|
||||
/**
|
||||
* Every image path any turn in this conversation refers to.
|
||||
*
|
||||
* Kept here rather than derived from the turns for one reason: the orphan
|
||||
* sweep needs to know which snips are still spoken for, and loading every
|
||||
* transcript to find out would mean a hundred JSON parses on every turn.
|
||||
*
|
||||
* Absent on entries written before this field existed, which is deliberately
|
||||
* not the same as an empty array: an absent value means "unknown", and the
|
||||
* sweep resolves it by reading that one transcript rather than by assuming the
|
||||
* conversation has no screenshots and deleting them.
|
||||
*/
|
||||
attachmentPaths?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the model in use can read images, as far as the capability check knows.
|
||||
*
|
||||
* `"unknown"` is deliberately distinct from `"no"`: a failed request is not
|
||||
* evidence that a model cannot see, and reporting it as one would be a false
|
||||
* claim about the user's setup. Lives here rather than in `vision.ts` so that
|
||||
* modules which must stay free of Vicinae imports can name it.
|
||||
*/
|
||||
export type VisionSupport = "yes" | "no" | "unknown";
|
||||
|
||||
/**
|
||||
* The tool running right now, and how stale it is getting.
|
||||
*
|
||||
* Lives here rather than beside the chat state machine so that `thread.ts` can
|
||||
* carry it into the transcript, which is the only place it is now rendered.
|
||||
*/
|
||||
export interface ToolActivity {
|
||||
/** Human-readable label, e.g. "Searching the web". */
|
||||
label: string;
|
||||
/**
|
||||
* How many times this turn has now started this *identical* call.
|
||||
*
|
||||
* Counted by the agent over name and arguments together, so two different
|
||||
* searches are two tools doing their job rather than a repeat.
|
||||
*/
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** A model as reported by `GET /api/tags`. */
|
||||
export interface ModelInfo {
|
||||
name: string;
|
||||
parameterSize?: string;
|
||||
/**
|
||||
* How much disk the model takes, in bytes.
|
||||
*
|
||||
* Meaningless for a cloud model proxied through a local daemon: that entry is
|
||||
* a few hundred bytes of manifest, so the caller shows nothing rather than
|
||||
* "326 B", which is what `ollama list` does too.
|
||||
*/
|
||||
size?: number;
|
||||
/**
|
||||
* What the model says it can do, e.g. `["tools", "vision"]`.
|
||||
*
|
||||
* Only newer servers send this. Absent means unknown, never "no": reporting a
|
||||
* missing field as a lack of vision would be a false claim about the user's
|
||||
* setup.
|
||||
*/
|
||||
capabilities?: string[];
|
||||
/**
|
||||
* Set when this entry is a proxy rather than weights on disk, naming the server
|
||||
* that actually runs it (`https://ollama.com`). Deleting one of these drops a
|
||||
* reference; it does not free the gigabytes the size column implies.
|
||||
*/
|
||||
remoteHost?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,6 +249,16 @@ export interface ThreadMessage {
|
||||
error?: string;
|
||||
/** Activity line shown while this message is streaming, e.g. "Searching the web". */
|
||||
status?: string;
|
||||
/**
|
||||
* The tool this message is waiting on, when there is one.
|
||||
*
|
||||
* Separate from {@link ThreadMessage.status} because the two are rendered from
|
||||
* different triggers: the status is a stand-in used only while there is no text
|
||||
* to read, while a running tool is worth saying whether or not tokens have
|
||||
* started arriving. Carrying both is what lets one line be printed for one
|
||||
* fact, rather than the same sentence appearing twice at the top of a turn.
|
||||
*/
|
||||
activity?: ToolActivity;
|
||||
/** Images attached to this question. Only ever non-empty on a user message. */
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
@@ -50,6 +50,33 @@ export function recencyGroup(timestamp: number, now: number = Date.now()): Recen
|
||||
return "Older";
|
||||
}
|
||||
|
||||
/**
|
||||
* A byte count for a person to read: "6.6 GB", "326 B".
|
||||
*
|
||||
* Decimal units, not the 1024-based ones a memory figure would use, because
|
||||
* that is what every other tool in this ecosystem prints: `ollama list` shows
|
||||
* 6550813657 bytes as 6.6 GB, and a number that disagreed with the terminal the
|
||||
* user just looked at would read as a bug in this extension.
|
||||
*
|
||||
* `undefined` for a size that is absent or nonsense, so a caller can leave the
|
||||
* field out rather than print "NaN B".
|
||||
*/
|
||||
export function formatBytes(bytes: number | undefined): string | undefined {
|
||||
if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return undefined;
|
||||
if (bytes < 1000) return `${Math.round(bytes)} B`;
|
||||
|
||||
const units = ["kB", "MB", "GB", "TB"];
|
||||
let value = bytes / 1000;
|
||||
let unit = 0;
|
||||
|
||||
while (value >= 1000 && unit < units.length - 1) {
|
||||
value /= 1000;
|
||||
unit += 1;
|
||||
}
|
||||
|
||||
return `${value.toFixed(1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
+7
-2
@@ -1,5 +1,6 @@
|
||||
import { LocalStorage } from "@vicinae/api";
|
||||
import { modelCapabilities, supportsVision } from "./ollama";
|
||||
import type { VisionSupport } from "./types";
|
||||
|
||||
/**
|
||||
* The cached answer to "can this model see images?".
|
||||
@@ -17,7 +18,11 @@ import { modelCapabilities, supportsVision } from "./ollama";
|
||||
/** One key per host and model, so switching model invalidates by construction. */
|
||||
const VISION_CACHE_PREFIX = "lam.vision.";
|
||||
|
||||
export type VisionSupport = "yes" | "no" | "unknown";
|
||||
// Re-exported so callers can keep naming the type from here, where it belongs
|
||||
// conceptually. The definition moved to `types.ts` so that `history.ts`, which
|
||||
// must not import a Vicinae module, can describe the same tri-state in the
|
||||
// system prompt.
|
||||
export type { VisionSupport };
|
||||
|
||||
export interface VisionCheck {
|
||||
host: string;
|
||||
@@ -61,7 +66,7 @@ export async function visionSupport(check: VisionCheck): Promise<VisionSupport>
|
||||
* Drops every cached answer.
|
||||
*
|
||||
* A model can gain vision without its name changing (a re-pull, a different
|
||||
* tag on the same account), so `Set Up Lam Agent` calls this rather than
|
||||
* tag on the same account), so `Setup & Status` calls this rather than
|
||||
* leaving the user pinned to a stale "no".
|
||||
*/
|
||||
export async function forgetVisionSupport(): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import {
|
||||
Action,
|
||||
ActionPanel,
|
||||
Alert,
|
||||
Form,
|
||||
Icon,
|
||||
List,
|
||||
Toast,
|
||||
confirmAlert,
|
||||
openExtensionPreferences,
|
||||
showToast,
|
||||
} from "@vicinae/api";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { usePreferences } from "./lib/hooks/usePreferences";
|
||||
import {
|
||||
deleteModel,
|
||||
describePullProgress,
|
||||
isSelfHosted,
|
||||
listModels,
|
||||
listRunningModels,
|
||||
pullModel,
|
||||
unloadModel,
|
||||
type RunningModel,
|
||||
} from "./lib/ollama";
|
||||
import { clearModelSelection, selectModel, type ResolvedPreferences } from "./lib/preferences";
|
||||
import type { ModelInfo } from "./lib/types";
|
||||
import { errorMessage, formatBytes } from "./lib/utils";
|
||||
|
||||
/**
|
||||
* Browse, pick, and manage the models the configured Ollama host can reach.
|
||||
*
|
||||
* Four jobs in one command, because they are four answers to the same question
|
||||
* ("what models do I have?"): pick one, unload one, delete one, pull a new one.
|
||||
* Unloading is the odd one out and the reason a row needs two states rather than
|
||||
* one: a model can be installed without being loaded, and only the loaded ones
|
||||
* are holding memory. Delete and pull only appear on a host the user runs, since
|
||||
* there is nothing to pull onto Ollama Cloud and nothing to delete from it.
|
||||
*
|
||||
* ## Where a pick is stored
|
||||
*
|
||||
* Not in the `Model` preference, because an extension cannot write its own
|
||||
* preferences. Vicinae reads them once at startup and holds them in memory, so
|
||||
* even writing its config file directly does nothing until it restarts, which was
|
||||
* measured rather than assumed (`providers["@adrianbonpin/lam-agent"].preferences`
|
||||
* in `~/.config/vicinae/settings.json`, edited while a command was then launched:
|
||||
* the requests still went to the old host).
|
||||
*
|
||||
* So a pick goes to LocalStorage and `loadPreferences` layers it over the
|
||||
* preference. The cost of that arrangement is one thing this screen has to say
|
||||
* out loud, because it is invisible otherwise: while a pick is active, editing
|
||||
* the `Model` preference has no effect until the pick is reset. Which is why the
|
||||
* setup screen shows both values and offers the reset.
|
||||
*
|
||||
* ## Why the pull keeps the window open
|
||||
*
|
||||
* The download runs inside this command's worker, so closing the launcher cancels
|
||||
* it. That is a real constraint and the command description says so.
|
||||
*/
|
||||
|
||||
/** Which surface the command is showing. The form is a view rather than a dialog, which Vicinae has no equivalent of. */
|
||||
type View = "list" | "pull";
|
||||
|
||||
export default function ModelsCommand() {
|
||||
const { preferences, reload } = usePreferences();
|
||||
|
||||
// Nothing renders until the model in use is known, because the whole list is
|
||||
// marked up relative to it: which row is current, and whether picking one
|
||||
// would change anything.
|
||||
if (!preferences) {
|
||||
return <List isLoading navigationTitle="Manage Models" searchBarPlaceholder="Loading models…" />;
|
||||
}
|
||||
|
||||
return <ModelsScreen preferences={preferences} reloadPreferences={reload} />;
|
||||
}
|
||||
|
||||
interface ModelsScreenProps {
|
||||
preferences: ResolvedPreferences;
|
||||
/** Re-reads preferences after this screen changes the model selection. */
|
||||
reloadPreferences: () => void;
|
||||
}
|
||||
|
||||
function ModelsScreen({ preferences, reloadPreferences }: ModelsScreenProps) {
|
||||
const [models, setModels] = useState<ModelInfo[] | undefined>(undefined);
|
||||
/** By name, so a row can ask whether it is the one holding memory. */
|
||||
const [running, setRunning] = useState<Map<string, RunningModel>>(new Map());
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [view, setView] = useState<View>("list");
|
||||
|
||||
/** Whether this host's models can be managed at all. */
|
||||
const selfHosted = isSelfHosted(preferences.host);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Both at once: what is installed and what is loaded are different
|
||||
// questions, and the second must not hold up the first.
|
||||
const [installed, loaded] = await Promise.all([
|
||||
listModels({ host: preferences.host, apiKey: preferences.apiKey }),
|
||||
listRunningModels({ host: preferences.host, apiKey: preferences.apiKey }),
|
||||
]);
|
||||
|
||||
setModels(installed);
|
||||
setRunning(new Map(loaded.map((model) => [model.name, model])));
|
||||
setError(undefined);
|
||||
} catch (thrown) {
|
||||
setModels([]);
|
||||
setRunning(new Map());
|
||||
setError(errorMessage(thrown));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [preferences.host, preferences.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
/**
|
||||
* Releases a model from memory.
|
||||
*
|
||||
* No confirmation, unlike deleting: nothing is removed from disk, and the
|
||||
* worst case is that the next question pays to load it again. The toast reports
|
||||
* what was freed, because that is the whole reason to do this.
|
||||
*/
|
||||
const unload = useCallback(
|
||||
async (model: ModelInfo, loaded: RunningModel) => {
|
||||
try {
|
||||
await unloadModel({ host: preferences.host, apiKey: preferences.apiKey, model: model.name });
|
||||
} catch (thrown) {
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
title: `Could not unload ${model.name}`,
|
||||
message: errorMessage(thrown),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const freed = formatBytes(loaded.sizeVram);
|
||||
await showToast({
|
||||
style: Toast.Style.Success,
|
||||
title: `Unloaded ${model.name}`,
|
||||
message: freed ? `${freed} of memory freed` : undefined,
|
||||
});
|
||||
|
||||
void load();
|
||||
},
|
||||
[load, preferences.host, preferences.apiKey],
|
||||
);
|
||||
|
||||
const choose = useCallback(
|
||||
async (name: string) => {
|
||||
if (name === preferences.model) {
|
||||
await showToast({ style: Toast.Style.Success, title: `${name} is already in use` });
|
||||
return;
|
||||
}
|
||||
|
||||
await selectModel(name);
|
||||
reloadPreferences();
|
||||
|
||||
await showToast({
|
||||
style: Toast.Style.Success,
|
||||
title: `Lam will use ${name}`,
|
||||
// Said here because it is the one surprising part of this screen: the
|
||||
// model is chosen in this command, not in Vicinae's settings.
|
||||
message: "Chosen here. The Model preference is left alone.",
|
||||
});
|
||||
},
|
||||
[preferences.model, reloadPreferences],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (model: ModelInfo) => {
|
||||
const confirmed = await confirmAlert({
|
||||
title: `Delete ${model.name}?`,
|
||||
message: model.remoteHost
|
||||
? `This removes the local reference. The model itself lives at ${model.remoteHost} and is not deleted there.`
|
||||
: "The weights are removed from this machine, and pulling it again means downloading all of it.",
|
||||
primaryAction: { title: "Delete", style: Alert.ActionStyle.Destructive },
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await deleteModel({ host: preferences.host, apiKey: preferences.apiKey, model: model.name });
|
||||
} catch (thrown) {
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
title: `Could not delete ${model.name}`,
|
||||
message: errorMessage(thrown),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// The model that just went may be the one in use, in which case the next
|
||||
// question asked would fail with a 404. A selection can be cleared here;
|
||||
// a preference cannot, so that case is reported instead of silently
|
||||
// leaving a dangling name behind.
|
||||
const wasInUse = model.name === preferences.model;
|
||||
const selected = preferences.modelSource === "selection";
|
||||
|
||||
if (wasInUse && selected) {
|
||||
await clearModelSelection();
|
||||
reloadPreferences();
|
||||
}
|
||||
|
||||
await showToast({
|
||||
style: Toast.Style.Success,
|
||||
title: `Deleted ${model.name}`,
|
||||
message: wasInUse
|
||||
? selected
|
||||
? "It was the model in use, so that choice has been cleared."
|
||||
: "It is still what the Model preference points at. Change it in preferences."
|
||||
: undefined,
|
||||
});
|
||||
|
||||
void load();
|
||||
},
|
||||
[load, preferences.host, preferences.apiKey, preferences.model, preferences.modelSource, reloadPreferences],
|
||||
);
|
||||
|
||||
if (view === "pull") {
|
||||
return (
|
||||
<PullModelForm
|
||||
host={preferences.host}
|
||||
apiKey={preferences.apiKey}
|
||||
onDone={() => {
|
||||
setView("list");
|
||||
void load();
|
||||
}}
|
||||
onCancel={() => setView("list")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const pullAction = selfHosted ? (
|
||||
<Action title="Pull a Model…" icon={Icon.Download} onAction={() => setView("pull")} />
|
||||
) : null;
|
||||
|
||||
const listActions = (
|
||||
<ActionPanel>
|
||||
<Action title="Refresh" icon={Icon.ArrowClockwise} onAction={() => void load()} />
|
||||
{pullAction ? <ActionPanel.Section>{pullAction}</ActionPanel.Section> : null}
|
||||
<ActionPanel.Section>
|
||||
<Action
|
||||
title="Open Extension Preferences"
|
||||
icon={Icon.BulletPoints}
|
||||
onAction={() => void openExtensionPreferences()}
|
||||
/>
|
||||
</ActionPanel.Section>
|
||||
</ActionPanel>
|
||||
);
|
||||
|
||||
const loaded = models ?? [];
|
||||
|
||||
return (
|
||||
<List
|
||||
isLoading={isLoading}
|
||||
navigationTitle="Manage Models"
|
||||
searchBarPlaceholder="Search models…"
|
||||
actions={listActions}
|
||||
>
|
||||
{error ? (
|
||||
<List.EmptyView
|
||||
icon={Icon.QuestionMarkCircle}
|
||||
title="Could not reach Ollama"
|
||||
description={error}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action title="Try Again" icon={Icon.ArrowClockwise} onAction={() => void load()} />
|
||||
<Action
|
||||
title="Open Extension Preferences"
|
||||
icon={Icon.BulletPoints}
|
||||
onAction={() => void openExtensionPreferences()}
|
||||
/>
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
) : models !== undefined && loaded.length === 0 ? (
|
||||
<List.EmptyView
|
||||
icon={Icon.Box}
|
||||
title={selfHosted ? "No models on this server" : "No models in this account"}
|
||||
description={
|
||||
selfHosted
|
||||
? "Pull one from the action panel, or with `ollama pull` in a terminal."
|
||||
: "This host has nothing installed, and models are managed at ollama.com."
|
||||
}
|
||||
actions={listActions}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{loaded.map((model) => {
|
||||
const isCurrent = model.name === preferences.model;
|
||||
const size = model.remoteHost ? undefined : formatBytes(model.size);
|
||||
const inMemory = running.get(model.name);
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
key={model.name}
|
||||
id={model.name}
|
||||
icon={isCurrent ? Icon.CheckCircle : Icon.Box}
|
||||
title={model.name}
|
||||
subtitle={[model.parameterSize, size].filter(Boolean).join(" · ") || undefined}
|
||||
accessories={[
|
||||
...(inMemory ? [{ tag: { value: "loaded", color: "green" as const } }] : []),
|
||||
...(isCurrent ? [{ tag: { value: "current", color: "blue" as const } }] : []),
|
||||
...(model.capabilities?.includes("vision") ? [{ tag: "vision" }] : []),
|
||||
{ tag: model.remoteHost ? "cloud" : selfHosted ? "local" : "cloud" },
|
||||
]}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action
|
||||
title={isCurrent ? "Current Model" : "Use This Model"}
|
||||
icon={isCurrent ? Icon.CheckCircle : Icon.Stars}
|
||||
onAction={() => void choose(model.name)}
|
||||
/>
|
||||
{inMemory ? (
|
||||
<Action
|
||||
title="Unload Model"
|
||||
icon={Icon.Eject}
|
||||
onAction={() => void unload(model, inMemory)}
|
||||
/>
|
||||
) : null}
|
||||
<Action.CopyToClipboard title="Copy Model Name" content={model.name} />
|
||||
{selfHosted ? (
|
||||
<Action
|
||||
title="Delete Model"
|
||||
icon={Icon.Trash}
|
||||
style="destructive"
|
||||
onAction={() => void remove(model)}
|
||||
/>
|
||||
) : null}
|
||||
<ActionPanel.Section>
|
||||
<Action title="Refresh" icon={Icon.ArrowClockwise} onAction={() => void load()} />
|
||||
{pullAction}
|
||||
<Action
|
||||
title="Open Extension Preferences"
|
||||
icon={Icon.BulletPoints}
|
||||
onAction={() => void openExtensionPreferences()}
|
||||
/>
|
||||
</ActionPanel.Section>
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
interface PullModelFormProps {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a model by name.
|
||||
*
|
||||
* A `Form` rather than a `List`, because the model being pulled is usually not
|
||||
* one of the models already installed, which is the whole reason for pulling it.
|
||||
* A picker over the library would be nicer and needs an endpoint this API does
|
||||
* not expose, so the name is typed and the description carries a few examples.
|
||||
*/
|
||||
function PullModelForm({ host, apiKey, onDone, onCancel }: PullModelFormProps) {
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
|
||||
const pull = useCallback(
|
||||
async (values: Form.Values) => {
|
||||
const name = String(values.model ?? "").trim();
|
||||
|
||||
if (!name) {
|
||||
await showToast({ style: Toast.Style.Failure, title: "Give the model a name" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPulling(true);
|
||||
const toast = await showToast({ style: Toast.Style.Animated, title: `Pulling ${name}` });
|
||||
|
||||
try {
|
||||
let last = "";
|
||||
|
||||
for await (const progress of pullModel({ host, apiKey, model: name })) {
|
||||
// Ollama sends a tick per chunk, which for a large model is far more
|
||||
// often than the toast needs updating. Only write when the line
|
||||
// actually changed, so the percentage is what limits the traffic.
|
||||
const line = describePullProgress(progress);
|
||||
if (line !== last) {
|
||||
last = line;
|
||||
toast.message = line;
|
||||
}
|
||||
}
|
||||
|
||||
toast.style = Toast.Style.Success;
|
||||
toast.title = `Pulled ${name}`;
|
||||
toast.message = "It is in the list now";
|
||||
onDone();
|
||||
} catch (thrown) {
|
||||
toast.style = Toast.Style.Failure;
|
||||
toast.title = `Could not pull ${name}`;
|
||||
toast.message = errorMessage(thrown);
|
||||
} finally {
|
||||
setIsPulling(false);
|
||||
}
|
||||
},
|
||||
[apiKey, host, onDone],
|
||||
);
|
||||
|
||||
return (
|
||||
<Form
|
||||
isLoading={isPulling}
|
||||
navigationTitle="Pull a Model"
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.SubmitForm title="Pull Model" icon={Icon.Download} onSubmit={pull} />
|
||||
<Action title="Cancel" icon={Icon.Xmark} onAction={onCancel} />
|
||||
</ActionPanel>
|
||||
}
|
||||
>
|
||||
<Form.TextField
|
||||
id="model"
|
||||
title="Model"
|
||||
placeholder="qwen3:8b"
|
||||
autoFocus
|
||||
info="The name as it appears in the Ollama library, including the tag. Leave the tag off to get the default one."
|
||||
/>
|
||||
<Form.Description
|
||||
title="Examples"
|
||||
text={["qwen3:8b", "llama3.2:3b", "gemma3:12b", "deepseek-r1:14b"].join("\n")}
|
||||
/>
|
||||
<Form.Description
|
||||
title="While it pulls"
|
||||
text="Leave the launcher open. The download runs in this window, so closing it cancels the pull."
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
+237
-14
@@ -1,7 +1,27 @@
|
||||
import { Action, ActionPanel, Icon, List, openExtensionPreferences } from "@vicinae/api";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { describeResumeTimeout, getLamPreferences, needsSetup } from "./lib/preferences";
|
||||
import {
|
||||
Action,
|
||||
ActionPanel,
|
||||
Alert,
|
||||
Icon,
|
||||
List,
|
||||
Toast,
|
||||
confirmAlert,
|
||||
openExtensionPreferences,
|
||||
showToast,
|
||||
} from "@vicinae/api";
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { clearAllChats, clearOrphanedSnips, runAutoDelete } from "./lib/cleanup";
|
||||
import { usePreferences } from "./lib/hooks/usePreferences";
|
||||
import {
|
||||
clearModelSelection,
|
||||
describeAutoDelete,
|
||||
describeMaxToolRounds,
|
||||
describeResumeTimeout,
|
||||
needsSetup,
|
||||
type ResolvedPreferences,
|
||||
} from "./lib/preferences";
|
||||
import { listModels } from "./lib/ollama";
|
||||
import { forgetGeoPlace } from "./lib/geo";
|
||||
import { forgetVisionSupport, visionSupport, type VisionSupport } from "./lib/vision";
|
||||
import type { ModelInfo } from "./lib/types";
|
||||
import { errorMessage } from "./lib/utils";
|
||||
@@ -32,7 +52,27 @@ function maskKey(apiKey: string): string {
|
||||
}
|
||||
|
||||
export default function SetupCommand() {
|
||||
const preferences = useMemo(() => getLamPreferences(), []);
|
||||
const { preferences, reload } = usePreferences();
|
||||
|
||||
// The screen does nothing until the model is known, because half of what it
|
||||
// reports is about that model: whether it can see images, and whether it is the
|
||||
// one from preferences or from the Manage Models command. Rendering it with the
|
||||
// preference value and correcting it a moment later would flash a wrong answer
|
||||
// at the user.
|
||||
if (!preferences) {
|
||||
return <List isLoading navigationTitle="Setup & Status" searchBarPlaceholder="Loading preferences…" />;
|
||||
}
|
||||
|
||||
return <SetupScreen preferences={preferences} reloadPreferences={reload} />;
|
||||
}
|
||||
|
||||
interface SetupScreenProps {
|
||||
preferences: ResolvedPreferences;
|
||||
/** Re-reads preferences after this screen changes the model selection. */
|
||||
reloadPreferences: () => void;
|
||||
}
|
||||
|
||||
function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) {
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -60,6 +100,10 @@ export default function SetupCommand() {
|
||||
// re-pull, a retagged build), so re-testing drops the cached answer that the
|
||||
// chat view gates the snip action on rather than leaving a stale "no".
|
||||
await forgetVisionSupport();
|
||||
|
||||
// Same reasoning for the IP-derived place: a re-test is the moment to stop
|
||||
// trusting an answer that was resolved hours ago, possibly in another city.
|
||||
await forgetGeoPlace();
|
||||
setVision(
|
||||
await visionSupport({
|
||||
host: preferences.host,
|
||||
@@ -76,18 +120,77 @@ export default function SetupCommand() {
|
||||
if (!missingKey) void testConnection();
|
||||
}, [missingKey, testConnection]);
|
||||
|
||||
// Opening any Lam command is a moment to collect expired conversations, and
|
||||
// this is one of them. Nothing is open here, so nothing is exempt.
|
||||
useEffect(() => {
|
||||
void runAutoDelete({ maxAgeMs: preferences.autoDeleteMs });
|
||||
}, [preferences.autoDeleteMs]);
|
||||
|
||||
const openPreferences = useCallback(() => {
|
||||
void openExtensionPreferences();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Hands the model back to the `Model` preference.
|
||||
*
|
||||
* The escape hatch for the one cost of keeping a selection: while one is set,
|
||||
* editing the preference has no effect, and the only way to notice is this
|
||||
* screen. Confirmed rather than immediate because it is a change the user
|
||||
* cannot see the result of until the next question they ask.
|
||||
*/
|
||||
const resetModel = useCallback(async () => {
|
||||
await clearModelSelection();
|
||||
reloadPreferences();
|
||||
|
||||
await showToast({
|
||||
style: Toast.Style.Success,
|
||||
title: "Model reset to the preference",
|
||||
message: preferences.preferenceModel,
|
||||
});
|
||||
}, [reloadPreferences, preferences.preferenceModel]);
|
||||
|
||||
/**
|
||||
* Confirms, then runs a destructive tidy-up.
|
||||
*
|
||||
* Both actions on this screen delete things that cannot be recovered: there is
|
||||
* no trash to undo them from, and a conversation's screenshots go with it. That
|
||||
* is worth one extra keystroke, and the alert says what will go rather than
|
||||
* asking "are you sure" about nothing in particular.
|
||||
*/
|
||||
const confirmThen = useCallback(
|
||||
async (options: { title: string; message: string }, run: () => Promise<void>) => {
|
||||
const confirmed = await confirmAlert({
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
primaryAction: { title: "Delete", style: Alert.ActionStyle.Destructive },
|
||||
});
|
||||
|
||||
if (confirmed) await run();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* The action panel for a maintenance row.
|
||||
*
|
||||
* The row's own action comes first, because it is why the user is looking at
|
||||
* the row, and the two read-only actions sit in their own section below so they
|
||||
* can never be the default that Return fires.
|
||||
*/
|
||||
const maintenanceActions = (primary: ReactNode) => (
|
||||
<ActionPanel>
|
||||
{primary}
|
||||
<ActionPanel.Section>
|
||||
<Action title="Open Extension Preferences" icon={Icon.BulletPoints} onAction={openPreferences} />
|
||||
<Action title="Test Connection" icon={Icon.ArrowClockwise} onAction={() => void testConnection()} />
|
||||
</ActionPanel.Section>
|
||||
</ActionPanel>
|
||||
);
|
||||
|
||||
const settingsActions = (
|
||||
<ActionPanel>
|
||||
<Action title="Open Extension Preferences" icon={Icon.BulletPoints} onAction={openPreferences} />
|
||||
<Action
|
||||
title="Test Connection"
|
||||
icon={Icon.ArrowClockwise}
|
||||
onAction={() => void testConnection()}
|
||||
/>
|
||||
<Action title="Test Connection" icon={Icon.ArrowClockwise} onAction={() => void testConnection()} />
|
||||
</ActionPanel>
|
||||
);
|
||||
|
||||
@@ -104,7 +207,7 @@ export default function SetupCommand() {
|
||||
return (
|
||||
<List
|
||||
isLoading={isLoading}
|
||||
navigationTitle="Lam Agent Setup"
|
||||
navigationTitle="Setup & Status"
|
||||
searchBarPlaceholder="Search models…"
|
||||
actions={settingsActions}
|
||||
>
|
||||
@@ -131,8 +234,36 @@ export default function SetupCommand() {
|
||||
<List.Item
|
||||
icon={Icon.Stars}
|
||||
title="Model"
|
||||
subtitle={preferences.model}
|
||||
actions={settingsActions}
|
||||
subtitle={
|
||||
preferences.modelSource === "selection"
|
||||
? `${preferences.model} (chosen in Models; the preference says ${preferences.preferenceModel})`
|
||||
: preferences.model
|
||||
}
|
||||
actions={
|
||||
preferences.modelSource === "selection" ? (
|
||||
<ActionPanel>
|
||||
<Action
|
||||
title="Reset to the Preference"
|
||||
icon={Icon.ArrowCounterClockwise}
|
||||
onAction={() => void resetModel()}
|
||||
/>
|
||||
<ActionPanel.Section>
|
||||
<Action
|
||||
title="Open Extension Preferences"
|
||||
icon={Icon.BulletPoints}
|
||||
onAction={openPreferences}
|
||||
/>
|
||||
<Action
|
||||
title="Test Connection"
|
||||
icon={Icon.ArrowClockwise}
|
||||
onAction={() => void testConnection()}
|
||||
/>
|
||||
</ActionPanel.Section>
|
||||
</ActionPanel>
|
||||
) : (
|
||||
settingsActions
|
||||
)
|
||||
}
|
||||
/>
|
||||
<List.Item
|
||||
icon={Icon.BulletPoints}
|
||||
@@ -140,6 +271,12 @@ export default function SetupCommand() {
|
||||
subtitle={preferences.webEnabled ? "Enabled" : "Disabled"}
|
||||
actions={settingsActions}
|
||||
/>
|
||||
<List.Item
|
||||
icon={Icon.Folder}
|
||||
title="File Access"
|
||||
subtitle={preferences.fileEnabled ? "Enabled" : "Disabled"}
|
||||
actions={settingsActions}
|
||||
/>
|
||||
<List.Item
|
||||
icon={Icon.Clock}
|
||||
title="Reasoning Effort"
|
||||
@@ -164,6 +301,18 @@ export default function SetupCommand() {
|
||||
subtitle={describeResumeTimeout(preferences.resumeTimeoutMs)}
|
||||
actions={settingsActions}
|
||||
/>
|
||||
<List.Item
|
||||
icon={Icon.Trash}
|
||||
title="Delete Old Chats After"
|
||||
subtitle={describeAutoDelete(preferences.autoDeleteMs)}
|
||||
actions={settingsActions}
|
||||
/>
|
||||
<List.Item
|
||||
icon={Icon.BulletPoints}
|
||||
title="Tool Call Limit"
|
||||
subtitle={describeMaxToolRounds(preferences.maxToolRounds)}
|
||||
actions={settingsActions}
|
||||
/>
|
||||
<List.Item
|
||||
icon={vision === "yes" ? Icon.Image : Icon.QuestionMarkCircle}
|
||||
title="Image Input"
|
||||
@@ -176,6 +325,72 @@ export default function SetupCommand() {
|
||||
}
|
||||
actions={settingsActions}
|
||||
/>
|
||||
<List.Item
|
||||
icon={preferences.place || preferences.ipGeolocation ? Icon.Person : Icon.QuestionMarkCircle}
|
||||
title="Location"
|
||||
subtitle={
|
||||
preferences.place
|
||||
? `${preferences.place} (from preferences)`
|
||||
: preferences.ipGeolocation
|
||||
? "Rough area, resolved from the IP address via ipwho.is"
|
||||
: "Region only, from the timezone and locale"
|
||||
}
|
||||
actions={settingsActions}
|
||||
/>
|
||||
</List.Section>
|
||||
|
||||
{/*
|
||||
* Maintenance sits below the settings it acts on. Both rows are destructive
|
||||
* rather than settings, so each builds its own action panel instead of
|
||||
* sharing the one above: these are the only two things on this screen that
|
||||
* delete anything, and neither should be one keystroke away from a row the
|
||||
* user was only reading.
|
||||
*/}
|
||||
<List.Section title="Maintenance">
|
||||
<List.Item
|
||||
icon={Icon.Image}
|
||||
title="Clear Out Unattached Photos"
|
||||
subtitle="Delete captures no conversation refers to any more. Screenshots still attached to a chat are kept, and so is one snipped in the last day."
|
||||
actions={maintenanceActions(
|
||||
<Action
|
||||
title="Clear Unattached Photos"
|
||||
icon={Icon.Image}
|
||||
style="destructive"
|
||||
onAction={() =>
|
||||
void confirmThen(
|
||||
{
|
||||
title: "Delete unattached photos?",
|
||||
message:
|
||||
"Screenshots that belong to no conversation will be deleted. Photos still attached to a chat are kept.",
|
||||
},
|
||||
clearOrphanedSnips,
|
||||
)
|
||||
}
|
||||
/>,
|
||||
)}
|
||||
/>
|
||||
<List.Item
|
||||
icon={Icon.Trash}
|
||||
title="Clear Out All Chats"
|
||||
subtitle="Delete every conversation, and every screenshot it captured. This cannot be undone."
|
||||
actions={maintenanceActions(
|
||||
<Action
|
||||
title="Clear All Chats"
|
||||
icon={Icon.Trash}
|
||||
style="destructive"
|
||||
onAction={() =>
|
||||
void confirmThen(
|
||||
{
|
||||
title: "Delete every conversation?",
|
||||
message:
|
||||
"Every conversation and every screenshot it captured will be deleted. This cannot be undone.",
|
||||
},
|
||||
clearAllChats,
|
||||
)
|
||||
}
|
||||
/>,
|
||||
)}
|
||||
/>
|
||||
</List.Section>
|
||||
|
||||
{error ? (
|
||||
@@ -197,7 +412,11 @@ export default function SetupCommand() {
|
||||
subtitle="Create one at ollama.com/settings/keys, then paste it into the extension preferences"
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action title="Open Extension Preferences" icon={Icon.BulletPoints} onAction={openPreferences} />
|
||||
<Action
|
||||
title="Open Extension Preferences"
|
||||
icon={Icon.BulletPoints}
|
||||
onAction={openPreferences}
|
||||
/>
|
||||
<Action.OpenInBrowser title="Create an API Key" url="https://ollama.com/settings/keys" />
|
||||
</ActionPanel>
|
||||
}
|
||||
@@ -218,7 +437,11 @@ export default function SetupCommand() {
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.CopyToClipboard title="Copy Model Name" content={model.name} />
|
||||
<Action title="Open Extension Preferences" icon={Icon.BulletPoints} onAction={openPreferences} />
|
||||
<Action
|
||||
title="Open Extension Preferences"
|
||||
icon={Icon.BulletPoints}
|
||||
onAction={openPreferences}
|
||||
/>
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
import { LaunchType, closeMainWindow, launchCommand, showHUD, type LaunchProps } from "@vicinae/api";
|
||||
import { captureRegion } from "./lib/capture";
|
||||
import { getLamPreferences } from "./lib/preferences";
|
||||
import { loadPreferences } from "./lib/preferences";
|
||||
import { describeCaptureFailure, readSnipContext, snipDirectory } from "./lib/snip";
|
||||
import { visionSupport } from "./lib/vision";
|
||||
|
||||
@@ -34,7 +34,10 @@ function settleWindow(): Promise<void> {
|
||||
* Bind it to a global shortcut for one-key "snip and ask".
|
||||
*/
|
||||
export default async function SnipCommand(props: LaunchProps) {
|
||||
const preferences = getLamPreferences();
|
||||
// Awaited because the model can come from a pick in the Manage Models command, which
|
||||
// lives in storage. The vision check below is about whichever model is in use,
|
||||
// so it has to be the resolved one.
|
||||
const preferences = await loadPreferences();
|
||||
const context = readSnipContext(props.launchContext);
|
||||
|
||||
// Before anything else. Everything below is pointless if the selector is
|
||||
@@ -56,7 +59,7 @@ export default async function SnipCommand(props: LaunchProps) {
|
||||
await showHUD(
|
||||
vision === "no"
|
||||
? `${preferences.model} cannot see images. Pick a vision model in preferences.`
|
||||
: `Could not check whether ${preferences.model} can see images. Run Set Up Lam Agent.`,
|
||||
: `Could not check whether ${preferences.model} can see images. Run Setup & Status.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,17 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
/**
|
||||
* One file at a time.
|
||||
*
|
||||
* The retention and snip tests exercise the real file system, and the snip
|
||||
* directory is a single fixed path taken from the stubbed `environment`
|
||||
* (`/tmp/lam-agent-support/snips`). Two files running at once would each
|
||||
* clear and repopulate the other's directory mid-test, so files are run
|
||||
* sequentially rather than giving every file its own path to reason about.
|
||||
* The whole suite is well under a second either way.
|
||||
*/
|
||||
fileParallelism: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user