Compare commits

..
10 Commits
Author SHA1 Message Date
adrianbonpin 58ed293716 docs: describe source citations and why the window cannot draw a footnote 2026-10-07 16:12:23 +08:00
adrianbonpin e1e44067d9 feat: ask the model to cite the sources it was given 2026-10-07 16:12:23 +08:00
adrianbonpin 7d7040353a feat: list an answer's sources under it, and keep them when copying
The list belongs to the answer rather than to the tool line, so it is drawn with
tool calls hidden too. It waits for the answer to cite something before appearing,
because otherwise it arrives in full with the first token and then shrinks as
citations arrive. A copied conversation carries the same list, worked out from a
plain scan: it is markdown on its way somewhere else, where a [1] inside a snippet
counting as a citation costs nothing.
2026-10-07 16:11:58 +08:00
adrianbonpin c767595a76 docs: correct the pipeline order in the plan 2026-10-07 16:11:19 +08:00
adrianbonpin 41de7e1cc6 feat: link citation markers in the render pipeline
renderModelOutput now takes the turn's sources and reports back which numbers it
linked, so the list has one source of truth for what the answer actually cited.

Maths runs before citations, not after. A link label has to escape its brackets,
and that escaped form is exactly LaTeX's own display delimiter, so converting the
model's maths first is what stops the maths pass from eating a marker and handing
back a bare 1.
2026-10-07 16:11:18 +08:00
adrianbonpin 1a8e767fbe feat: number a turn's sources once and store them with the records
runAgent owns the list, so every call in one turn shares its numbering, and each
record carries only the entries its own call added: the turn's whole list is the
flattening of its records. The schema accepts the field as optional, because zod
drops unknown keys and a reopened conversation would otherwise lose every link.
2026-10-07 16:10:01 +08:00
adrianbonpin 5d7eec53dc feat: print a stable source number in the web tool results
Each result now carries the number the model cites, assigned from the turn's
list rather than from its position in one call, so three searches in a turn no
longer produce three runs of 1. The entries a call added come back beside the
content, for the transcript to store and draw.
2026-10-07 16:09:30 +08:00
adrianbonpin 56c48a5b03 feat: number, link and list the sources a turn consults
One number per page per turn, assigned where the tools run so the model and
the reader agree on what [3] means. The module also owns the marker pass and
the list under the answer, because Vicinae cannot draw a footnote: it parses
markdown with CMARK_OPT_DEFAULT and attaches only the table and strikethrough
extensions, so [^1] arrives as literal text.

A marker stays text unless every number in it is a source with a destination
this window should open, and a footnote definition for a source we already
list is dropped while a markdown reference definition is left alone.
2026-10-07 16:08:52 +08:00
adrianbonpin 27ee7206b6 docs: implementation plan for source citations 2026-10-07 16:07:59 +08:00
adrianbonpin a4bc49c238 docs: spec for source citations with footnotes in the transcript 2026-10-07 16:06:31 +08:00
31 changed files with 113 additions and 5663 deletions
+20 -64
View File
@@ -17,14 +17,13 @@ Hit a global shortcut, type a question, get a streamed answer without leaving wh
- **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.
- **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).
- **Tools.** Lam decides when to call them: current time, location context, web search, and page fetching. A turn that uses up its tool budget still ends in an answer, written from what it gathered, rather than a notice that it stopped.
- **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 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.
- **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.
## Requirements
@@ -68,12 +67,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`. 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.
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 **Ollama Models** afterwards and pick the real one from the list.
3. **`Set Up Lam Agent`** 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, run **Manage Models** and pick one, or edit the **Model** preference directly.
To change the model later, run **Ollama Models** and pick one, or edit the **Model** preference directly.
> 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.
> An extension can read preferences but not write them, which is why a pick made in **Ollama 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
@@ -83,7 +82,7 @@ To change the model later, run **Manage Models** and pick one, or edit the **Mod
| `↩` 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 History**) |
| `⌘ ⇧ ⌫` | Delete the highlighted conversation (also in **Chat Sessions**) |
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.
@@ -118,24 +117,21 @@ 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 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. |
| **Chat Sessions** | Browse, reopen, and delete past conversations. |
| **Ollama 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). |
| **Set Up Lam Agent** | 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. **Manage Models** can pick one for you, and a pick takes precedence over this value until you reset it from the setup screen. |
| **Model** | None | Required. Any model your key can reach. **Ollama 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 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. |
@@ -145,7 +141,7 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
## 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.
**Ollama 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 |
| --- | --- |
@@ -192,7 +188,7 @@ The download runs inside this command's worker, so **closing the launcher cancel
## Tools
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.
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.
| Tool | What it does |
| --- | --- |
@@ -211,7 +207,7 @@ If you turn on **IP Geolocation**, and only then, `get_location` resolves a roug
**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.
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. `Set Up Lam Agent` 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".
@@ -237,38 +233,6 @@ When tool calls are off, nothing is listed. A call that failed, or that was cut
**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.
@@ -287,7 +251,7 @@ The index entry for a conversation carries its image paths (`SessionSummary.atta
### Doing it by hand
**Setup & Status** has a Maintenance section with the same two jobs as explicit actions:
**Set Up Lam Agent** has a Maintenance section with the same two jobs as explicit actions:
| Action | What it deletes |
| --- | --- |
@@ -336,12 +300,6 @@ The whole pass is plain TypeScript with no Vicinae imports, so it unit tests wit
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.
@@ -352,8 +310,8 @@ The list shows the sources the answer cited, falling back to everything the turn
src/
chat.tsx # "chat" command entrypoint, thin wrapper
snip.tsx # "snip" command entrypoint, no-view: captures then hands over
manage-models.tsx # "manage-models" command: browse, pick, pull, delete
chat-history.tsx # "chat-history" command: browse, reopen, delete conversations
models.tsx # "models" command: browse, pick, pull, delete
sessions.tsx # browse, reopen, delete conversations
setup.tsx # connection check + live model list
lib/
ollama.ts # the only file that talks HTTP
@@ -366,8 +324,6 @@ src/
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
@@ -423,7 +379,7 @@ The rendered height stays capped at 200px whatever is asked for (`Math.min(_rawH
**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.
**Preferences have exactly one reader.** `loadPreferences` applies the model chosen in **Ollama 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.
@@ -441,7 +397,7 @@ 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.
**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 Models command picks names from the list rather than taking them typed.
## Development
@@ -456,7 +412,7 @@ npm test
## Roadmap
- **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.
- **Actions.** Opening links, clipboard access, launching apps. These need an approval step first. An assistant that can silently act on your machine is a different risk profile from one that answers questions, and this release does not pretend otherwise.
- **`@` mentions** for files and clipboard content.
- **Per-conversation model** overrides.
- **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
@@ -1,406 +0,0 @@
# 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.
@@ -57,31 +57,19 @@ would have been asked are recorded here instead.
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.
- **A2. Numbers are never renumbered.** Contiguous numbering from 1 in citation
order is the academic convention, but it would make the transcript disagree
with the model's own reasoning text and with the tool output a curious reader
can inspect. Keeping the model's numbers means one number means one thing
everywhere in the turn.
- **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.
- **A5. The marker is `[1]`, not a superscript.** A superscript inside a link is
a smaller target and, at this pane width, harder to read than the bracket form
that is already the convention in most model output.
## Design
@@ -164,12 +152,7 @@ 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.
- **`[n]`, `[^n]` and comma lists** (`[1, 3]`) are recognised.
- **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.
+10 -31
View File
@@ -11,31 +11,34 @@
{
"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": "chat-history",
"title": "Chat History",
"name": "sessions",
"title": "Chat Sessions",
"description": "Browse, reopen, and clean up your past conversations with Lam.",
"mode": "view"
},
{
"name": "manage-models",
"title": "Manage Models",
"name": "models",
"title": "Ollama Models",
"subtitle": "Lam Agent",
"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": "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.",
"title": "Set Up Lam Agent",
"description": "Check your Ollama connection, pick the model Lam should use, and tune how Lam answers.",
"mode": "view"
}
],
@@ -50,7 +53,7 @@
{
"name": "model",
"title": "Model",
"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.",
"description": "The model Lam should use, for example deepseek-v4.1-flash:cloud or gpt-oss:120b. Run \"Set Up Lam Agent\" to pick from the models your key can access.",
"type": "textfield",
"required": true
},
@@ -170,30 +173,6 @@
{ "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",
-125
View File
@@ -1,125 +0,0 @@
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);
});
});
+10 -49
View File
@@ -116,21 +116,23 @@ describe("linkCitations", () => {
{ index: 3, title: "Third", url: "https://third.example" },
];
it("raises a marker into a superscript link", () => {
it("turns a marker into a link whose label is the marker", () => {
const result = linkCitations("Rain is likely [1].", sources);
expect(result.text).toBe("Rain is likely [¹](https://first.example).");
expect(result.text).toBe("Rain is likely [\\[1\\]](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).");
expect(linkCitations("Rain [^1].", sources).text).toBe("Rain [\\[1\\]](https://first.example).");
});
it("separates markers written as one list", () => {
it("links a list of markers and keeps the comma", () => {
const result = linkCitations("Both [1, 3] agree.", sources);
expect(result.text).toBe("Both [¹](https://first.example) [³](https://third.example) agree.");
expect(result.text).toBe(
"Both [\\[1\\]](https://first.example), [\\[3\\]](https://third.example) agree.",
);
expect([...result.cited].sort()).toEqual([1, 3]);
});
@@ -143,47 +145,6 @@ describe("linkCitations", () => {
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);
@@ -210,7 +171,7 @@ describe("linkCitations", () => {
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");
expect(linkCitations(answer, sources).text).toBe("Rain [\\[1\\]](https://first.example).\n");
});
it("keeps a markdown reference definition, which backs a link the model wrote", () => {
@@ -218,14 +179,14 @@ describe("linkCitations", () => {
// 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",
"Rain [\\[1\\]](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",
"Rain [\\[1\\]](https://first.example).\n\n[^7]: Something else",
);
});
-607
View File
@@ -1,607 +0,0 @@
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);
});
});
+1 -53
View File
@@ -4,16 +4,7 @@ import { buildMessages, composeSystemPrompt, describeRuntime, type RuntimeContex
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", () => {
@@ -188,46 +179,3 @@ 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.");
});
});
+1 -1
View File
@@ -675,7 +675,7 @@ describe("sources", () => {
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("Rain is likely [\\[1\\]](https://first.example).");
expect(text).toContain("_Sources_");
expect(text).toContain("- [\\[1\\]](https://first.example) First");
// The answer did not lean on the second one.
-34
View File
@@ -296,37 +296,3 @@ describe("the model selection", () => {
});
});
});
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([]);
});
});
+3 -3
View File
@@ -145,13 +145,13 @@ describe("citations in the pipeline", () => {
it("reports which citations it linked", () => {
const result = renderModelOutput("Rain [1].", { sources });
expect(result.text).toBe("Rain [¹](https://first.example).");
expect(result.text).toBe("Rain [\\[1\\]](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)",
"x² be [\\[1\\]](https://first.example)",
);
});
@@ -159,7 +159,7 @@ describe("citations in the pipeline", () => {
const text = rendered("```bash\ncurl [1]\n```\n\nSee [1].", sources);
expect(text).toContain("curl [1]");
expect(text).toContain("[¹](https://first.example).");
expect(text).toContain("[\\[1\\]](https://first.example).");
});
it("changes nothing when no sources are given", () => {
+1 -1
View File
@@ -191,7 +191,7 @@ describe("resolveStartup", () => {
});
it("does not resume when a session was pushed in by hand", async () => {
// The Chat History command already knows which conversation to show.
// The sessions command already knows which conversation to show.
const p = ports();
await resolveStartup(input({ hasInitialSession: true }), p);
+3 -157
View File
@@ -1,10 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
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";
import { TOOL_DEFINITIONS, WEB_TOOL_NAMES, executeTool, toolsFor } from "../lib/tools";
/**
* Direct coverage for the tool layer.
@@ -44,12 +40,10 @@ afterEach(() => {
});
describe("tool registration", () => {
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.
it("exposes all four tools to the model", () => {
const names = TOOL_DEFINITIONS.map((tool) => tool.function.name);
expect(names.slice(0, 4)).toEqual(["get_datetime", "get_location", "web_search", "web_fetch"]);
expect(names).toEqual(["get_datetime", "get_location", "web_search", "web_fetch"]);
});
it("marks both web tools as cloud-dependent", () => {
@@ -475,151 +469,3 @@ 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 -1
View File
@@ -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 Chat History command.
* also be pushed onto the navigation stack from the sessions command.
*
* Assign this command a global shortcut in Vicinae's settings to get the
* launcher-and-start-typing behaviour. `fallbackText` is populated when the
-4
View File
@@ -249,10 +249,6 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
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 = {
-93
View File
@@ -1,93 +0,0 @@
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 },
});
}
+8 -56
View File
@@ -104,46 +104,13 @@ export interface CitationResult {
}
/**
* 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.
* A citation marker the model wrote, in the two shapes it reaches for.
*
* 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.
* `[1, 3]`, which is how a sentence resting on two sources is usually 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;
}
const CITATION_MARKER = /\[\^?(\d+(?:\s*,\s*\d+)*)\](?![(:])/g;
/**
* A footnote definition line, which is `[^1]: title, url` on a line of its own.
@@ -198,8 +165,7 @@ function stripDefinitions(text: string, known: Set<number>): string {
}
/**
* `[1]`, `[^1]`, `[1, 3]` and `[1][2]` as superscript links, ordered ascending and
* each page once, plus the numbers that became links.
* `[1]`, `[^1]` and `[1, 3]` as links, 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
@@ -213,18 +179,8 @@ export function linkCitations(text: string, sources: Source[]): CitationResult {
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);
chunk.replace(CITATION_MARKER, (whole: string, digits: string) => {
const wanted = digits.split(",").map((part) => Number(part.trim()));
const links: string[] = [];
for (const index of wanted) {
@@ -234,15 +190,11 @@ export function linkCitations(text: string, sources: Source[]): CitationResult {
// marker that is partly a link reads as a citation that works.
if (!href) return whole;
links.push(markdownLink(superscript(index), href));
links.push(markdownLink(`[${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 links.join(", ");
}),
);
+2 -30
View File
@@ -16,7 +16,7 @@ export const STORAGE_INDEX_KEY = "lam.sessions.index";
export const STORAGE_SESSION_PREFIX = "lam.session.";
/**
* The model picked in the Manage Models command.
* The model picked in the 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
@@ -96,7 +96,7 @@ 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.
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, cite one number at a time when a sentence rests on two of them (write [1][2], not [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.
@@ -107,31 +107,3 @@ 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.";
-821
View File
@@ -1,821 +0,0 @@
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}.`,
};
}
+4 -35
View File
@@ -1,4 +1,4 @@
import { DEFAULT_SYSTEM_PROMPT, FILE_TOOL_GUIDANCE, RECENCY_HINT } from "./constants";
import { DEFAULT_SYSTEM_PROMPT, RECENCY_HINT } from "./constants";
import type { ApiMessage, ChatTurn, VisionSupport } from "./types";
/**
@@ -51,15 +51,6 @@ export interface RuntimeContext {
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[] };
}
/**
@@ -84,12 +75,7 @@ export function describeRuntime(context: RuntimeContext): string {
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.",
"- 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) {
@@ -104,17 +90,6 @@ export function describeRuntime(context: RuntimeContext): string {
: "- 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."
@@ -132,16 +107,10 @@ export function describeRuntime(context: RuntimeContext): string {
* 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 {
*/
export function composeSystemPrompt(customPrompt: string, context: RuntimeContext): string {
const parts = [customPrompt.trim() || DEFAULT_SYSTEM_PROMPT];
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]}`;
}
+2 -26
View File
@@ -1,9 +1,6 @@
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";
@@ -36,7 +33,7 @@ const FLUSH_INTERVAL_MS = 60;
export interface UseChatOptions {
preferences: ResolvedPreferences;
/** Restores an existing conversation, e.g. when reopening from the Chat History command. */
/** Restores an existing conversation, e.g. when reopening from the sessions command. */
initialSession?: Session;
/**
* Whether the model can read images, for the system prompt.
@@ -208,18 +205,6 @@ 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, {
model: preferences.model,
@@ -227,11 +212,6 @@ export function useChat(options: UseChatOptions): UseChat {
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,
@@ -253,10 +233,7 @@ export function useChat(options: UseChatOptions): UseChat {
// cap, so the two conventions meet here.
maxToolRounds: preferences.maxToolRounds ?? null,
messages,
tools: toolsFor({
webEnabled: preferences.webEnabled,
fileEnabled: preferences.fileEnabled,
}),
tools: toolsFor({ webEnabled: preferences.webEnabled }),
toolContext: {
host: preferences.host,
apiKey: preferences.apiKey,
@@ -264,7 +241,6 @@ export function useChat(options: UseChatOptions): UseChat {
// 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: {
+1 -1
View File
@@ -5,7 +5,7 @@ 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
* the 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
+3 -30
View File
@@ -1,6 +1,5 @@
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.
@@ -25,9 +24,6 @@ export interface LamPreferences {
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. */
@@ -39,7 +35,7 @@ export interface ResolvedPreferences {
model: string;
/**
* Whether {@link ResolvedPreferences.model} came from the preference or from a
* pick in the Manage Models command.
* pick in the 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
@@ -66,22 +62,6 @@ export interface ResolvedPreferences {
/** 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. */
@@ -222,13 +202,6 @@ export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
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,
@@ -244,14 +217,14 @@ export function resolvePreferences(raw: LamPreferences): 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.
* applies the model chosen in the Models command.
*/
function getLamPreferences(): ResolvedPreferences {
return resolvePreferences(getPreferenceValues<LamPreferences>());
}
/**
* The model chosen in the Manage Models command, if there is one.
* The model chosen in the 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
+1 -1
View File
@@ -18,7 +18,7 @@ import { errorMessage } from "./utils";
*/
export interface StartupInput {
/** A conversation already in hand, which is how the Chat History command pushes this view. */
/** A conversation already in hand, which is how the sessions command pushes this view. */
hasInitialSession: boolean;
/** A conversation to reopen by id, handed over by the snip command. */
initialSessionId?: string;
+8 -286
View File
@@ -1,16 +1,4 @@
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 { GeoLookup } from "./geo";
@@ -20,12 +8,11 @@ import { errorMessage } from "./utils";
/**
* The tools Lam can call.
*
* 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.
* All four are read-only. That is a deliberate boundary for this version: an
* assistant that can silently act on the user's machine needs a confirmation
* story first, and this release is chat-only. Anything with a side effect
* (opening URLs, clipboard access, launching apps) belongs behind an approval
* step that does not exist yet.
*/
export interface ToolContext {
@@ -57,14 +44,6 @@ export interface ToolContext {
* 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 };
}
/**
@@ -144,180 +123,26 @@ 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 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;
});
/** Filters out the web tools when the user has turned them off. */
export function toolsFor(options: { webEnabled: boolean }): ToolDefinition[] {
return TOOL_DEFINITIONS.filter((tool) => options.webEnabled || !WEB_TOOL_NAMES.has(tool.function.name));
}
function asString(value: unknown): string | undefined {
@@ -336,42 +161,6 @@ 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.
*
@@ -399,73 +188,6 @@ 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 };
}
+1 -1
View File
@@ -66,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 `Setup & Status` calls this rather than
* tag on the same account), so `Set Up Lam Agent` calls this rather than
* leaving the user pinned to a stale "no".
*/
export async function forgetVisionSupport(): Promise<void> {
+2 -2
View File
@@ -67,7 +67,7 @@ export default function ModelsCommand() {
// 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 <List isLoading navigationTitle="Ollama Models" searchBarPlaceholder="Loading models…" />;
}
return <ModelsScreen preferences={preferences} reloadPreferences={reload} />;
@@ -256,7 +256,7 @@ function ModelsScreen({ preferences, reloadPreferences }: ModelsScreenProps) {
return (
<List
isLoading={isLoading}
navigationTitle="Manage Models"
navigationTitle="Ollama Models"
searchBarPlaceholder="Search models…"
actions={listActions}
>
+1 -1
View File
@@ -78,7 +78,7 @@ export default function SessionsCommand() {
return (
<List
isLoading={!loaded}
navigationTitle="Chat History"
navigationTitle="Chat Sessions"
searchBarPlaceholder="Search conversations…"
>
{loaded && items.length === 0 ? (
+18 -32
View File
@@ -1,14 +1,4 @@
import {
Action,
ActionPanel,
Alert,
Icon,
List,
Toast,
confirmAlert,
openExtensionPreferences,
showToast,
} from "@vicinae/api";
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";
@@ -56,11 +46,13 @@ export default function SetupCommand() {
// 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
// one from preferences or from the 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 (
<List isLoading navigationTitle="Lam Agent Setup" searchBarPlaceholder="Loading preferences…" />
);
}
return <SetupScreen preferences={preferences} reloadPreferences={reload} />;
@@ -182,7 +174,11 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) {
{primary}
<ActionPanel.Section>
<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.Section>
</ActionPanel>
);
@@ -190,7 +186,11 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) {
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>
);
@@ -207,7 +207,7 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) {
return (
<List
isLoading={isLoading}
navigationTitle="Setup & Status"
navigationTitle="Lam Agent Setup"
searchBarPlaceholder="Search models…"
actions={settingsActions}
>
@@ -271,12 +271,6 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) {
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"
@@ -412,11 +406,7 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) {
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>
}
@@ -437,11 +427,7 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) {
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>
}
/>
+2 -2
View File
@@ -34,7 +34,7 @@ function settleWindow(): Promise<void> {
* Bind it to a global shortcut for one-key "snip and ask".
*/
export default async function SnipCommand(props: LaunchProps) {
// Awaited because the model can come from a pick in the Manage Models command, which
// Awaited because the model can come from a pick in the 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();
@@ -59,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 Setup & Status.`,
: `Could not check whether ${preferences.model} can see images. Run Set Up Lam Agent.`,
);
return;
}