Compare commits
2
Commits
34c4383428
..
prod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00307021c6 | ||
|
|
2adc6d74b5 |
@@ -17,13 +17,14 @@ 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.
|
- **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.
|
- **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).
|
- **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, 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.
|
- **Tools.** Lam decides when to call them: current time, location context, web search, page fetching, and, when you switch file access on, seven tools that list, search, read, write, edit, move and delete files.
|
||||||
|
- **Files you allow, and nothing else.** Off by default. When it is on, Lam can read under your home directory except for credential stores, and can only change files in Downloads, `Documents/Lam Agent`, its own folder, and wherever you add. Every change is shown to you with the exact path before it happens, a delete or a move asks every time, and a credential file is refused by name. See [Files](#files).
|
||||||
- **Sources you can check.** A search result and a fetched page each get a number, and those numbers are the ones Lam cites, so `[1]` in the answer and `[1]` under it are the same page. The list sits under the answer it belongs to, each number opens the page it names, and a number Lam was never shown stays as plain text rather than becoming a link to somewhere else.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **Screenshots, two ways.** Snip a region into the conversation you are already in from the action panel, or run **Snip to Lam** to start a new chat about it. The launcher window gets out of the way first, so it never ends up in the shot, and the capture appears in the transcript as a thumbnail with Open Screenshot for the full image. Neither is offered unless the current model can actually see images.
|
||||||
- **Chat only.** Lam answers questions. It does not touch your files or run commands. The one exception is capture, and it only ever happens because you pressed the key for it.
|
- **Chat answers, and nothing else happens unless you allow it.** Lam does not run commands, open URLs or touch the clipboard, and the one thing it can change on the machine is a file, which it can only do when you turn File Access on and approve each change.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -67,12 +68,12 @@ npm run build
|
|||||||
Setup is two prompts on first launch, then one command you can revisit any time.
|
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.
|
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 **Ollama Models** afterwards and pick the real one from the list.
|
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. **`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.
|
3. **`Setup & Status`** verifies the connection, lists every model your key can actually reach, and reports which one is in use and where that came from.
|
||||||
|
|
||||||
To change the model later, run **Ollama Models** and pick one, or edit the **Model** preference directly.
|
To change the model later, run **Manage Models** and pick one, or edit the **Model** preference directly.
|
||||||
|
|
||||||
> An extension can read preferences but not write them, which is why 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.
|
> An extension can read preferences but not write them, which is why a pick made in **Manage Models** is stored by the extension and layered over the preference rather than saved into it. See [Models](#models) for how the two are reconciled, and for what a reset does.
|
||||||
|
|
||||||
## Keyboard shortcuts
|
## Keyboard shortcuts
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ To change the model later, run **Ollama Models** and pick one, or edit the **Mod
|
|||||||
| `↩` with an empty bar | Open the highlighted conversation |
|
| `↩` with an empty bar | Open the highlighted conversation |
|
||||||
| `⌘ N` | New chat |
|
| `⌘ N` | New chat |
|
||||||
| `⌘ ⇧ S` | Snip a region and attach it to this chat (only with a vision model) |
|
| `⌘ ⇧ S` | Snip a region and attach it to this chat (only with a vision model) |
|
||||||
| `⌘ ⇧ ⌫` | Delete the highlighted conversation (also in **Chat Sessions**) |
|
| `⌘ ⇧ ⌫` | Delete the highlighted conversation (also in **Chat History**) |
|
||||||
|
|
||||||
Prompts are single line. An earlier `⌘ ↩` multi-line composer was removed. See [Architecture](#architecture) for why a `Form.TextArea` cannot be laid out cleanly in a launcher. The `Tab` shortcut went with it: the left column *is* the conversation switcher now, so an accessory dropdown would only duplicate it.
|
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.
|
||||||
|
|
||||||
@@ -117,21 +118,24 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Chat with Lam** | The chat surface. Bind this to your global shortcut. |
|
| **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". |
|
| **Snip to Lam** | Capture a region and start a new chat about it. A `no-view` command, so the launcher window closes before the selector appears. Bind this to a second shortcut for one-key "snip and ask". |
|
||||||
| **Chat Sessions** | Browse, reopen, and delete past conversations. |
|
| **Chat History** | 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). |
|
| **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). |
|
||||||
| **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. |
|
| **Setup & Status** | Verify the connection, list available models, check whether the selected model can see images, and clear out chats or unattached photos. |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
| Preference | Default | Notes |
|
| Preference | Default | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **Ollama API Key** | None | Required. Stored by Vicinae as a password preference. |
|
| **Ollama API Key** | None | Required. Stored by Vicinae as a password preference. |
|
||||||
| **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. |
|
| **Model** | None | Required. Any model your key can reach. **Manage Models** can pick one for you, and a pick takes precedence over this value until you reset it from the setup screen. |
|
||||||
| **Ollama Host** | `https://ollama.com` | Set to `http://localhost:11434` for a local server. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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`. |
|
| **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. |
|
| **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 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. |
|
| **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. |
|
||||||
@@ -141,7 +145,7 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
|
|||||||
|
|
||||||
## Models
|
## Models
|
||||||
|
|
||||||
**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.
|
**Manage Models** lists everything the configured host can reach, filtered by the search bar. Each row shows the parameter count and how much disk it takes, with tags for the model in use and for anything that reports image input. Picking one writes it to LocalStorage and `loadPreferences` layers it over the `Model` preference, so it takes effect on the next question with no restart.
|
||||||
|
|
||||||
| Action | Notes |
|
| Action | Notes |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -188,7 +192,7 @@ The download runs inside this command's worker, so **closing the launcher cancel
|
|||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
Lam calls these on its own when they would improve an answer. All four are read-only, and all four are the entire list of things Lam is allowed to do without being asked twice. Screen capture is deliberately not among them.
|
Lam calls these on its own when they would improve an answer. The four below are read-only, and they are the entire list of things Lam is allowed to do without being asked first. The file tools are a separate family, because they are the only ones that can change the machine: they are absent from the model's list until you switch **File Access** on, and every change they make is confirmed by you. Screen capture is deliberately not among any of them.
|
||||||
|
|
||||||
| Tool | What it does |
|
| Tool | What it does |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -207,7 +211,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.
|
**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. `Set Up Lam Agent` 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. `Setup & Status` clears that cache, and the capability cache, when you re-test.
|
||||||
|
|
||||||
This extension does not use GPS. That would mean CoreLocation on macOS, which has no command-line interface, or GeoClue on Linux, whose usual provider resolves nearby Wi-Fi networks through a Mozilla service anyway. Neither is worth it for "is it late where you are".
|
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".
|
||||||
|
|
||||||
@@ -233,6 +237,38 @@ 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.
|
**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
|
## 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.
|
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.
|
||||||
@@ -251,7 +287,7 @@ The index entry for a conversation carries its image paths (`SessionSummary.atta
|
|||||||
|
|
||||||
### Doing it by hand
|
### Doing it by hand
|
||||||
|
|
||||||
**Set Up Lam Agent** has a Maintenance section with the same two jobs as explicit actions:
|
**Setup & Status** has a Maintenance section with the same two jobs as explicit actions:
|
||||||
|
|
||||||
| Action | What it deletes |
|
| Action | What it deletes |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -316,8 +352,8 @@ The list shows the sources the answer cited, falling back to everything the turn
|
|||||||
src/
|
src/
|
||||||
chat.tsx # "chat" command entrypoint, thin wrapper
|
chat.tsx # "chat" command entrypoint, thin wrapper
|
||||||
snip.tsx # "snip" command entrypoint, no-view: captures then hands over
|
snip.tsx # "snip" command entrypoint, no-view: captures then hands over
|
||||||
models.tsx # "models" command: browse, pick, pull, delete
|
manage-models.tsx # "manage-models" command: browse, pick, pull, delete
|
||||||
sessions.tsx # browse, reopen, delete conversations
|
chat-history.tsx # "chat-history" command: browse, reopen, delete conversations
|
||||||
setup.tsx # connection check + live model list
|
setup.tsx # connection check + live model list
|
||||||
lib/
|
lib/
|
||||||
ollama.ts # the only file that talks HTTP
|
ollama.ts # the only file that talks HTTP
|
||||||
@@ -330,6 +366,8 @@ src/
|
|||||||
retention.ts # what gets deleted, and when
|
retention.ts # what gets deleted, and when
|
||||||
cleanup.ts # the sweep + its notification
|
cleanup.ts # the sweep + its notification
|
||||||
history.ts # turns -> API messages
|
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
|
location.ts # time/place, derived locally
|
||||||
thread.ts # turns -> flat message list
|
thread.ts # turns -> flat message list
|
||||||
markdown.ts # messages -> detail-panel markdown
|
markdown.ts # messages -> detail-panel markdown
|
||||||
@@ -385,7 +423,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.
|
**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 **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.
|
**Preferences have exactly one reader.** `loadPreferences` applies the model chosen in **Manage Models** on top of what Vicinae holds, and it is asynchronous because that pick lives in LocalStorage. `getLamPreferences` is deliberately not exported, because a second, synchronous reader would look correct and silently ignore the pick. Views take preferences through `hooks/usePreferences`, and the chat view is split in two so that `useChat` is only ever created once the model is known, rather than being pinned to whatever the preference said a moment earlier.
|
||||||
|
|
||||||
**Sessions are capped twice over**, once per turn and once per session, because `LocalStorage` has no documented ceiling.
|
**Sessions are capped twice over**, once per turn and once per session, because `LocalStorage` has no documented ceiling.
|
||||||
|
|
||||||
@@ -403,7 +441,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.
|
**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 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 Manage Models command picks names from the list rather than taking them typed.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -418,7 +456,7 @@ npm test
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- **Actions.** Opening links, clipboard access, launching apps. These need an approval step first. An assistant that can silently act on your machine is a different risk profile from one that answers questions, and this release does not pretend otherwise.
|
- **Actions.** Opening links, clipboard access, launching apps. This release ships the approval step they were waiting for, with the file tools, so the remaining work is the actions themselves rather than the permission model under them.
|
||||||
- **`@` mentions** for files and clipboard content.
|
- **`@` mentions** for files and clipboard content.
|
||||||
- **Per-conversation model** overrides.
|
- **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.
|
- **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
@@ -0,0 +1,406 @@
|
|||||||
|
# File system access for Lam
|
||||||
|
|
||||||
|
Status: draft for review
|
||||||
|
|
||||||
|
Date: 2026-10-07
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Lam is a chat window that cannot look at anything on the machine unless the user
|
||||||
|
attaches a screenshot to a question. Every question about a file is therefore
|
||||||
|
answered from memory of a description the user typed, and the README says so in
|
||||||
|
as many words: "Chat only. Lam answers questions. It does not touch your files or
|
||||||
|
run commands."
|
||||||
|
|
||||||
|
The capability is not the problem. `src/lib/attachments.ts` already imports
|
||||||
|
`readFile` from `node:fs/promises` to turn an attached screenshot into base64, and
|
||||||
|
`src/lib/capture.ts` writes capture files. The problem is that nothing here has a
|
||||||
|
permission model, and an assistant that can read and write without one is a
|
||||||
|
liability rather than a feature.
|
||||||
|
|
||||||
|
### What the platform gives us, and what it does not
|
||||||
|
|
||||||
|
Established by reading the Vicinae source in `/tmp/vicinae-src`
|
||||||
|
(`vicinaehq/vicinae`, `7733e95`):
|
||||||
|
|
||||||
|
- **Extensions are unsandboxed Node.** Each command runs in a worker thread of the
|
||||||
|
extension manager (`src/typescript/extension-manager/src/index.ts`), and the only
|
||||||
|
`require` patch is a lookup table for React and `@vicinae/api`
|
||||||
|
(`src/typescript/extension-manager/src/patch-require.ts`). Everything else falls
|
||||||
|
through to the real loader, so `node:fs` and `node:path` work. Vicinae's own
|
||||||
|
boilerplate extension uses them (`extra/extension-boilerplate/src/wallpaper.tsx`
|
||||||
|
imports `readdir` from `node:fs/promises`).
|
||||||
|
- **There is no OS-level permission to lean on**, no manifest permission block
|
||||||
|
(`schemas/manifest.ts` has none), and `environment.canAccess` only covers
|
||||||
|
`FileSearch`, `BrowserExtension`, `Wallpaper`, `AI` and `WindowManagement`. If
|
||||||
|
Lam is to be bounded at all, this repository has to do the bounding.
|
||||||
|
- **`confirmAlert` works.** `src/server/src/extension/api/ui-service.hpp` implements
|
||||||
|
it with a `CallbackAlertWidget`, so an approval step can be shown from a running
|
||||||
|
command.
|
||||||
|
- **`rememberUserChoice` does not.** `Alert.Options` declares it, and no C++ file
|
||||||
|
mentions it, so "do not ask again" would have to be our own bookkeeping and must
|
||||||
|
not claim to be the platform's.
|
||||||
|
- **`environment.supportPath`** is documented as the directory an extension may read
|
||||||
|
and write, and is a sensible default write root.
|
||||||
|
- **`FileSearch`** exposes Vicinae's file index read only. It returns indexed
|
||||||
|
filename matches, needs the index to be built, and yields no file contents, so it
|
||||||
|
is not the shape the model needs. Not used.
|
||||||
|
|
||||||
|
### The risk this feature creates
|
||||||
|
|
||||||
|
Everything Lam reads is sent to the model, which is Ollama Cloud unless the user
|
||||||
|
self hosts. So reading a file is a disclosure even though it changes nothing on
|
||||||
|
disk. That is why the read boundary in this spec is as considered as the write
|
||||||
|
boundary, and why the README has to say it out loud.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Lam can list a directory, find a file by name or content, and read a file.
|
||||||
|
2. Lam can create, overwrite and edit files, and can delete and move them.
|
||||||
|
3. Nothing outside the configured paths is readable or writable, and a symlink or a
|
||||||
|
`..` segment cannot change that.
|
||||||
|
4. Every operation that changes the disk is approved by the user, with the exact
|
||||||
|
affected path on screen, and a refusal is a normal outcome rather than a failure.
|
||||||
|
5. Credential stores stay unreadable by default, because their contents would end up
|
||||||
|
in a model request.
|
||||||
|
6. A refusal or a policy failure tells the model what happened and what it may do
|
||||||
|
instead, so the turn continues and produces an answer.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Running commands. There is no shell tool here, and adding one would make every
|
||||||
|
other boundary in this spec decorative.
|
||||||
|
- Watching files, tailing logs, or any long lived handle.
|
||||||
|
- Recursive delete. See A4.
|
||||||
|
- Editing in place with a patch format. Replacement is exact string substitution.
|
||||||
|
- A directory creation tool. The only way a directory comes into existence here is
|
||||||
|
the parent chain `write_file` creates for the file it was asked to write, which is
|
||||||
|
disclosed in the alert.
|
||||||
|
- A file picker or a browse view. The tools take paths the model writes.
|
||||||
|
- Reading or writing outside `$HOME`, except through a preference the user sets
|
||||||
|
themselves.
|
||||||
|
- Copying. `move_path` covers the useful case and copying doubles the files a
|
||||||
|
mistake can create.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
The four decisions this cycle rested on were put to the user directly; the smaller
|
||||||
|
ones that follow from them are recorded here.
|
||||||
|
|
||||||
|
- **A1. An approval covers a path for one answer.** One `runAgent` call is one
|
||||||
|
answer, so the ledger lives for exactly that turn. The first write to a resolved
|
||||||
|
path raises one alert; further writes to that same path in that answer go through
|
||||||
|
without one; the next question starts empty. Chosen over "ask every time" because
|
||||||
|
a model that needs three passes at one file would otherwise interrupt three times,
|
||||||
|
and over "the roots are the permission" because a path mistake inside a write root
|
||||||
|
would then destroy a file silently.
|
||||||
|
- **A2. Delete and move always ask, even for a path already approved for writing.**
|
||||||
|
The one deliberate exception to A1, and the deviation from the user's answer that
|
||||||
|
was put back to them and confirmed. Every other operation leaves something
|
||||||
|
recoverable behind: an overwrite is a change the user watched, an edit is a
|
||||||
|
replacement, but a delete is the end of the file. An approval granted for an edit
|
||||||
|
ten seconds earlier is not consent to remove the thing that was just edited.
|
||||||
|
`delete_path` and `move_path` therefore skip the ledger entirely.
|
||||||
|
- **A3. Reads are bounded by `$HOME` minus a credential denylist.** Anything under
|
||||||
|
the home directory is readable except the locations in the denylist below. Chosen
|
||||||
|
over an unrestricted filesystem, which would put `/etc` and mounted volumes in a
|
||||||
|
model request, and over an allowlist that starts empty, which would make the
|
||||||
|
feature useless until it was configured.
|
||||||
|
- **A4. `delete_path` refuses a non-empty directory and never recurses.** A recursive
|
||||||
|
delete is the one call where a misread path destroys an unbounded amount of work,
|
||||||
|
and no confirmation dialog makes it recoverable. A single file, or an empty
|
||||||
|
directory, is the whole scope. The refusal names what is in the directory so the
|
||||||
|
model can ask for the contents one at a time.
|
||||||
|
- **A5. `move_path` refuses to overwrite an existing destination.** The alternative is
|
||||||
|
a move that silently destroys the file it lands on. The model can delete the
|
||||||
|
destination first, which asks again under A2.
|
||||||
|
- **A6. `write_file` overwrites without a separate flag, and discloses which it is.**
|
||||||
|
The approval alert says whether the file exists, how big it is and when it last
|
||||||
|
changed, so an overwrite is never a surprise. The old line count is deliberately not
|
||||||
|
among them: counting it would mean reading a file Lam is about to replace, and stat
|
||||||
|
gives the size and the age for free. A `mustNotExist` flag would add a
|
||||||
|
round trip and a second approval to the common case to guard against something the
|
||||||
|
alert already shows.
|
||||||
|
- **A7. Relative paths resolve against `$HOME`, not the working directory.** A launcher
|
||||||
|
command has no meaningful working directory, and `~` is expanded, because both are
|
||||||
|
what the model will write by habit and a rejection would only cost a round trip.
|
||||||
|
- **A8. `search_files` searches names and optionally content.** Content search is
|
||||||
|
"which file mentions this string", which is the question that actually motivates
|
||||||
|
searching at all. It reads files, so it discloses what it reads exactly as
|
||||||
|
`read_file` does, and it is capped in depth and results.
|
||||||
|
- **A9. If the approval dialog cannot be shown, the call is refused.** Fail closed.
|
||||||
|
An unverified path in this repository is the confirmation rendering while a turn
|
||||||
|
is streaming, which cannot be tested locally; refusing is the only safe default.
|
||||||
|
- **A10. The ledger is keyed by resolved real path, not by the string the model
|
||||||
|
wrote.** `~/Downloads/x.txt`, `/Users/me/Downloads/x.txt` and a symlink to it are
|
||||||
|
one approval, because they are one file. Keying on the written form would let a
|
||||||
|
second spelling of the same path slip past an approval the user already gave for
|
||||||
|
it, and would show the same file's change twice as two permissions.
|
||||||
|
- **A11. File access is off until it is switched on, and when it is off the tools do
|
||||||
|
not exist for the model.** This is a third preference beyond the two the user asked
|
||||||
|
for, added because the read side is the silent one: a write always raises a dialog,
|
||||||
|
but a read goes straight into a model request, and the model may choose to read
|
||||||
|
without the user having asked about any file. `ipGeolocation` in this repository is
|
||||||
|
already off by default for the weaker version of the same reason (it discloses one
|
||||||
|
thing about the user to a third party), so the defaults here follow that precedent
|
||||||
|
rather than the `webSearchEnabled` one. Off means absent from `toolsFor`, not merely
|
||||||
|
refusing: the model never learns the tools exist, and the prompt paragraph is not
|
||||||
|
added. One checkbox turns it on.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Module layout
|
||||||
|
|
||||||
|
| file | job |
|
||||||
|
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `src/lib/files.ts` (new) | Path policy and every file operation. No Vicinae import, so the whole boundary unit tests headlessly. |
|
||||||
|
| `src/lib/approval.ts` (new) | The only Vicinae facing part: the `confirmAlert` wording, and the per-answer ledger. |
|
||||||
|
| `src/lib/tools.ts` (edit) | Seven definitions and thin handlers, plus a `FILE_TOOL_NAMES` set so `toolsFor` can leave them out entirely when the feature is off (A11). Approval arrives as an injected callback, the way `ipPlace` already does. |
|
||||||
|
| `src/lib/preferences.ts` (edit) | `readRoots` and `writeRoots`, parsed by a pure `splitRoots()`. |
|
||||||
|
| `src/lib/constants.ts` (edit) | The caps, and the prompt paragraph. |
|
||||||
|
| `src/lib/hooks/useChat.ts` (edit) | Creates the ledger per turn and injects the confirm callback. |
|
||||||
|
| `package.json` (edit) | The two new preferences in the manifest. |
|
||||||
|
| `README.md` (edit) | The promise that Lam does not touch files, replaced by what it now does. |
|
||||||
|
|
||||||
|
`files.ts` owns no UI and `approval.ts` owns no path logic, so the policy can be
|
||||||
|
tested without a dialog and the dialog without a disk.
|
||||||
|
|
||||||
|
### Path model
|
||||||
|
|
||||||
|
```
|
||||||
|
read roots: $HOME minus the denylist
|
||||||
|
write roots: ~/Downloads
|
||||||
|
~/Documents/Lam Agent
|
||||||
|
environment.supportPath
|
||||||
|
+ whatever readRoots / writeRoots preferences add
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolution, in order, for every operation that takes a path:
|
||||||
|
|
||||||
|
1. Reject a non-string, an empty string, or a path containing a NUL byte.
|
||||||
|
2. Expand a leading `~` or `~/`; resolve a relative path against `$HOME` (A7).
|
||||||
|
3. `path.resolve` to a normal form, so `..` segments are gone.
|
||||||
|
4. `realpath` the path. If it does not exist (the create case), `realpath` the
|
||||||
|
nearest existing ancestor and re-append the remainder.
|
||||||
|
5. Check containment against the roots **after** step 4, so a symlink pointing
|
||||||
|
outside a root is judged by where it lands rather than by where it is written.
|
||||||
|
6. For a read, check the denylist against the resolved path.
|
||||||
|
|
||||||
|
Containment is a component-wise comparison, not a string prefix:
|
||||||
|
`/Users/me/Downloads-evil` must not match the root `/Users/me/Downloads`.
|
||||||
|
|
||||||
|
The denylist, matched on the resolved path, either exactly or as a directory
|
||||||
|
prefix:
|
||||||
|
|
||||||
|
| rule | why |
|
||||||
|
| ----------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||||
|
| `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.config/gh`, `~/.docker/config.json`, `~/.netrc` | Credential stores and key material |
|
||||||
|
| basename `.env` or starting `.env.` | Application secrets, the common shape |
|
||||||
|
| basename `id_rsa`, `id_dsa`, `id_ecdsa`, `id_ed25519`, `credentials.json` | Private keys and cloud credential files |
|
||||||
|
| extension `.pem`, `.key`, `.p12` | Key material under a name no other rule catches |
|
||||||
|
|
||||||
|
The denylist is a guard, not a proof: a credential file with a name nobody thought
|
||||||
|
of is still readable. That is stated in the risks rather than papered over.
|
||||||
|
|
||||||
|
### Operations
|
||||||
|
|
||||||
|
Every function returns a result object rather than throwing, so a policy refusal
|
||||||
|
and an I/O failure reach the model in the same shape.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type FileResult =
|
||||||
|
| { ok: true; content: string; path: string; bytes?: number }
|
||||||
|
| { ok: false; content: string; refused: "policy" | "approval" | "io" };
|
||||||
|
```
|
||||||
|
|
||||||
|
The distinction matters in one place only: a policy refusal means "there is
|
||||||
|
nothing here you are allowed to touch", while an approval refusal means "the user
|
||||||
|
said no to this one", and the model is told to stop trying in the first case and to
|
||||||
|
ask rather than retry in the second.
|
||||||
|
|
||||||
|
| tool | parameters | notes |
|
||||||
|
| ---------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `list_directory` | `path`, `hidden?` | Directories first, then files, each sorted. Capped at `MAX_LIST_ENTRIES` (200), with a count of what was left out. |
|
||||||
|
| `search_files` | `path`, `pattern`, `contains?` | `pattern` is a glob (`*`, `?`, `**`) matched against the basename; `contains` is a case-insensitive substring test on the file's first `MAX_SEARCH_BYTES` (64 KB). Capped at `MAX_SEARCH_RESULTS` (50) and `MAX_SEARCH_DEPTH` (8). A path that fails the read check is skipped, contents and all, so the denylist holds for search results as well as for direct reads. |
|
||||||
|
| `read_file` | `path`, `offset?`, `limit?` | Numbered lines, stopping at whichever comes first of the default 200 lines and `MAX_READ_CHARS` (8000) characters, with the notice naming that cap so it is clear which one stopped it. Binary content (a NUL byte in the first 8 KB) is refused with its size and detected type rather than returned as mojibake. |
|
||||||
|
| `write_file` | `path`, `content` | Creates the parent directory chain when it is missing, disclosed in the alert, since `~/Downloads/reports/may.md` is a normal thing to ask for and there is no separate mkdir tool. Reports `Created` or `Overwrote (...)` with the size and line count of what it wrote. |
|
||||||
|
| `edit_file` | `path`, `old_string`, `new_string`, `all?` | Exact substitution. Refuses when `old_string` occurs zero times or more than once without `all`, naming the count so the model can add context instead of guessing. |
|
||||||
|
| `delete_path` | `path` | One file, or an empty directory. A non-empty directory is refused with its entry count (A4). |
|
||||||
|
| `move_path` | `from`, `to` | Both the source and the destination are policy checked and both are approved. Refuses an existing destination (A5). |
|
||||||
|
|
||||||
|
`toolLabel` gains "Reading a file", "Writing a file", "Editing a file", "Deleting a
|
||||||
|
file", "Moving a file", "Listing a folder", "Searching your files" for the activity
|
||||||
|
line.
|
||||||
|
|
||||||
|
### Approval
|
||||||
|
|
||||||
|
`approval.ts` exports `createApprovalLedger(confirm)` and the wording.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type FileApproval = {
|
||||||
|
/** What is about to happen, in the user's terms. */
|
||||||
|
title: string;
|
||||||
|
/** The concrete path, tilde shortened, plus the size and shape of what changes. */
|
||||||
|
body: string;
|
||||||
|
/** Whether this one is irreversible, which decides the button style. */
|
||||||
|
destructive: boolean;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`confirm` is `(approval: FileApproval) => Promise<boolean>`, implemented in
|
||||||
|
`useChat.ts` with `confirmAlert`. The ledger remembers resolved paths approved
|
||||||
|
during the turn (A1, A10) and is consulted only by the five mutating operations
|
||||||
|
other than delete and move, which always ask (A2).
|
||||||
|
|
||||||
|
The alert content is the point of the whole design, so it is specified rather than
|
||||||
|
left to the implementation:
|
||||||
|
|
||||||
|
```
|
||||||
|
Title: Overwrite report.md?
|
||||||
|
Body: /Users/me/Downloads/report.md
|
||||||
|
Replaces 1,240 bytes, last changed 3 days ago.
|
||||||
|
New content is 2,180 bytes, 61 lines.
|
||||||
|
[Allow] [Decline]
|
||||||
|
```
|
||||||
|
|
||||||
|
When the parent directory does not exist, the body gains a line before the rest:
|
||||||
|
`Creates the folder ~/Downloads/reports as well.` Nothing else appears in the
|
||||||
|
body that the user cannot act on, and the exact path is always there, because the
|
||||||
|
path is the thing being approved.
|
||||||
|
|
||||||
|
- `edit_file` shows the substitution itself, both sides truncated to 200
|
||||||
|
characters: `old_string → new_string`, which is the one alert where showing the
|
||||||
|
change is better than showing the size of the file.
|
||||||
|
- `delete_path` uses `Alert.ActionStyle.Destructive` on the primary action and adds
|
||||||
|
"This cannot be undone."
|
||||||
|
- `move_path` shows both paths and says whether the destination exists.
|
||||||
|
- A policy refusal never reaches the user as a dialog at all, because there is
|
||||||
|
nothing to approve.
|
||||||
|
- The tool record keeps the refusal text, so a transcript with Show Tool Calls on
|
||||||
|
shows what was declined next to what was allowed. No new field is needed for that,
|
||||||
|
which is why `ToolCallRecord` does not change in this spec.
|
||||||
|
|
||||||
|
### Refusals the model reads
|
||||||
|
|
||||||
|
Short, factual, and actionable, in the same voice as the rest of the tool output:
|
||||||
|
|
||||||
|
```
|
||||||
|
Refused: /etc/passwd is outside the paths Lam may read. Readable: your home
|
||||||
|
directory, except credential stores. Nothing was read.
|
||||||
|
|
||||||
|
Refused: ~/.ssh/id_ed25519 is a credential store, so Lam does not read it.
|
||||||
|
|
||||||
|
Refused: the user declined to write /Users/me/Downloads/draft.md. Nothing was
|
||||||
|
written. Ask before trying again.
|
||||||
|
|
||||||
|
Refused: ~/Downloads/archive is a directory with 42 entries, and Lam does not
|
||||||
|
delete directories recursively. List it and delete what you actually need.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prompt
|
||||||
|
|
||||||
|
The system prompt gains a paragraph naming the write roots, saying that every
|
||||||
|
change is approved by the user and that a refusal is final, that paths outside the
|
||||||
|
roots are refused outright, and that a large file should be read in parts. It also
|
||||||
|
gains one line of disclosure: file contents go to the model provider, so Lam reads
|
||||||
|
what the question needs and nothing more.
|
||||||
|
|
||||||
|
### Preferences
|
||||||
|
|
||||||
|
Two new text fields in `package.json`, mirrored in `LamPreferences`, plus the switch
|
||||||
|
from A11:
|
||||||
|
|
||||||
|
| preference | shape |
|
||||||
|
| ------------ | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `fileAccess` | Checkbox. Off by default, and off means the seven tools are absent from `toolsFor` and the prompt paragraph is not added. |
|
||||||
|
| `readRoots` | Extra read roots, one absolute path per line |
|
||||||
|
| `writeRoots` | Extra write roots, one absolute path per line |
|
||||||
|
|
||||||
|
`splitRoots(value)` in `files.ts` is pure and tested: it splits on newlines and
|
||||||
|
commas, trims, expands `~`, drops empty entries, keeps a path that does not exist
|
||||||
|
yet (a write root may be created later), and reports entries that are not absolute
|
||||||
|
rather than silently ignoring them. A bad entry does not disable the others, and
|
||||||
|
the defaults above always apply, so an empty preference is the same as no
|
||||||
|
preference.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Situation | Behaviour |
|
||||||
|
| ------------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| Path outside every root | Refused, naming the roots. The user is not prompted. |
|
||||||
|
| Path inside the denylist | Refused, naming the rule. |
|
||||||
|
| Symlink or `..` escaping a root | Resolved first, then refused, and the refusal names the real location. |
|
||||||
|
| Path does not exist (read) | `ENOENT` reported with the path, not a stack. |
|
||||||
|
| Path exists (create) | Reported as an overwrite with the current size. |
|
||||||
|
| Permission denied by the OS | Refused with the OS message, which is already specific. |
|
||||||
|
| File larger than the cap | The cap is applied and stated; nothing is silently cut. |
|
||||||
|
| Binary file | Refused with size and detected type. |
|
||||||
|
| Non-empty directory delete | Refused with the entry count (A4). |
|
||||||
|
| Move onto an existing path | Refused, naming both paths (A5). |
|
||||||
|
| User declines | Refusal returned as a tool result with `isError: true`, turn continues. |
|
||||||
|
| Approval dialog cannot be shown | Refused (A9). |
|
||||||
|
| Ledger already holds the path | No dialog for a write or an edit; a delete or a move asks anyway (A2). |
|
||||||
|
| Feature off (A11) | The tools are not offered, so the model cannot call them |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `files.test.ts`, new, the bulk of the suite: a table of resolution cases
|
||||||
|
(`~`, relative, `..`, symlink out of a root, `/Users/me/Downloads-evil`,
|
||||||
|
denylist hits and near misses, non-existent parent chains), plus operation tests
|
||||||
|
against a real temporary directory built with `mkdtemp`, including the delete and
|
||||||
|
move refusals, the binary guard, line offsets, and the caps.
|
||||||
|
- `approval.test.ts`, new: the ledger approves once per resolved path per turn,
|
||||||
|
treats two spellings of one path as one entry (A10), and never suppresses a
|
||||||
|
delete or a move (A2); the wording for each of the five operations.
|
||||||
|
- `tools.test.ts`: each tool dispatches to the right operation; a declined call
|
||||||
|
returns `isError` with the refusal text; a policy refusal never calls the confirm
|
||||||
|
callback; the tools stay out of the list when the feature is off.
|
||||||
|
- **`preferences.test.ts`**: `splitRoots` for empty, comma-separated, newline-separated,
|
||||||
|
padded, `~`, relative and repeated input.
|
||||||
|
- Mutation checks on the policy table: inverting a containment comparison, checking
|
||||||
|
before `realpath`, and keying the ledger on the written path must each fail a test.
|
||||||
|
|
||||||
|
## Approaches considered
|
||||||
|
|
||||||
|
- **One implementation in `tools.ts`, importing `confirmAlert` directly.** Fewer
|
||||||
|
files, but it makes the tool layer Vicinae dependent, against the deliberate split
|
||||||
|
that lets `tools.ts` and `agent.ts` run in a plain unit test, and it puts the
|
||||||
|
policy somewhere a test cannot reach it without the runtime.
|
||||||
|
- **A separate process holding the file access, with the extension talking to it.**
|
||||||
|
A real boundary, and the only one an attacker could not walk around, but it needs
|
||||||
|
the toolchain to spawn and supervise a process, and it moves the approval dialog
|
||||||
|
somewhere it cannot be shown.
|
||||||
|
- **Vicinae's `FileSearch` for discovery.** Rejected: indexed names only, no
|
||||||
|
contents, and it needs the user's index to be enabled and current.
|
||||||
|
- **An allowlist that starts empty.** Rejected in A3: the feature would do nothing
|
||||||
|
until configured, and the first experience of a new capability should not be a
|
||||||
|
refusal.
|
||||||
|
- **Asking on every mutating call (the "every write asks" option).** Rejected in A1
|
||||||
|
in favour of one approval per path per answer.
|
||||||
|
- **Recursive delete with a stronger confirmation.** Rejected in A4.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Reading is disclosure.** Every byte read leaves the machine in a model request.
|
||||||
|
The denylist and the home boundary limit the blast radius; they cannot make a file
|
||||||
|
safe to read. The README will say this plainly, and the prompt will tell the model
|
||||||
|
to read only what the question needs.
|
||||||
|
- **The denylist is a list of guesses.** A key file with an unusual name under an
|
||||||
|
allowed path is readable. This is a denylist by construction, so it is bounded by
|
||||||
|
what was thought of.
|
||||||
|
- **The confirmation has not been seen rendering.** Vicinae cannot be run in this
|
||||||
|
environment, so `confirmAlert` during a streaming turn is reasoned from the C++
|
||||||
|
implementation. A9 fails closed, and the first real use will tell us.
|
||||||
|
- **`rememberUserChoice` is not implemented**, so nothing here may offer "do not ask
|
||||||
|
again" as though the platform provided it. The ledger is per answer, which is a
|
||||||
|
defensible unit and not a promise about the future.
|
||||||
|
- **A model with write access can still do damage inside a write root.** Once
|
||||||
|
`~/Downloads` is approved, a wrong path inside it is real damage. That is why the
|
||||||
|
alert shows the concrete path, size and line count rather than the tool name.
|
||||||
|
- **A long turn can spend many approvals.** The ledger limits this to one per
|
||||||
|
distinct path, and the tool round cap bounds it further.
|
||||||
|
- **The check and the write are not atomic.** Between resolving a path and acting on
|
||||||
|
it, a symlink could be swapped so that the write lands somewhere the policy did not
|
||||||
|
clear. Defending that needs `O_NOFOLLOW` and an open file handle per operation,
|
||||||
|
which is a different design, and the threat it guards against is another process
|
||||||
|
already running as the user. Out of scope, stated rather than implied.
|
||||||
+31
-10
@@ -11,34 +11,31 @@
|
|||||||
{
|
{
|
||||||
"name": "chat",
|
"name": "chat",
|
||||||
"title": "Chat with Lam",
|
"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.",
|
"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"
|
"mode": "view"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "snip",
|
"name": "snip",
|
||||||
"title": "Snip to Lam",
|
"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.",
|
"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"
|
"mode": "no-view"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sessions",
|
"name": "chat-history",
|
||||||
"title": "Chat Sessions",
|
"title": "Chat History",
|
||||||
"description": "Browse, reopen, and clean up your past conversations with Lam.",
|
"description": "Browse, reopen, and clean up your past conversations with Lam.",
|
||||||
"mode": "view"
|
"mode": "view"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "models",
|
"name": "manage-models",
|
||||||
"title": "Ollama Models",
|
"title": "Manage 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.",
|
"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"
|
"mode": "view"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "setup",
|
"name": "setup",
|
||||||
"title": "Set Up Lam Agent",
|
"title": "Setup & Status",
|
||||||
"description": "Check your Ollama connection, pick the model Lam should use, and tune how Lam answers.",
|
"description": "Check your Ollama connection, pick the model Lam should use, tune how Lam answers, and clear out old chats or screenshots you never sent.",
|
||||||
"mode": "view"
|
"mode": "view"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -53,7 +50,7 @@
|
|||||||
{
|
{
|
||||||
"name": "model",
|
"name": "model",
|
||||||
"title": "Model",
|
"title": "Model",
|
||||||
"description": "The model Lam should use, for example deepseek-v4.1-flash:cloud or gpt-oss:120b. Run \"Set Up Lam Agent\" to pick from the models your key can access.",
|
"description": "The model Lam should use, for example deepseek-v4.1-flash:cloud or gpt-oss:120b. Run \"Setup & Status\" to pick from the models your key can access.",
|
||||||
"type": "textfield",
|
"type": "textfield",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
@@ -173,6 +170,30 @@
|
|||||||
{ "title": "Unlimited (no cap)", "value": "unlimited" }
|
{ "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": {
|
"dependencies": {
|
||||||
"@vicinae/api": "^0.29.0",
|
"@vicinae/api": "^0.29.0",
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createApprovalLedger, describeApproval, type FileApproval } from "../lib/approval";
|
||||||
|
import type { ApprovalRequest } from "../lib/files";
|
||||||
|
|
||||||
|
const request = (overrides: Partial<ApprovalRequest> = {}): ApprovalRequest => ({
|
||||||
|
operation: "overwrite",
|
||||||
|
path: "/Users/me/Downloads/report.md",
|
||||||
|
display: "~/Downloads/report.md",
|
||||||
|
subject: "report.md",
|
||||||
|
details: ["Replaces 14 bytes, last changed 3 days ago."],
|
||||||
|
irreversible: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("describeApproval", () => {
|
||||||
|
it("names the operation and the file", () => {
|
||||||
|
expect(describeApproval(request()).title).toBe("Overwrite report.md?");
|
||||||
|
expect(describeApproval(request({ operation: "create" })).title).toBe("Create report.md?");
|
||||||
|
expect(describeApproval(request({ operation: "edit" })).title).toBe("Edit report.md?");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the concrete path and every detail line", () => {
|
||||||
|
const approval = describeApproval(request());
|
||||||
|
expect(approval.body).toContain("~/Downloads/report.md");
|
||||||
|
expect(approval.body).toContain("Replaces 14 bytes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the path first, because it is the thing being approved", () => {
|
||||||
|
expect(describeApproval(request()).body.split("\n")[0]).toBe("~/Downloads/report.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a delete as destructive, and says it cannot be undone", () => {
|
||||||
|
const approval = describeApproval(
|
||||||
|
request({ operation: "delete", details: ["This cannot be undone."], irreversible: true }),
|
||||||
|
);
|
||||||
|
expect(approval.destructive).toBe(true);
|
||||||
|
expect(approval.body).toContain("cannot be undone");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names both ends of a move", () => {
|
||||||
|
const approval = describeApproval(
|
||||||
|
request({
|
||||||
|
operation: "move",
|
||||||
|
display: "~/a.md to ~/Documents/b.md",
|
||||||
|
subject: "a.md to b.md",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(approval.title).toBe("Move a.md to b.md?");
|
||||||
|
expect(approval.body).toContain("~/a.md to ~/Documents/b.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createApprovalLedger", () => {
|
||||||
|
it("asks once for a path and remembers the answer", async () => {
|
||||||
|
const asked: FileApproval[] = [];
|
||||||
|
const ledger = createApprovalLedger(async (approval) => {
|
||||||
|
asked.push(approval);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await ledger.approve(request())).toBe(true);
|
||||||
|
expect(await ledger.approve(request())).toBe(true);
|
||||||
|
expect(asked).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the dialog the same words the ledger keyed on", async () => {
|
||||||
|
// A ledger that approved one thing while the dialog described another is the
|
||||||
|
// failure this pair exists to make impossible.
|
||||||
|
const asked: FileApproval[] = [];
|
||||||
|
const ledger = createApprovalLedger(async (approval) => {
|
||||||
|
asked.push(approval);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await ledger.approve(request({ operation: "create", subject: "notes.md" }));
|
||||||
|
expect(asked[0].title).toBe("Create notes.md?");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remembers nothing when the user declines, so the next attempt asks", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const ledger = createApprovalLedger(async () => {
|
||||||
|
calls++;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await ledger.approve(request())).toBe(false);
|
||||||
|
expect(await ledger.approve(request())).toBe(false);
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats two spellings of one path as one path", async () => {
|
||||||
|
// The ledger keys on the resolved path, so an edit approved for
|
||||||
|
// ~/Downloads/report.md also covers /Users/me/Downloads/report.md.
|
||||||
|
let calls = 0;
|
||||||
|
const ledger = createApprovalLedger(async () => {
|
||||||
|
calls++;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await ledger.approve(request());
|
||||||
|
await ledger.approve(request({ display: "/Users/me/Downloads/report.md" }));
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks every time for a delete or a move, even after an approval for the same path", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const ledger = createApprovalLedger(async () => {
|
||||||
|
calls++;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await ledger.approve(request());
|
||||||
|
await ledger.approve(request({ operation: "delete", irreversible: true }));
|
||||||
|
await ledger.approve(request({ operation: "delete", irreversible: true }));
|
||||||
|
expect(calls).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when the dialog itself fails, rather than assuming consent", async () => {
|
||||||
|
// Fail closed: an unshowable dialog is not permission.
|
||||||
|
const ledger = createApprovalLedger(async () => {
|
||||||
|
throw new Error("no such widget");
|
||||||
|
});
|
||||||
|
expect(await ledger.approve(request())).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import {
|
||||||
|
createPolicy,
|
||||||
|
deletePath,
|
||||||
|
denialFor,
|
||||||
|
editTextFile,
|
||||||
|
isWithin,
|
||||||
|
listDirectory,
|
||||||
|
movePath,
|
||||||
|
readDenial,
|
||||||
|
readTextFile,
|
||||||
|
resolvePath,
|
||||||
|
searchFiles,
|
||||||
|
splitRoots,
|
||||||
|
writeDenial,
|
||||||
|
writeTextFile,
|
||||||
|
type ApprovalRequest,
|
||||||
|
type PathPolicy,
|
||||||
|
} from "../lib/files";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A temporary home, with its symlinks resolved.
|
||||||
|
*
|
||||||
|
* `mkdtemp` on macOS hands back a path under `/var`, which is itself a symlink to
|
||||||
|
* `/private/var`, so a policy built on the raw value and a resolved path would
|
||||||
|
* disagree about every single path in these tests. Resolving here is what keeps the
|
||||||
|
* tests about the policy rather than about the platform.
|
||||||
|
*/
|
||||||
|
async function makeHome(prefix: string): Promise<string> {
|
||||||
|
return realpath(await mkdtemp(path.join(tmpdir(), prefix)));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isWithin", () => {
|
||||||
|
it("accepts the root itself and a path under it", () => {
|
||||||
|
expect(isWithin("/Users/me/Downloads", "/Users/me/Downloads")).toBe(true);
|
||||||
|
expect(isWithin("/Users/me/Downloads/a/b.md", "/Users/me/Downloads")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a sibling whose name merely starts the same way", () => {
|
||||||
|
// The whole reason containment is component-wise: a string prefix would let this
|
||||||
|
// through, and it is not inside the root.
|
||||||
|
expect(isWithin("/Users/me/Downloads-evil/x", "/Users/me/Downloads")).toBe(false);
|
||||||
|
expect(isWithin("/Users/me", "/Users/me/Downloads")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("splitRoots", () => {
|
||||||
|
const home = "/Users/me";
|
||||||
|
|
||||||
|
it("splits on newlines and commas, trimming as it goes", () => {
|
||||||
|
expect(splitRoots("/a\n/b, /c", home).roots).toEqual(["/a", "/b", "/c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty for empty or missing input", () => {
|
||||||
|
expect(splitRoots(undefined, home).roots).toEqual([]);
|
||||||
|
expect(splitRoots(" \n , ", home).roots).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands a home shorthand", () => {
|
||||||
|
expect(splitRoots("~/Code\n~", home).roots).toEqual(["/Users/me/Code", "/Users/me"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a relative path instead of guessing what it means", () => {
|
||||||
|
const result = splitRoots("/a\nrelative/path\n/b", home);
|
||||||
|
expect(result.roots).toEqual(["/a", "/b"]);
|
||||||
|
expect(result.rejected).toEqual(["relative/path"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one copy of a repeated root", () => {
|
||||||
|
expect(splitRoots("/a\n/a", home).roots).toEqual(["/a"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolvePath", () => {
|
||||||
|
let home: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
home = await makeHome("lam-files-");
|
||||||
|
await mkdir(path.join(home, "Downloads", "reports"), { recursive: true });
|
||||||
|
await writeFile(path.join(home, "Downloads", "report.md"), "hello\n");
|
||||||
|
await mkdir(path.join(home, "outside"), { recursive: true });
|
||||||
|
// A symlink that points out of the write roots, which the policy must judge by
|
||||||
|
// where it lands rather than by where it is written.
|
||||||
|
await symlink(path.join(home, "outside"), path.join(home, "Downloads", "escape"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const policy = (): PathPolicy => createPolicy({ home, supportPath: path.join(home, "support") });
|
||||||
|
|
||||||
|
it("returns the real path of a file that exists", async () => {
|
||||||
|
const result = await resolvePath(path.join(home, "Downloads", "report.md"), policy());
|
||||||
|
expect(result).toMatchObject({ ok: true, path: path.join(home, "Downloads", "report.md") });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a relative path against home", async () => {
|
||||||
|
const result = await resolvePath("Downloads/report.md", policy());
|
||||||
|
expect(result).toMatchObject({ ok: true, path: path.join(home, "Downloads", "report.md") });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands a leading tilde", async () => {
|
||||||
|
const result = await resolvePath("~/Downloads/report.md", policy());
|
||||||
|
expect(result).toMatchObject({ ok: true, path: path.join(home, "Downloads", "report.md") });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the nearest existing ancestor for a path that does not exist yet", async () => {
|
||||||
|
const result = await resolvePath(path.join(home, "Downloads", "new", "file.md"), policy());
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
path: path.join(home, "Downloads", "new", "file.md"),
|
||||||
|
missing: true,
|
||||||
|
anchor: path.join(home, "Downloads"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves .. before deciding, so it cannot walk out of a root", async () => {
|
||||||
|
const result = await resolvePath(path.join(home, "Downloads", "..", "..", "etc"), policy());
|
||||||
|
expect(result).toMatchObject({ ok: true, path: path.resolve(home, "..", "etc") });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows a symlink to where it lands", async () => {
|
||||||
|
const result = await resolvePath(path.join(home, "Downloads", "escape", "x.md"), policy());
|
||||||
|
expect(result).toMatchObject({ ok: true, path: path.join(home, "outside", "x.md") });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an empty path or one with a null byte", async () => {
|
||||||
|
expect((await resolvePath("", policy())).ok).toBe(false);
|
||||||
|
expect((await resolvePath("a\0b", policy())).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readable and writable", () => {
|
||||||
|
const home = "/Users/me";
|
||||||
|
const policy = createPolicy({ home, supportPath: "/Users/me/support" });
|
||||||
|
|
||||||
|
it("reads anything under home", () => {
|
||||||
|
expect(readDenial(path.join(home, "Projects", "a.ts"), policy)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses anything outside home", () => {
|
||||||
|
expect(readDenial("/etc/passwd", policy)).toContain("outside the paths");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses each credential store by name", () => {
|
||||||
|
for (const denied of [
|
||||||
|
path.join(home, ".ssh", "id_rsa"),
|
||||||
|
path.join(home, ".gnupg", "secring.gpg"),
|
||||||
|
path.join(home, ".aws", "credentials"),
|
||||||
|
path.join(home, ".config", "gh", "hosts.yml"),
|
||||||
|
path.join(home, ".netrc"),
|
||||||
|
path.join(home, ".docker", "config.json"),
|
||||||
|
path.join(home, "Projects", ".env"),
|
||||||
|
path.join(home, "Projects", ".env.local"),
|
||||||
|
path.join(home, "keys", "server.pem"),
|
||||||
|
path.join(home, "keys", "server.key"),
|
||||||
|
path.join(home, "keys", "cert.p12"),
|
||||||
|
path.join(home, "creds", "credentials.json"),
|
||||||
|
path.join(home, "keys", "id_ed25519"),
|
||||||
|
]) {
|
||||||
|
expect(denialFor(denied, policy), denied).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a near miss alone", () => {
|
||||||
|
// Names that merely resemble the rules: a denylist that over-matches teaches the
|
||||||
|
// user that the feature is broken rather than careful.
|
||||||
|
for (const allowed of [
|
||||||
|
path.join(home, "Projects", "environment.md"),
|
||||||
|
path.join(home, "Projects", ".envrc.example"),
|
||||||
|
path.join(home, "Documents", "keys.keynote"),
|
||||||
|
path.join(home, "Documents", "id_ed25519.pub"),
|
||||||
|
path.join(home, "Downloads", "pem"),
|
||||||
|
]) {
|
||||||
|
expect(denialFor(allowed, policy), allowed).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes only inside the write roots", () => {
|
||||||
|
expect(writeDenial(path.join(home, "Downloads", "a.md"), policy)).toBeUndefined();
|
||||||
|
expect(writeDenial(path.join(home, "Documents", "Lam Agent", "a.md"), policy)).toBeUndefined();
|
||||||
|
expect(writeDenial("/Users/me/support/a.md", policy)).toBeUndefined();
|
||||||
|
expect(writeDenial(path.join(home, "Documents", "taxes.md"), policy)).toContain("outside the paths");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the roots the preferences name", () => {
|
||||||
|
const extended = createPolicy({
|
||||||
|
home,
|
||||||
|
readRoots: ["/Volumes/Archive"],
|
||||||
|
writeRoots: ["/Volumes/Archive/scratch"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readDenial("/Volumes/Archive/db.sqlite", extended)).toBeUndefined();
|
||||||
|
expect(writeDenial("/Volumes/Archive/scratch/a.md", extended)).toBeUndefined();
|
||||||
|
expect(writeDenial("/Volumes/Archive/db.sqlite", extended)).toContain("outside the paths");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listDirectory", () => {
|
||||||
|
let home: string;
|
||||||
|
let policy: PathPolicy;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
home = await makeHome("lam-list-");
|
||||||
|
policy = createPolicy({ home });
|
||||||
|
await mkdir(path.join(home, "Downloads", "reports"), { recursive: true });
|
||||||
|
await writeFile(path.join(home, "Downloads", "report.md"), "hello\n");
|
||||||
|
await writeFile(path.join(home, "Downloads", ".hidden"), "x\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists directories first, then files, hiding dotfiles by default", async () => {
|
||||||
|
const result = await listDirectory(path.join(home, "Downloads"), policy);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
expect(result.content).toContain("reports/ (directory)");
|
||||||
|
expect(result.content).toContain("report.md (");
|
||||||
|
expect(result.content).not.toContain(".hidden");
|
||||||
|
expect(result.content.indexOf("reports/")).toBeLessThan(result.content.indexOf("report.md"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes dotfiles when asked", async () => {
|
||||||
|
const result = await listDirectory(path.join(home, "Downloads"), policy, { hidden: true });
|
||||||
|
expect(result.ok && result.content).toContain(".hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a directory outside the read roots", async () => {
|
||||||
|
const result = await listDirectory("/etc", policy);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.refused).toBe("policy");
|
||||||
|
expect(result.content).toContain("outside the paths");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a credential directory", async () => {
|
||||||
|
const result = await listDirectory(path.join(home, ".ssh"), policy);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.content).toContain("credentials");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when the path is a file", async () => {
|
||||||
|
const result = await listDirectory(path.join(home, "Downloads", "report.md"), policy);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.content).toContain("read_file");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("states the cap instead of truncating silently", async () => {
|
||||||
|
const many = path.join(home, "Downloads", "many");
|
||||||
|
await mkdir(many, { recursive: true });
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 210 }, (_, index) => writeFile(path.join(many, `f${index}.txt`), "x")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await listDirectory(many, policy);
|
||||||
|
expect(result.ok && result.content).toContain("and 10 more");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readTextFile", () => {
|
||||||
|
let home: string;
|
||||||
|
let policy: PathPolicy;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
home = await makeHome("lam-read-");
|
||||||
|
policy = createPolicy({ home });
|
||||||
|
await writeFile(
|
||||||
|
path.join(home, "lines.txt"),
|
||||||
|
Array.from({ length: 300 }, (_, index) => `line ${index + 1}`).join("\n"),
|
||||||
|
);
|
||||||
|
await writeFile(path.join(home, "binary.bin"), Buffer.from([0x00, 0x01, 0x02, 0x00]));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("numbers the lines it returns", async () => {
|
||||||
|
const result = await readTextFile({ path: path.join(home, "lines.txt") }, policy);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
expect(result.content).toContain("1\tline 1");
|
||||||
|
expect(result.content).toContain("200\tline 200");
|
||||||
|
expect(result.content).not.toContain("line 201");
|
||||||
|
// The reader has to be able to tell there is more, and where to continue.
|
||||||
|
expect(result.content).toContain("of 300");
|
||||||
|
expect(result.content).toContain("continue with offset 201");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues from an offset", async () => {
|
||||||
|
const result = await readTextFile({ path: path.join(home, "lines.txt"), offset: 201, limit: 10 }, policy);
|
||||||
|
expect(result.ok && result.content).toContain("201\tline 201");
|
||||||
|
expect(result.ok && result.content).not.toContain("211\tline 211");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a binary file with its size rather than returning mojibake", async () => {
|
||||||
|
const result = await readTextFile({ path: path.join(home, "binary.bin") }, policy);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.content).toContain("binary");
|
||||||
|
expect(result.content).toContain("bytes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing file as a missing file", async () => {
|
||||||
|
const result = await readTextFile({ path: path.join(home, "nope.txt") }, policy);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.refused).toBe("io");
|
||||||
|
expect(result.content).toContain("nope.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a credential file", async () => {
|
||||||
|
const result = await readTextFile({ path: path.join(home, ".ssh", "id_rsa") }, policy);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.refused).toBe("policy");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("searchFiles", () => {
|
||||||
|
let home: string;
|
||||||
|
let policy: PathPolicy;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
home = await makeHome("lam-search-");
|
||||||
|
policy = createPolicy({ home });
|
||||||
|
await mkdir(path.join(home, "Downloads", "deep", "deeper"), { recursive: true });
|
||||||
|
await writeFile(path.join(home, "Downloads", "notes.md"), "the deploy token is here\n");
|
||||||
|
await writeFile(path.join(home, "Downloads", "other.txt"), "nothing to see\n");
|
||||||
|
await writeFile(path.join(home, "Downloads", "deep", "deeper", "notes.md"), "x\n");
|
||||||
|
await mkdir(path.join(home, ".ssh"), { recursive: true });
|
||||||
|
await writeFile(path.join(home, ".ssh", "notes.md"), "id_rsa\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds files by a name pattern", async () => {
|
||||||
|
const result = await searchFiles({ path: path.join(home, "Downloads"), pattern: "*.md" }, policy);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
expect(result.content).toContain("notes.md");
|
||||||
|
expect(result.content).not.toContain("other.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches content when asked, and only returns the files that match", async () => {
|
||||||
|
const result = await searchFiles(
|
||||||
|
{ path: path.join(home, "Downloads"), pattern: "*", contains: "deploy token" },
|
||||||
|
policy,
|
||||||
|
);
|
||||||
|
expect(result.ok && result.content).toContain("notes.md");
|
||||||
|
expect(result.ok && result.content).not.toContain("other.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a credential store, even by name", async () => {
|
||||||
|
const result = await searchFiles({ path: home, pattern: "notes.md" }, policy);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
expect(result.content).not.toContain(".ssh");
|
||||||
|
expect(result.content).toContain("Downloads/notes.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a pattern with no regular-expression meaning as a literal", async () => {
|
||||||
|
const result = await searchFiles({ path: path.join(home, "Downloads"), pattern: "notes.md" }, policy);
|
||||||
|
expect(result.ok && result.content).toContain("notes.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing was found rather than failing", async () => {
|
||||||
|
const result = await searchFiles({ path: path.join(home, "Downloads"), pattern: "*.pdf" }, policy);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.ok && result.content).toContain("No files");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the mutating operations", () => {
|
||||||
|
let home: string;
|
||||||
|
let policy: PathPolicy;
|
||||||
|
let asked: ApprovalRequest[];
|
||||||
|
const allow = async (request: ApprovalRequest) => {
|
||||||
|
asked.push(request);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const deny = async (request: ApprovalRequest) => {
|
||||||
|
asked.push(request);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
home = await makeHome("lam-write-");
|
||||||
|
policy = createPolicy({ home });
|
||||||
|
asked = [];
|
||||||
|
await mkdir(path.join(home, "Downloads"), { recursive: true });
|
||||||
|
await writeFile(path.join(home, "Downloads", "report.md"), "one\ntwo\nthree\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a file after approval and says what it wrote", async () => {
|
||||||
|
const result = await writeTextFile(
|
||||||
|
{ path: path.join(home, "Downloads", "new.md"), content: "hello\n" },
|
||||||
|
policy,
|
||||||
|
allow,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok && result.content).toContain("Wrote");
|
||||||
|
expect(asked).toHaveLength(1);
|
||||||
|
expect(asked[0].operation).toBe("create");
|
||||||
|
expect(asked[0].details.join(" ")).toContain("New content is 6 bytes, 2 lines");
|
||||||
|
expect(await readFile(path.join(home, "Downloads", "new.md"), "utf8")).toBe("hello\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discloses what an overwrite replaces", async () => {
|
||||||
|
await writeTextFile(
|
||||||
|
{ path: path.join(home, "Downloads", "report.md"), content: "replaced\n" },
|
||||||
|
policy,
|
||||||
|
allow,
|
||||||
|
);
|
||||||
|
expect(asked[0].operation).toBe("overwrite");
|
||||||
|
expect(asked[0].details.join(" ")).toContain("Replaces 14 bytes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates missing folders, and says so before it does", async () => {
|
||||||
|
await writeTextFile(
|
||||||
|
{ path: path.join(home, "Downloads", "reports", "may.md"), content: "x\n" },
|
||||||
|
policy,
|
||||||
|
allow,
|
||||||
|
);
|
||||||
|
expect(asked[0].details.join(" ")).toContain("Creates the folder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes nothing when the user declines", async () => {
|
||||||
|
const result = await writeTextFile(
|
||||||
|
{ path: path.join(home, "Downloads", "new.md"), content: "hello\n" },
|
||||||
|
policy,
|
||||||
|
deny,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.refused).toBe("approval");
|
||||||
|
expect(result.content).toContain("declined");
|
||||||
|
await expect(readFile(path.join(home, "Downloads", "new.md"))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never asks about a path outside the write roots", async () => {
|
||||||
|
const result = await writeTextFile(
|
||||||
|
{ path: path.join(home, "Documents", "taxes.md"), content: "x" },
|
||||||
|
policy,
|
||||||
|
allow,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(asked).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edits a unique occurrence and reports the count", async () => {
|
||||||
|
const result = await editTextFile(
|
||||||
|
{ path: path.join(home, "Downloads", "report.md"), oldString: "two", newString: "TWO" },
|
||||||
|
policy,
|
||||||
|
allow,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok && result.content).toContain("1 occurrence");
|
||||||
|
expect(await readFile(path.join(home, "Downloads", "report.md"), "utf8")).toContain("TWO");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the substitution in the approval", async () => {
|
||||||
|
await editTextFile(
|
||||||
|
{ path: path.join(home, "Downloads", "report.md"), oldString: "two", newString: "TWO" },
|
||||||
|
policy,
|
||||||
|
allow,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asked[0].details.join(" ")).toContain("- two");
|
||||||
|
expect(asked[0].details.join(" ")).toContain("+ TWO");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an ambiguous edit rather than guessing which one", async () => {
|
||||||
|
await writeFile(path.join(home, "Downloads", "twice.md"), "same\nsame\n");
|
||||||
|
const result = await editTextFile(
|
||||||
|
{ path: path.join(home, "Downloads", "twice.md"), oldString: "same", newString: "other" },
|
||||||
|
policy,
|
||||||
|
allow,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.content).toContain("2 times");
|
||||||
|
expect(result.content).toContain("all");
|
||||||
|
expect(asked).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces every occurrence when asked to", async () => {
|
||||||
|
await writeFile(path.join(home, "Downloads", "twice.md"), "same\nsame\n");
|
||||||
|
const result = await editTextFile(
|
||||||
|
{
|
||||||
|
path: path.join(home, "Downloads", "twice.md"),
|
||||||
|
oldString: "same",
|
||||||
|
newString: "other",
|
||||||
|
all: true,
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
allow,
|
||||||
|
);
|
||||||
|
expect(result.ok && result.content).toContain("2 occurrences");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an edit whose text is not there", async () => {
|
||||||
|
const result = await editTextFile(
|
||||||
|
{ path: path.join(home, "Downloads", "report.md"), oldString: "absent", newString: "x" },
|
||||||
|
policy,
|
||||||
|
allow,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.content).toContain("does not contain");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a file after approval, and says it cannot be undone", async () => {
|
||||||
|
const result = await deletePath(path.join(home, "Downloads", "report.md"), policy, allow);
|
||||||
|
|
||||||
|
expect(result.ok && result.content).toContain("Deleted");
|
||||||
|
expect(asked[0].operation).toBe("delete");
|
||||||
|
expect(asked[0].irreversible).toBe(true);
|
||||||
|
expect(asked[0].details.join(" ")).toContain("cannot be undone");
|
||||||
|
await expect(readFile(path.join(home, "Downloads", "report.md"))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a directory that has anything in it", async () => {
|
||||||
|
await mkdir(path.join(home, "Downloads", "stuff"));
|
||||||
|
await writeFile(path.join(home, "Downloads", "stuff", "a.md"), "x");
|
||||||
|
|
||||||
|
const result = await deletePath(path.join(home, "Downloads", "stuff"), policy, allow);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.content).toContain("1 entry");
|
||||||
|
expect(result.content).toContain("recursively");
|
||||||
|
expect(asked).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes an empty directory", async () => {
|
||||||
|
await mkdir(path.join(home, "Downloads", "empty"));
|
||||||
|
const result = await deletePath(path.join(home, "Downloads", "empty"), policy, allow);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a file and names both ends in the request", async () => {
|
||||||
|
const result = await movePath(
|
||||||
|
{
|
||||||
|
from: path.join(home, "Downloads", "report.md"),
|
||||||
|
to: path.join(home, "Downloads", "renamed.md"),
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
allow,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok && result.content).toContain("Moved");
|
||||||
|
expect(asked[0].subject).toBe("report.md to renamed.md");
|
||||||
|
expect(asked[0].irreversible).toBe(true);
|
||||||
|
expect(await readFile(path.join(home, "Downloads", "renamed.md"), "utf8")).toContain("one");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to move onto an existing path", async () => {
|
||||||
|
await writeFile(path.join(home, "Downloads", "other.md"), "keep me\n");
|
||||||
|
const result = await movePath(
|
||||||
|
{
|
||||||
|
from: path.join(home, "Downloads", "report.md"),
|
||||||
|
to: path.join(home, "Downloads", "other.md"),
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
allow,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.content).toContain("already exists");
|
||||||
|
expect(await readFile(path.join(home, "Downloads", "other.md"), "utf8")).toBe("keep me\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to move into a path it may not write", async () => {
|
||||||
|
const result = await movePath(
|
||||||
|
{
|
||||||
|
from: path.join(home, "Downloads", "report.md"),
|
||||||
|
to: path.join(home, "Documents", "report.md"),
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
allow,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(asked).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,16 @@ import { buildMessages, composeSystemPrompt, describeRuntime, type RuntimeContex
|
|||||||
import type { ChatTurn } from "../lib/types";
|
import type { ChatTurn } from "../lib/types";
|
||||||
|
|
||||||
function turn(prompt: string, response: string): ChatTurn {
|
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", () => {
|
describe("buildMessages", () => {
|
||||||
@@ -179,3 +188,46 @@ describe("buildMessages images", () => {
|
|||||||
expect(messages.some((message) => message.images !== undefined)).toBe(false);
|
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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -296,3 +296,37 @@ 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ describe("resolveStartup", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not resume when a session was pushed in by hand", async () => {
|
it("does not resume when a session was pushed in by hand", async () => {
|
||||||
// The sessions command already knows which conversation to show.
|
// The Chat History command already knows which conversation to show.
|
||||||
const p = ports();
|
const p = ports();
|
||||||
|
|
||||||
await resolveStartup(input({ hasInitialSession: true }), p);
|
await resolveStartup(input({ hasInitialSession: true }), p);
|
||||||
|
|||||||
+157
-3
@@ -1,6 +1,10 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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 { createSourceList } from "../lib/citations";
|
||||||
import { TOOL_DEFINITIONS, WEB_TOOL_NAMES, executeTool, toolsFor } from "../lib/tools";
|
import { createPolicy, type ApproveFn } from "../lib/files";
|
||||||
|
import { TOOL_DEFINITIONS, WEB_TOOL_NAMES, executeTool, toolsFor, type ToolContext } from "../lib/tools";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Direct coverage for the tool layer.
|
* Direct coverage for the tool layer.
|
||||||
@@ -40,10 +44,12 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("tool registration", () => {
|
describe("tool registration", () => {
|
||||||
it("exposes all four tools to the model", () => {
|
it("exposes the four always-available tools to the model", () => {
|
||||||
|
// The file tools are in the list too, but they are gated by a preference, so the
|
||||||
|
// unconditional ones are what this pins.
|
||||||
const names = TOOL_DEFINITIONS.map((tool) => tool.function.name);
|
const names = TOOL_DEFINITIONS.map((tool) => tool.function.name);
|
||||||
|
|
||||||
expect(names).toEqual(["get_datetime", "get_location", "web_search", "web_fetch"]);
|
expect(names.slice(0, 4)).toEqual(["get_datetime", "get_location", "web_search", "web_fetch"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks both web tools as cloud-dependent", () => {
|
it("marks both web tools as cloud-dependent", () => {
|
||||||
@@ -469,3 +475,151 @@ describe("dispatch", () => {
|
|||||||
expect(fetchMock).not.toHaveBeenCalled();
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function SessionsCommand() {
|
|||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
isLoading={!loaded}
|
isLoading={!loaded}
|
||||||
navigationTitle="Chat Sessions"
|
navigationTitle="Chat History"
|
||||||
searchBarPlaceholder="Search conversations…"
|
searchBarPlaceholder="Search conversations…"
|
||||||
>
|
>
|
||||||
{loaded && items.length === 0 ? (
|
{loaded && items.length === 0 ? (
|
||||||
+1
-1
@@ -6,7 +6,7 @@ import { readSnipContext } from "./lib/snip";
|
|||||||
* Entrypoint for the "chat" command.
|
* Entrypoint for the "chat" command.
|
||||||
*
|
*
|
||||||
* Deliberately thin: everything lives in {@link ChatView} so the same view can
|
* Deliberately thin: everything lives in {@link ChatView} so the same view can
|
||||||
* also be pushed onto the navigation stack from the sessions command.
|
* also be pushed onto the navigation stack from the Chat History command.
|
||||||
*
|
*
|
||||||
* Assign this command a global shortcut in Vicinae's settings to get the
|
* Assign this command a global shortcut in Vicinae's settings to get the
|
||||||
* launcher-and-start-typing behaviour. `fallbackText` is populated when the
|
* launcher-and-start-typing behaviour. `fallbackText` is populated when the
|
||||||
|
|||||||
@@ -249,6 +249,10 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
|
|||||||
now: options.toolContext.now,
|
now: options.toolContext.now,
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
sources,
|
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 = {
|
const record: ToolCallRecord = {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Alert, confirmAlert } from "@vicinae/api";
|
||||||
|
import type { ApprovalRequest, ApproveFn, FileOperation } from "./files";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The approval step, and the only file in this feature that imports Vicinae.
|
||||||
|
*
|
||||||
|
* Kept apart from `files.ts` on purpose: the policy there is a value and this is a
|
||||||
|
* dialog, so the boundary can be tested without a launcher and the wording can be
|
||||||
|
* changed without touching a path rule.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FileApproval {
|
||||||
|
title: string;
|
||||||
|
/** Every line the user needs: the path first, then what changes. */
|
||||||
|
body: string;
|
||||||
|
/** A delete or a move, which the dialog styles differently. */
|
||||||
|
destructive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERBS: Record<FileOperation, string> = {
|
||||||
|
create: "Create",
|
||||||
|
overwrite: "Overwrite",
|
||||||
|
edit: "Edit",
|
||||||
|
delete: "Delete",
|
||||||
|
move: "Move",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a request into the words the user reads.
|
||||||
|
*
|
||||||
|
* The path is the second thing in the body and is never omitted: it is the thing
|
||||||
|
* being approved, and a dialog that said "Lam wants to write a file" would leave the
|
||||||
|
* user with nothing to judge.
|
||||||
|
*/
|
||||||
|
export function describeApproval(request: ApprovalRequest): FileApproval {
|
||||||
|
return {
|
||||||
|
title: `${VERBS[request.operation]} ${request.subject}?`,
|
||||||
|
body: [request.display, ...request.details].join("\n"),
|
||||||
|
destructive: request.irreversible,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ledger: one approval per resolved path, per answer, except for the
|
||||||
|
* irreversible operations, which ask every time.
|
||||||
|
*
|
||||||
|
* Per answer rather than per session because a session can run for hours, and because
|
||||||
|
* "yes, edit that file while we work on it" is not consent to whatever Lam decides to
|
||||||
|
* do with it twenty minutes later.
|
||||||
|
*
|
||||||
|
* Keyed on the resolved path, so `~/Downloads/x.md` and `/Users/me/Downloads/x.md`
|
||||||
|
* are one entry: they are one file, and keying on the written form would let a second
|
||||||
|
* spelling of the same path slip past an approval the user already gave for it.
|
||||||
|
*/
|
||||||
|
export function createApprovalLedger(confirm: (approval: FileApproval) => Promise<boolean>): {
|
||||||
|
approve: ApproveFn;
|
||||||
|
} {
|
||||||
|
const approved = new Set<string>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
async approve(request: ApprovalRequest): Promise<boolean> {
|
||||||
|
if (!request.irreversible && approved.has(request.path)) return true;
|
||||||
|
|
||||||
|
// A dialog that cannot be shown is not consent, and neither is an exception
|
||||||
|
// thrown while trying to show one, so both come back as a refusal. This is the
|
||||||
|
// fail-closed half of the approval story, and it is here rather than at a call
|
||||||
|
// site because every call site would have to remember it.
|
||||||
|
const granted = await confirm(describeApproval(request)).catch(() => false);
|
||||||
|
if (granted && !request.irreversible) approved.add(request.path);
|
||||||
|
|
||||||
|
return granted;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dialog itself.
|
||||||
|
*
|
||||||
|
* Vicinae's `rememberUserChoice` is declared in the API but not implemented in the
|
||||||
|
* server, so nothing here offers "do not ask again": the ledger above is the only
|
||||||
|
* memory, and it lasts one answer.
|
||||||
|
*/
|
||||||
|
export function confirmWithAlert(approval: FileApproval): Promise<boolean> {
|
||||||
|
return confirmAlert({
|
||||||
|
title: approval.title,
|
||||||
|
message: approval.body,
|
||||||
|
primaryAction: {
|
||||||
|
title: "Allow",
|
||||||
|
style: approval.destructive ? Alert.ActionStyle.Destructive : Alert.ActionStyle.Default,
|
||||||
|
},
|
||||||
|
dismissAction: { title: "Decline", style: Alert.ActionStyle.Cancel },
|
||||||
|
});
|
||||||
|
}
|
||||||
+29
-1
@@ -16,7 +16,7 @@ export const STORAGE_INDEX_KEY = "lam.sessions.index";
|
|||||||
export const STORAGE_SESSION_PREFIX = "lam.session.";
|
export const STORAGE_SESSION_PREFIX = "lam.session.";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The model picked in the Models command.
|
* The model picked in the Manage Models command.
|
||||||
*
|
*
|
||||||
* Separate from the `model` preference because an extension cannot write its own
|
* 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
|
* preferences: Vicinae reads them once at startup and holds them in memory, so
|
||||||
@@ -107,3 +107,31 @@ Say what you are unsure about rather than hedging.`;
|
|||||||
/** Extra instruction appended when the user has web tools enabled and the question looks time-sensitive. */
|
/** Extra instruction appended when the user has web tools enabled and the question looks time-sensitive. */
|
||||||
export const RECENCY_HINT =
|
export const RECENCY_HINT =
|
||||||
"If the answer depends on current events, prices, versions, or releases, use web_search before answering.";
|
"If the answer depends on current events, prices, versions, or releases, use web_search before answering.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File access caps.
|
||||||
|
*
|
||||||
|
* Every one of these exists because the alternative is a tool that can swallow a
|
||||||
|
* turn's whole context window or a worker's heap: a directory with 40,000 entries,
|
||||||
|
* a log file of 900 MB, a search that walks a home directory to its leaves. Each cap
|
||||||
|
* is stated in the tool's own output when it bites, so the model knows it is looking
|
||||||
|
* at part of something rather than all of it.
|
||||||
|
*/
|
||||||
|
export const MAX_READ_LINES = 200;
|
||||||
|
export const MAX_READ_CHARS = 8000;
|
||||||
|
export const MAX_LIST_ENTRIES = 200;
|
||||||
|
export const MAX_SEARCH_RESULTS = 50;
|
||||||
|
export const MAX_SEARCH_DEPTH = 8;
|
||||||
|
export const MAX_SEARCH_BYTES = 64 * 1024;
|
||||||
|
export const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file paragraph added to the runtime description when the tools are on.
|
||||||
|
*
|
||||||
|
* Separate from `describeRuntime` because it is the part that does not change with
|
||||||
|
* the configured roots: what a refusal means, and the one thing about file reading
|
||||||
|
* that the user cannot see from the dialog, which is that the contents leave the
|
||||||
|
* machine on their way to the model.
|
||||||
|
*/
|
||||||
|
export const FILE_TOOL_GUIDANCE =
|
||||||
|
"A refused change is final: say what you could not do rather than trying again. Anything you read is sent to the model provider with the conversation, so read what the question needs and nothing more, and read a large file in parts with offset and limit instead of asking for the whole thing.";
|
||||||
|
|||||||
@@ -0,0 +1,821 @@
|
|||||||
|
import { mkdir, readFile, readdir, realpath, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import {
|
||||||
|
MAX_FILE_BYTES,
|
||||||
|
MAX_LIST_ENTRIES,
|
||||||
|
MAX_READ_CHARS,
|
||||||
|
MAX_READ_LINES,
|
||||||
|
MAX_SEARCH_BYTES,
|
||||||
|
MAX_SEARCH_DEPTH,
|
||||||
|
MAX_SEARCH_RESULTS,
|
||||||
|
} from "./constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where Lam may read and write, and how a written path becomes a real one.
|
||||||
|
*
|
||||||
|
* No Vicinae import anywhere in this file, and no dialog: the policy is a value a
|
||||||
|
* test can build, and approval arrives as a callback from the caller. That split is
|
||||||
|
* what makes the whole boundary testable without a running launcher, and it is also
|
||||||
|
* why every function here returns a result rather than throwing: a refusal and an
|
||||||
|
* I/O failure reach the model in the same shape.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PolicyOptions {
|
||||||
|
/** The home directory. Overridden in tests, which is why it is not read inline. */
|
||||||
|
home?: string;
|
||||||
|
/** `environment.supportPath`, the one directory an extension is invited to write to. */
|
||||||
|
supportPath?: string;
|
||||||
|
/** Extra read roots from the preference. */
|
||||||
|
readRoots?: string[];
|
||||||
|
/** Extra write roots from the preference. */
|
||||||
|
writeRoots?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PathPolicy {
|
||||||
|
home: string;
|
||||||
|
readRoots: string[];
|
||||||
|
writeRoots: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directories under home that hold credentials, matched on the resolved path. */
|
||||||
|
const DENIED_DIRECTORIES = [".ssh", ".gnupg", ".aws", ".config/gh"];
|
||||||
|
/** Files under home that hold credentials. */
|
||||||
|
const DENIED_FILES = [".netrc", ".docker/config.json"];
|
||||||
|
/** Basenames that are key material or credentials wherever they sit. */
|
||||||
|
const DENIED_BASENAMES = ["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "credentials.json"];
|
||||||
|
/** Extensions that mean key material. */
|
||||||
|
const DENIED_EXTENSIONS = [".pem", ".key", ".p12"];
|
||||||
|
/** A basename that is an environment file, exactly or with a dot suffix. */
|
||||||
|
const DENIED_PREFIXES = [".env"];
|
||||||
|
|
||||||
|
/** Directories a search does not walk into, because they are noise and they are large. */
|
||||||
|
const SKIP_DIRECTORIES = [".git", "node_modules"];
|
||||||
|
|
||||||
|
function unique(values: string[]): string[] {
|
||||||
|
return [...new Set(values.filter((value) => value.length > 0))];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPolicy(options: PolicyOptions = {}): PathPolicy {
|
||||||
|
const home = options.home ?? homedir();
|
||||||
|
const under = (...parts: string[]) => path.join(home, ...parts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
home,
|
||||||
|
readRoots: unique([home, ...(options.readRoots ?? [])]),
|
||||||
|
// Downloads and a folder of Lam's own are the two the user named. The support
|
||||||
|
// directory comes third because it is where the extension is invited to write,
|
||||||
|
// which makes it the one place a failure cannot be blamed on a guessed path.
|
||||||
|
writeRoots: unique([
|
||||||
|
under("Downloads"),
|
||||||
|
under("Documents", "Lam Agent"),
|
||||||
|
...(options.supportPath ? [options.supportPath] : []),
|
||||||
|
...(options.writeRoots ?? []),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RootList {
|
||||||
|
roots: string[];
|
||||||
|
/** Entries that are not absolute paths, reported rather than silently dropped. */
|
||||||
|
rejected: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a roots preference: one path per line, or comma separated.
|
||||||
|
*
|
||||||
|
* A rejected entry does not disable the others, and a root that does not exist yet is
|
||||||
|
* kept, because a write root may be created after it is configured. Relative entries
|
||||||
|
* are rejected rather than resolved: in a tool argument a relative path has an obvious
|
||||||
|
* meaning (the user's home), and in a setting it has none.
|
||||||
|
*/
|
||||||
|
export function splitRoots(value: string | undefined, home: string = homedir()): RootList {
|
||||||
|
const roots: string[] = [];
|
||||||
|
const rejected: string[] = [];
|
||||||
|
|
||||||
|
for (const piece of (value ?? "").split(/[\n,]/)) {
|
||||||
|
const trimmed = piece.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
const expanded = expandHome(trimmed, home);
|
||||||
|
if (!path.isAbsolute(expanded)) {
|
||||||
|
rejected.push(trimmed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
roots.push(expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { roots: unique(roots), rejected: unique(rejected) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `~` and `~/x` become home. Anything else is left alone. */
|
||||||
|
function expandHome(written: string, home: string): string {
|
||||||
|
if (written === "~") return home;
|
||||||
|
if (written.startsWith("~/")) return path.join(home, written.slice(2));
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `target` is `root` itself or sits under it, compared component by component. */
|
||||||
|
export function isWithin(target: string, root: string): boolean {
|
||||||
|
if (target === root) return true;
|
||||||
|
|
||||||
|
// The separator is the whole point: comparing raw prefixes would put
|
||||||
|
// /Users/me/Downloads-evil inside /Users/me/Downloads.
|
||||||
|
const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
|
||||||
|
return target.startsWith(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PathResolution {
|
||||||
|
ok: true;
|
||||||
|
/** The absolute, symlink-free path, or the nearest real ancestor plus the rest. */
|
||||||
|
path: string;
|
||||||
|
/** The nearest existing directory, which is what containment is judged against. */
|
||||||
|
anchor: string;
|
||||||
|
/** True when the path itself does not exist, so a create is what happens. */
|
||||||
|
missing: boolean;
|
||||||
|
}
|
||||||
|
export type ResolveResult = PathResolution | { ok: false; reason: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a path the model wrote into a real one.
|
||||||
|
*
|
||||||
|
* The order matters and is the whole point: expand, resolve, then `realpath`, then
|
||||||
|
* judge containment. Checking containment before `realpath` would decide about a path
|
||||||
|
* by where it is written rather than where it lands, and a symlink inside a write root
|
||||||
|
* would then be a way to write anywhere.
|
||||||
|
*/
|
||||||
|
export async function resolvePath(written: unknown, policy: PathPolicy): Promise<ResolveResult> {
|
||||||
|
if (typeof written !== "string" || written.trim().length === 0) {
|
||||||
|
return { ok: false, reason: "A path is required, as a string." };
|
||||||
|
}
|
||||||
|
if (written.includes("\0")) {
|
||||||
|
return { ok: false, reason: "That path contains a null byte, which no real path has." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expanded = expandHome(written.trim(), policy.home);
|
||||||
|
// A relative path is read against home rather than the working directory: a launcher
|
||||||
|
// command has no meaningful working directory.
|
||||||
|
const absolute = path.resolve(policy.home, expanded);
|
||||||
|
const { real, anchor, missing } = await realpathChain(absolute);
|
||||||
|
|
||||||
|
return { ok: true, path: real, anchor, missing };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The real path of a location that may not exist yet.
|
||||||
|
*
|
||||||
|
* `realpath` fails on a path that is not there, and the create case is the one that
|
||||||
|
* matters most, so the nearest existing ancestor is resolved instead and the rest of
|
||||||
|
* the path is re-appended. That ancestor is also returned, because it is what the
|
||||||
|
* write check is judged against.
|
||||||
|
*/
|
||||||
|
async function realpathChain(absolute: string): Promise<{ real: string; anchor: string; missing: boolean }> {
|
||||||
|
const trailing: string[] = [];
|
||||||
|
let current = absolute;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const real = await realpath(current);
|
||||||
|
return {
|
||||||
|
real: path.join(real, ...trailing.reverse()),
|
||||||
|
anchor: real,
|
||||||
|
missing: trailing.length > 0,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
const parent = path.dirname(current);
|
||||||
|
if (parent === current) return { real: absolute, anchor: absolute, missing: true };
|
||||||
|
trailing.push(path.basename(current));
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a path may not be read, or undefined when it may. */
|
||||||
|
export function denialFor(resolved: string, policy: PathPolicy): string | undefined {
|
||||||
|
for (const directory of DENIED_DIRECTORIES) {
|
||||||
|
if (isWithin(resolved, path.join(policy.home, directory))) {
|
||||||
|
return `it is inside ~/${directory}, which holds credentials`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of DENIED_FILES) {
|
||||||
|
if (resolved === path.join(policy.home, file)) return `it is ~/${file}, which holds credentials`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = path.basename(resolved);
|
||||||
|
|
||||||
|
if (DENIED_BASENAMES.includes(base)) return `its name, ${base}, marks it as a credential file`;
|
||||||
|
|
||||||
|
const extension = path.extname(base).toLowerCase();
|
||||||
|
if (DENIED_EXTENSIONS.includes(extension)) {
|
||||||
|
return `it is a ${extension} file, which is key material`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DENIED_PREFIXES.some((prefix) => base === prefix || base.startsWith(`${prefix}.`))) {
|
||||||
|
return `its name, ${base}, marks it as an environment file`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a path may not be read, or undefined when it may. */
|
||||||
|
export function readDenial(resolved: string, policy: PathPolicy): string | undefined {
|
||||||
|
const denied = denialFor(resolved, policy);
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
const allowed = policy.readRoots.some((root) => isWithin(resolved, root));
|
||||||
|
return allowed
|
||||||
|
? undefined
|
||||||
|
: `it is outside the paths Lam may read (${describeRoots(policy.readRoots, policy.home)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a path may not be changed, or undefined when it may.
|
||||||
|
*
|
||||||
|
* Deliberately does not consult the denylist: those rules protect against disclosure,
|
||||||
|
* and a write discloses nothing. A write to `~/Downloads/report.pem` is allowed, and
|
||||||
|
* the dialog names the path it is about to replace.
|
||||||
|
*/
|
||||||
|
export function writeDenial(resolved: string, policy: PathPolicy): string | undefined {
|
||||||
|
const allowed = policy.writeRoots.some((root) => isWithin(resolved, root));
|
||||||
|
return allowed
|
||||||
|
? undefined
|
||||||
|
: `it is outside the paths Lam may change (${describeRoots(policy.writeRoots, policy.home)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shortens a path to its `~` form for anything a person reads. */
|
||||||
|
export function displayPath(resolved: string, home: string): string {
|
||||||
|
if (resolved === home) return "~";
|
||||||
|
if (isWithin(resolved, home)) return `~/${path.relative(home, resolved)}`;
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "~ and /Volumes/Archive", for a refusal the model is meant to act on. */
|
||||||
|
function describeRoots(roots: string[], home: string): string {
|
||||||
|
return roots.map((root) => displayPath(root, home)).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileOk {
|
||||||
|
ok: true;
|
||||||
|
content: string;
|
||||||
|
path: string;
|
||||||
|
bytes?: number;
|
||||||
|
}
|
||||||
|
export interface FileRefused {
|
||||||
|
ok: false;
|
||||||
|
/**
|
||||||
|
* `policy` is "there is nothing here you are allowed to touch", `approval` is "the
|
||||||
|
* user said no to this one", `io` is the disk saying no. The three are kept apart
|
||||||
|
* because the model should stop trying in the first case, ask rather than retry in
|
||||||
|
* the second, and can often route around the third.
|
||||||
|
*/
|
||||||
|
refused: "policy" | "approval" | "io";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
export type FileResult = FileOk | FileRefused;
|
||||||
|
|
||||||
|
/** The approval callback the mutating operations call. Supplied by `approval.ts`. */
|
||||||
|
export type ApproveFn = (request: ApprovalRequest) => Promise<boolean>;
|
||||||
|
|
||||||
|
export type FileOperation = "create" | "overwrite" | "edit" | "delete" | "move";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an operation wants to do, as data.
|
||||||
|
*
|
||||||
|
* Describes the request rather than the dialog, so this file stays free of UI and the
|
||||||
|
* wording lives in `approval.ts`. The resolved path is separate from the display form
|
||||||
|
* because the ledger remembers the real path while the user reads the short one.
|
||||||
|
*/
|
||||||
|
export interface ApprovalRequest {
|
||||||
|
operation: FileOperation;
|
||||||
|
/** The resolved path, which is what the ledger keys on. */
|
||||||
|
path: string;
|
||||||
|
/** The path as the user should read it, tilde shortened. */
|
||||||
|
display: string;
|
||||||
|
/** The thing the dialog names, such as a basename, or "a.txt to b.txt". */
|
||||||
|
subject: string;
|
||||||
|
/** Phrases describing what changes, already written out. */
|
||||||
|
details: string[];
|
||||||
|
/** A delete or a move asks every time, because it cannot be undone. */
|
||||||
|
irreversible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refusal(refused: FileRefused["refused"], content: string): FileRefused {
|
||||||
|
return { ok: false, refused, content };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The model never sees a stack trace: an OS error is one sentence about one path. */
|
||||||
|
function ioFailure(error: unknown, display: string): FileRefused {
|
||||||
|
const code = (error as { code?: string })?.code;
|
||||||
|
const message = (error as { message?: string })?.message ?? "unknown error";
|
||||||
|
|
||||||
|
if (code === "ENOENT") return refusal("io", `${display} does not exist.`);
|
||||||
|
if (code === "EACCES" || code === "EPERM") {
|
||||||
|
return refusal("io", `${display} was refused by the operating system (permission denied).`);
|
||||||
|
}
|
||||||
|
if (code === "EISDIR") return refusal("io", `${display} is a directory, not a file.`);
|
||||||
|
if (code === "ENOTDIR") return refusal("io", `${display} is not a directory.`);
|
||||||
|
if (code === "ENOTEMPTY") return refusal("io", `${display} is not empty.`);
|
||||||
|
|
||||||
|
return refusal("io", `${display} could not be read or written: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "1.2 KB", so a size in a dialog is a number a person can judge. */
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1000) return `${bytes} bytes`;
|
||||||
|
if (bytes < 1_000_000) return `${(bytes / 1000).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "3 days ago", for a modification time. */
|
||||||
|
export function describeAge(changed: Date, now: Date): string {
|
||||||
|
const seconds = Math.max(0, Math.round((now.getTime() - changed.getTime()) / 1000));
|
||||||
|
if (seconds < 60) return "just now";
|
||||||
|
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
||||||
|
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||||||
|
|
||||||
|
const days = Math.round(hours / 24);
|
||||||
|
if (days < 30) return `${days} day${days === 1 ? "" : "s"} ago`;
|
||||||
|
|
||||||
|
return changed.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineCount(text: string): number {
|
||||||
|
return text.length === 0 ? 0 : text.split("\n").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line of text, cut to a length a dialog can show. */
|
||||||
|
export function oneLine(text: string, limit = 200): string {
|
||||||
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||||
|
return collapsed.length > limit ? `${collapsed.slice(0, limit)} …` : collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreparedWrite = { ok: true; resolved: PathResolution; display: string };
|
||||||
|
|
||||||
|
/** Resolve, then the write check. The shared opening move of every mutating call. */
|
||||||
|
async function prepareWrite(written: string, policy: PathPolicy): Promise<PreparedWrite | FileRefused> {
|
||||||
|
const resolved = await resolvePath(written, policy);
|
||||||
|
if (!resolved.ok) return refusal("policy", `Refused: ${resolved.reason}`);
|
||||||
|
|
||||||
|
const display = displayPath(resolved.path, policy.home);
|
||||||
|
const denied = writeDenial(resolved.path, policy);
|
||||||
|
if (denied) return refusal("policy", `Refused: ${display} cannot be changed because ${denied}.`);
|
||||||
|
|
||||||
|
return { ok: true, resolved, display };
|
||||||
|
}
|
||||||
|
|
||||||
|
function declined(display: string): FileRefused {
|
||||||
|
return refusal(
|
||||||
|
"approval",
|
||||||
|
`Refused: the user declined to change ${display}. Nothing was written. Ask first rather than trying again.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDirectory(
|
||||||
|
written: string,
|
||||||
|
policy: PathPolicy,
|
||||||
|
options: { hidden?: boolean; now?: Date } = {},
|
||||||
|
): Promise<FileResult> {
|
||||||
|
const resolved = await resolvePath(written, policy);
|
||||||
|
if (!resolved.ok) return refusal("policy", `Refused: ${resolved.reason}`);
|
||||||
|
|
||||||
|
const display = displayPath(resolved.path, policy.home);
|
||||||
|
const denied = readDenial(resolved.path, policy);
|
||||||
|
if (denied) return refusal("policy", `Refused: ${display} cannot be read because ${denied}.`);
|
||||||
|
|
||||||
|
const details = await stat(resolved.path).catch(() => undefined);
|
||||||
|
if (!details) return refusal("io", `${display} does not exist.`);
|
||||||
|
if (!details.isDirectory()) {
|
||||||
|
return refusal("io", `${display} is a file, not a directory. Use read_file to read it.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await readdir(resolved.path, { withFileTypes: true }).catch((error) => error);
|
||||||
|
if (!Array.isArray(entries)) return ioFailure(entries, display);
|
||||||
|
|
||||||
|
const visible = options.hidden ? entries : entries.filter((entry) => !entry.name.startsWith("."));
|
||||||
|
|
||||||
|
const rows = await Promise.all(
|
||||||
|
visible.map(async (entry) => {
|
||||||
|
const full = path.join(resolved.path, entry.name);
|
||||||
|
const info = await stat(full).catch(() => undefined);
|
||||||
|
return {
|
||||||
|
directory: entry.isDirectory(),
|
||||||
|
name: entry.name,
|
||||||
|
size: info?.size ?? 0,
|
||||||
|
changed: info?.mtime,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
rows.sort((left, right) => {
|
||||||
|
if (left.directory !== right.directory) return left.directory ? -1 : 1;
|
||||||
|
return left.name.localeCompare(right.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = options.now ?? new Date();
|
||||||
|
const shown = rows.slice(0, MAX_LIST_ENTRIES);
|
||||||
|
const lines = shown.map((row) => {
|
||||||
|
if (row.directory) return `${row.name}/ (directory)`;
|
||||||
|
const age = row.changed ? `, changed ${describeAge(row.changed, now)}` : "";
|
||||||
|
return `${row.name} (${formatBytes(row.size)}${age})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const more =
|
||||||
|
rows.length > shown.length
|
||||||
|
? `\n\nShowing ${shown.length} of ${rows.length} entries, and ${rows.length - shown.length} more.`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
path: resolved.path,
|
||||||
|
content: `${display} holds ${rows.length} entr${rows.length === 1 ? "y" : "ies"}:\n\n${lines
|
||||||
|
.map((line) => `- ${line}`)
|
||||||
|
.join("\n")}${more}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readTextFile(
|
||||||
|
args: { path: string; offset?: number; limit?: number },
|
||||||
|
policy: PathPolicy,
|
||||||
|
): Promise<FileResult> {
|
||||||
|
const resolved = await resolvePath(args.path, policy);
|
||||||
|
if (!resolved.ok) return refusal("policy", `Refused: ${resolved.reason}`);
|
||||||
|
|
||||||
|
const display = displayPath(resolved.path, policy.home);
|
||||||
|
const denied = readDenial(resolved.path, policy);
|
||||||
|
if (denied) return refusal("policy", `Refused: ${display} cannot be read because ${denied}.`);
|
||||||
|
|
||||||
|
const details = await stat(resolved.path).catch(() => undefined);
|
||||||
|
if (!details) return refusal("io", `${display} does not exist.`);
|
||||||
|
if (details.isDirectory()) {
|
||||||
|
return refusal("io", `${display} is a directory. Use list_directory to see what is in it.`);
|
||||||
|
}
|
||||||
|
if (details.size > MAX_FILE_BYTES) {
|
||||||
|
return refusal(
|
||||||
|
"io",
|
||||||
|
`${display} is ${formatBytes(details.size)}, too large to read in one go (the limit is ${formatBytes(MAX_FILE_BYTES)}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await readFile(resolved.path).catch((error) => error);
|
||||||
|
if (!Buffer.isBuffer(bytes)) return ioFailure(bytes, display);
|
||||||
|
|
||||||
|
// A NUL byte in the first stretch is what separates text from everything else, and
|
||||||
|
// detecting it here is why a PNG never comes back as mojibake the model might try
|
||||||
|
// to read anyway.
|
||||||
|
if (bytes.subarray(0, 8192).includes(0)) {
|
||||||
|
return refusal(
|
||||||
|
"io",
|
||||||
|
`${display} is binary (${formatBytes(bytes.length)}), not text, so Lam will not print it.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = bytes.toString("utf8").split("\n");
|
||||||
|
const offset = Math.max(0, (args.offset ?? 1) - 1);
|
||||||
|
const limit = Math.max(1, args.limit ?? MAX_READ_LINES);
|
||||||
|
|
||||||
|
const numbered: string[] = [];
|
||||||
|
let characters = 0;
|
||||||
|
let consumed = offset;
|
||||||
|
|
||||||
|
for (let index = offset; index < lines.length && numbered.length < limit; index++) {
|
||||||
|
const line = `${index + 1}\t${lines[index]}`;
|
||||||
|
// The character cap is the second of two limits and is reported as such, so a long
|
||||||
|
// file is never presented as if it were complete.
|
||||||
|
if (characters + line.length > MAX_READ_CHARS) break;
|
||||||
|
characters += line.length;
|
||||||
|
numbered.push(line);
|
||||||
|
consumed = index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const head =
|
||||||
|
consumed < lines.length
|
||||||
|
? `${display} (showing lines ${offset + 1} to ${consumed} of ${lines.length}; continue with offset ${consumed + 1}):\n\n`
|
||||||
|
: `${display} (${lines.length} lines):\n\n`;
|
||||||
|
|
||||||
|
return { ok: true, path: resolved.path, bytes: bytes.length, content: head + numbered.join("\n") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchFiles(
|
||||||
|
args: { path: string; pattern: string; contains?: string },
|
||||||
|
policy: PathPolicy,
|
||||||
|
): Promise<FileResult> {
|
||||||
|
const resolved = await resolvePath(args.path, policy);
|
||||||
|
if (!resolved.ok) return refusal("policy", `Refused: ${resolved.reason}`);
|
||||||
|
|
||||||
|
const display = displayPath(resolved.path, policy.home);
|
||||||
|
const denied = readDenial(resolved.path, policy);
|
||||||
|
if (denied) return refusal("policy", `Refused: ${display} cannot be searched because ${denied}.`);
|
||||||
|
|
||||||
|
const matcher = globMatcher(args.pattern);
|
||||||
|
const needle = args.contains?.toLowerCase();
|
||||||
|
const found: string[] = [];
|
||||||
|
let capped = false;
|
||||||
|
|
||||||
|
const walk = async (directory: string, depth: number): Promise<void> => {
|
||||||
|
if (found.length >= MAX_SEARCH_RESULTS) {
|
||||||
|
capped = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (depth > MAX_SEARCH_DEPTH) return;
|
||||||
|
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (found.length >= MAX_SEARCH_RESULTS) {
|
||||||
|
capped = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const full = path.join(directory, entry.name);
|
||||||
|
// Every candidate is judged by the same rules as a direct read, so a search can
|
||||||
|
// never become a way around the denylist.
|
||||||
|
if (readDenial(full, policy)) continue;
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (!SKIP_DIRECTORIES.includes(entry.name)) await walk(full, depth + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matcher(entry.name)) continue;
|
||||||
|
|
||||||
|
if (needle) {
|
||||||
|
const bytes = await readFile(full).catch(() => undefined);
|
||||||
|
if (!bytes) continue;
|
||||||
|
if (!bytes.subarray(0, MAX_SEARCH_BYTES).toString("utf8").toLowerCase().includes(needle)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found.push(displayPath(full, policy.home));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const details = await stat(resolved.path).catch(() => undefined);
|
||||||
|
if (!details) return refusal("io", `${display} does not exist.`);
|
||||||
|
if (details.isDirectory()) await walk(resolved.path, 0);
|
||||||
|
else if (matcher(path.basename(resolved.path))) found.push(display);
|
||||||
|
|
||||||
|
if (found.length === 0) {
|
||||||
|
return { ok: true, path: resolved.path, content: `No files under ${display} matched.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = capped ? `\n\nStopped at ${MAX_SEARCH_RESULTS} matches.` : "";
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
path: resolved.path,
|
||||||
|
content: `${found.length} match${found.length === 1 ? "" : "es"} under ${display}:\n\n${found
|
||||||
|
.map((entry) => `- ${entry}`)
|
||||||
|
.join("\n")}${note}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A glob matcher for `*`, `?` and `**`.
|
||||||
|
*
|
||||||
|
* Written here rather than pulled in as a dependency: it is a dozen lines, and the only
|
||||||
|
* thing it is ever handed is a filename pattern written by a model or by the user. The
|
||||||
|
* escape step and the wildcards are one pass with a callback, because two passes need a
|
||||||
|
* placeholder for `**` and a placeholder in a regular expression is a control character
|
||||||
|
* this repository's lint forbids, having already been bitten by that once.
|
||||||
|
*/
|
||||||
|
export function globMatcher(pattern: string): (name: string) => boolean {
|
||||||
|
const escaped = pattern.replace(/[.+^${}()|[\]\\*?]/g, (character) => {
|
||||||
|
if (character === "*") return "[^/]*";
|
||||||
|
if (character === "?") return "[^/]";
|
||||||
|
return `\\${character}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const expression = new RegExp(`^${escaped}$`, "i");
|
||||||
|
return (name: string) => expression.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeTextFile(
|
||||||
|
args: { path: string; content: string },
|
||||||
|
policy: PathPolicy,
|
||||||
|
approve: ApproveFn,
|
||||||
|
options: { now?: Date } = {},
|
||||||
|
): Promise<FileResult> {
|
||||||
|
const prepared = await prepareWrite(args.path, policy);
|
||||||
|
if (!prepared.ok) return prepared;
|
||||||
|
|
||||||
|
const { resolved, display } = prepared;
|
||||||
|
const existing = resolved.missing ? undefined : await stat(resolved.path).catch(() => undefined);
|
||||||
|
if (existing?.isDirectory()) {
|
||||||
|
return refusal("io", `${display} is a directory, and Lam does not overwrite one with a file.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = typeof args.content === "string" ? args.content : "";
|
||||||
|
const bytes = Buffer.byteLength(content, "utf8");
|
||||||
|
const parent = path.dirname(resolved.path);
|
||||||
|
const details: string[] = [];
|
||||||
|
|
||||||
|
if (parent !== resolved.anchor) {
|
||||||
|
details.push(`Creates the folder ${displayPath(parent, policy.home)} as well.`);
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
details.push(
|
||||||
|
`Replaces ${formatBytes(existing.size)}, last changed ${describeAge(existing.mtime, options.now ?? new Date())}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
details.push(`New content is ${formatBytes(bytes)}, ${lineCount(content)} lines.`);
|
||||||
|
|
||||||
|
const request: ApprovalRequest = {
|
||||||
|
operation: existing ? "overwrite" : "create",
|
||||||
|
path: resolved.path,
|
||||||
|
display,
|
||||||
|
subject: path.basename(resolved.path),
|
||||||
|
details,
|
||||||
|
irreversible: false,
|
||||||
|
};
|
||||||
|
if (!(await approve(request))) return declined(display);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(parent, { recursive: true });
|
||||||
|
await writeFile(resolved.path, content, "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
return ioFailure(error, display);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
path: resolved.path,
|
||||||
|
bytes,
|
||||||
|
content: `${existing ? "Overwrote" : "Wrote"} ${display} (${formatBytes(bytes)}, ${lineCount(content)} lines).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editTextFile(
|
||||||
|
args: { path: string; oldString: string; newString: string; all?: boolean },
|
||||||
|
policy: PathPolicy,
|
||||||
|
approve: ApproveFn,
|
||||||
|
): Promise<FileResult> {
|
||||||
|
const prepared = await prepareWrite(args.path, policy);
|
||||||
|
if (!prepared.ok) return prepared;
|
||||||
|
|
||||||
|
const { resolved, display } = prepared;
|
||||||
|
const details = await stat(resolved.path).catch(() => undefined);
|
||||||
|
if (!details) return refusal("io", `${display} does not exist, so there is nothing to edit.`);
|
||||||
|
if (details.isDirectory()) return refusal("io", `${display} is a directory.`);
|
||||||
|
|
||||||
|
if (!args.oldString) return refusal("io", "edit_file needs an old_string to replace.");
|
||||||
|
if (args.oldString === args.newString) {
|
||||||
|
return refusal("io", "old_string and new_string are identical, so nothing would change.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = await readFile(resolved.path, "utf8").catch((error) => error);
|
||||||
|
if (typeof before !== "string") return ioFailure(before, display);
|
||||||
|
|
||||||
|
const occurrences = before.split(args.oldString).length - 1;
|
||||||
|
if (occurrences === 0) {
|
||||||
|
return refusal(
|
||||||
|
"io",
|
||||||
|
`${display} does not contain that text, so nothing was changed. Read the file again and copy the exact characters, including indentation.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (occurrences > 1 && !args.all) {
|
||||||
|
return refusal(
|
||||||
|
"io",
|
||||||
|
`That text appears ${occurrences} times in ${display}, so nothing was changed. Include more surrounding text to make it unique, or pass all: true to replace every one.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: ApprovalRequest = {
|
||||||
|
operation: "edit",
|
||||||
|
path: resolved.path,
|
||||||
|
display,
|
||||||
|
subject: path.basename(resolved.path),
|
||||||
|
details: [
|
||||||
|
`Replaces ${occurrences} occurrence${occurrences === 1 ? "" : "s"}.`,
|
||||||
|
`- ${oneLine(args.oldString)}`,
|
||||||
|
`+ ${oneLine(args.newString)}`,
|
||||||
|
],
|
||||||
|
irreversible: false,
|
||||||
|
};
|
||||||
|
if (!(await approve(request))) return declined(display);
|
||||||
|
|
||||||
|
const after = args.all
|
||||||
|
? before.split(args.oldString).join(args.newString)
|
||||||
|
: before.replace(args.oldString, args.newString);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(resolved.path, after, "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
return ioFailure(error, display);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
path: resolved.path,
|
||||||
|
content: `Edited ${display}: replaced ${occurrences} occurrence${
|
||||||
|
occurrences === 1 ? "" : "s"
|
||||||
|
}. The file is now ${formatBytes(Buffer.byteLength(after, "utf8"))}, ${lineCount(after)} lines.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePath(
|
||||||
|
written: string,
|
||||||
|
policy: PathPolicy,
|
||||||
|
approve: ApproveFn,
|
||||||
|
): Promise<FileResult> {
|
||||||
|
const prepared = await prepareWrite(written, policy);
|
||||||
|
if (!prepared.ok) return prepared;
|
||||||
|
|
||||||
|
const { resolved, display } = prepared;
|
||||||
|
const details = await stat(resolved.path).catch(() => undefined);
|
||||||
|
if (!details) return refusal("io", `${display} does not exist, so there is nothing to delete.`);
|
||||||
|
|
||||||
|
const entries = details.isDirectory()
|
||||||
|
? await readdir(resolved.path).catch(() => [] as string[])
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// A directory with anything in it is refused outright rather than recursed into: this
|
||||||
|
// is the one call where a misread path destroys an unbounded amount of work, and no
|
||||||
|
// confirmation dialog makes that recoverable.
|
||||||
|
if (entries && entries.length > 0) {
|
||||||
|
return refusal(
|
||||||
|
"io",
|
||||||
|
`Refused: ${display} is a directory with ${entries.length} entr${
|
||||||
|
entries.length === 1 ? "y" : "ies"
|
||||||
|
}, and Lam does not delete directories recursively. List it and delete what you actually need.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: ApprovalRequest = {
|
||||||
|
operation: "delete",
|
||||||
|
path: resolved.path,
|
||||||
|
display,
|
||||||
|
subject: path.basename(resolved.path),
|
||||||
|
details: [
|
||||||
|
details.isDirectory()
|
||||||
|
? "This is an empty directory."
|
||||||
|
: `${formatBytes(details.size)}, last changed ${describeAge(details.mtime, new Date())}.`,
|
||||||
|
"This cannot be undone.",
|
||||||
|
],
|
||||||
|
irreversible: true,
|
||||||
|
};
|
||||||
|
if (!(await approve(request))) return declined(display);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (details.isDirectory()) await rmdir(resolved.path);
|
||||||
|
else await unlink(resolved.path);
|
||||||
|
} catch (error) {
|
||||||
|
return ioFailure(error, display);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, path: resolved.path, content: `Deleted ${display}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function movePath(
|
||||||
|
args: { from: string; to: string },
|
||||||
|
policy: PathPolicy,
|
||||||
|
approve: ApproveFn,
|
||||||
|
): Promise<FileResult> {
|
||||||
|
const source = await prepareWrite(args.from, policy);
|
||||||
|
if (!source.ok) return source;
|
||||||
|
|
||||||
|
const target = await prepareWrite(args.to, policy);
|
||||||
|
if (!target.ok) return target;
|
||||||
|
|
||||||
|
const details = await stat(source.resolved.path).catch(() => undefined);
|
||||||
|
if (!details) return refusal("io", `${source.display} does not exist, so there is nothing to move.`);
|
||||||
|
|
||||||
|
const occupied = await stat(target.resolved.path).catch(() => undefined);
|
||||||
|
if (occupied) {
|
||||||
|
return refusal(
|
||||||
|
"io",
|
||||||
|
`Refused: ${target.display} already exists. Nothing was moved, and Lam does not overwrite a file to move another one onto it.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: ApprovalRequest = {
|
||||||
|
operation: "move",
|
||||||
|
path: source.resolved.path,
|
||||||
|
display: `${source.display} to ${target.display}`,
|
||||||
|
subject: `${path.basename(source.resolved.path)} to ${path.basename(target.resolved.path)}`,
|
||||||
|
details: [
|
||||||
|
details.isDirectory()
|
||||||
|
? "This is a directory, and everything in it moves with it."
|
||||||
|
: `Moves ${formatBytes(details.size)}, last changed ${describeAge(details.mtime, new Date())}.`,
|
||||||
|
"This cannot be undone.",
|
||||||
|
],
|
||||||
|
irreversible: true,
|
||||||
|
};
|
||||||
|
if (!(await approve(request))) return declined(request.display);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(path.dirname(target.resolved.path), { recursive: true });
|
||||||
|
await rename(source.resolved.path, target.resolved.path);
|
||||||
|
} catch (error) {
|
||||||
|
return ioFailure(error, source.display);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
path: target.resolved.path,
|
||||||
|
content: `Moved ${source.display} to ${target.display}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
+35
-4
@@ -1,4 +1,4 @@
|
|||||||
import { DEFAULT_SYSTEM_PROMPT, RECENCY_HINT } from "./constants";
|
import { DEFAULT_SYSTEM_PROMPT, FILE_TOOL_GUIDANCE, RECENCY_HINT } from "./constants";
|
||||||
import type { ApiMessage, ChatTurn, VisionSupport } from "./types";
|
import type { ApiMessage, ChatTurn, VisionSupport } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,6 +51,15 @@ export interface RuntimeContext {
|
|||||||
selfHosted: boolean;
|
selfHosted: boolean;
|
||||||
webTools: boolean;
|
webTools: boolean;
|
||||||
vision: VisionSupport;
|
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[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,7 +84,12 @@ export function describeRuntime(context: RuntimeContext): string {
|
|||||||
const lines = [
|
const lines = [
|
||||||
"What you are, for questions about your own setup. Do not speculate beyond this:",
|
"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}.`,
|
`- You are Lam, running inside the Vicinae launcher on the user's desktop, and answering as \`${context.model}\` served by ${served}.`,
|
||||||
"- 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.",
|
// The claim has to match the session, because a model told it cannot read files
|
||||||
|
// while holding file tools will refuse work it could do, and one told the reverse
|
||||||
|
// will promise things no tool here can do.
|
||||||
|
context.fileTools
|
||||||
|
? "- You see what the user types, what they attach, and what you look at with your file tools. You cannot read their screen, clipboard or calendar, and nothing on the machine changes except through a file change the user approved."
|
||||||
|
: "- You see only what the user types or attaches. You cannot read their screen, files, clipboard, calendar or anything else on the machine, and you cannot act on it.",
|
||||||
];
|
];
|
||||||
|
|
||||||
if (context.selfHosted) {
|
if (context.selfHosted) {
|
||||||
@@ -90,6 +104,17 @@ export function describeRuntime(context: RuntimeContext): string {
|
|||||||
: "- You have no web tools in this session. Say that plainly rather than offering to look something up.",
|
: "- 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(
|
lines.push(
|
||||||
context.vision === "yes"
|
context.vision === "yes"
|
||||||
? "- You can read images. A screenshot attached to a question is yours to describe, so describe it."
|
? "- You can read images. A screenshot attached to a question is yours to describe, so describe it."
|
||||||
@@ -107,10 +132,16 @@ export function describeRuntime(context: RuntimeContext): string {
|
|||||||
* The runtime paragraph is appended either way. It is not a matter of style, so
|
* 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
|
* 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.
|
* 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];
|
const parts = [customPrompt.trim() || DEFAULT_SYSTEM_PROMPT];
|
||||||
if (context.webTools) parts.push(RECENCY_HINT);
|
if (context.webTools) parts.push(RECENCY_HINT);
|
||||||
parts.push(describeRuntime(context));
|
parts.push(describeRuntime(context));
|
||||||
return parts.join("\n\n");
|
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]}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { environment } from "@vicinae/api";
|
||||||
import { runAgent } from "../agent";
|
import { runAgent } from "../agent";
|
||||||
|
import { confirmWithAlert, createApprovalLedger } from "../approval";
|
||||||
import { toStoredAttachment } from "../attachments";
|
import { toStoredAttachment } from "../attachments";
|
||||||
|
import { createPolicy, displayPath } from "../files";
|
||||||
import { ipPlace } from "../geo";
|
import { ipPlace } from "../geo";
|
||||||
import { buildMessages, composeSystemPrompt } from "../history";
|
import { buildMessages, composeSystemPrompt } from "../history";
|
||||||
import { isSelfHosted } from "../ollama";
|
import { isSelfHosted } from "../ollama";
|
||||||
@@ -33,7 +36,7 @@ const FLUSH_INTERVAL_MS = 60;
|
|||||||
|
|
||||||
export interface UseChatOptions {
|
export interface UseChatOptions {
|
||||||
preferences: ResolvedPreferences;
|
preferences: ResolvedPreferences;
|
||||||
/** Restores an existing conversation, e.g. when reopening from the sessions command. */
|
/** Restores an existing conversation, e.g. when reopening from the Chat History command. */
|
||||||
initialSession?: Session;
|
initialSession?: Session;
|
||||||
/**
|
/**
|
||||||
* Whether the model can read images, for the system prompt.
|
* Whether the model can read images, for the system prompt.
|
||||||
@@ -205,6 +208,18 @@ export function useChat(options: UseChatOptions): UseChat {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortRef.current = controller;
|
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(
|
const messages = buildMessages(
|
||||||
composeSystemPrompt(preferences.systemPrompt, {
|
composeSystemPrompt(preferences.systemPrompt, {
|
||||||
model: preferences.model,
|
model: preferences.model,
|
||||||
@@ -212,6 +227,11 @@ export function useChat(options: UseChatOptions): UseChat {
|
|||||||
selfHosted: isSelfHosted(preferences.host),
|
selfHosted: isSelfHosted(preferences.host),
|
||||||
webTools: preferences.webEnabled,
|
webTools: preferences.webEnabled,
|
||||||
vision: visionRef.current,
|
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,
|
base.turns,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -233,7 +253,10 @@ export function useChat(options: UseChatOptions): UseChat {
|
|||||||
// cap, so the two conventions meet here.
|
// cap, so the two conventions meet here.
|
||||||
maxToolRounds: preferences.maxToolRounds ?? null,
|
maxToolRounds: preferences.maxToolRounds ?? null,
|
||||||
messages,
|
messages,
|
||||||
tools: toolsFor({ webEnabled: preferences.webEnabled }),
|
tools: toolsFor({
|
||||||
|
webEnabled: preferences.webEnabled,
|
||||||
|
fileEnabled: preferences.fileEnabled,
|
||||||
|
}),
|
||||||
toolContext: {
|
toolContext: {
|
||||||
host: preferences.host,
|
host: preferences.host,
|
||||||
apiKey: preferences.apiKey,
|
apiKey: preferences.apiKey,
|
||||||
@@ -241,6 +264,7 @@ export function useChat(options: UseChatOptions): UseChat {
|
|||||||
// Only wired up when the user switched it on, so an off setting
|
// Only wired up when the user switched it on, so an off setting
|
||||||
// cannot reach the network for a location at all.
|
// cannot reach the network for a location at all.
|
||||||
ipPlace: preferences.ipGeolocation ? ipPlace : undefined,
|
ipPlace: preferences.ipGeolocation ? ipPlace : undefined,
|
||||||
|
files: fileAccess,
|
||||||
},
|
},
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
events: {
|
events: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { loadPreferences, type ResolvedPreferences } from "../preferences";
|
|||||||
* The preferences a view should use, or `undefined` until they have loaded.
|
* The preferences a view should use, or `undefined` until they have loaded.
|
||||||
*
|
*
|
||||||
* Every reader goes through `loadPreferences`, which layers the model chosen in
|
* Every reader goes through `loadPreferences`, which layers the model chosen in
|
||||||
* the Models command on top of what Vicinae holds, and that read is asynchronous
|
* the Manage Models command on top of what Vicinae holds, and that read is asynchronous
|
||||||
* because the selection lives in LocalStorage. A hook rather than a synchronous
|
* 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
|
* 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
|
* component, and a view that forgets to handle the loading case fails to compile
|
||||||
|
|||||||
+30
-3
@@ -1,5 +1,6 @@
|
|||||||
import { getPreferenceValues, LocalStorage } from "@vicinae/api";
|
import { getPreferenceValues, LocalStorage } from "@vicinae/api";
|
||||||
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL, STORAGE_MODEL_KEY } from "./constants";
|
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL, STORAGE_MODEL_KEY } from "./constants";
|
||||||
|
import { splitRoots } from "./files";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preferences, read and normalised.
|
* Preferences, read and normalised.
|
||||||
@@ -24,6 +25,9 @@ export interface LamPreferences {
|
|||||||
resumeTimeout?: string;
|
resumeTimeout?: string;
|
||||||
autoDeleteAfter?: string;
|
autoDeleteAfter?: string;
|
||||||
maxToolRounds?: 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. */
|
/** Where the model in use came from, so the UI can explain a divergence from the preference. */
|
||||||
@@ -35,7 +39,7 @@ export interface ResolvedPreferences {
|
|||||||
model: string;
|
model: string;
|
||||||
/**
|
/**
|
||||||
* Whether {@link ResolvedPreferences.model} came from the preference or from a
|
* Whether {@link ResolvedPreferences.model} came from the preference or from a
|
||||||
* pick in the Models command.
|
* pick in the Manage Models command.
|
||||||
*
|
*
|
||||||
* Carried rather than inferred, because the two disagree silently otherwise:
|
* Carried rather than inferred, because the two disagree silently otherwise:
|
||||||
* the setup screen would show one model while the chat used another, and the
|
* the setup screen would show one model while the chat used another, and the
|
||||||
@@ -62,6 +66,22 @@ export interface ResolvedPreferences {
|
|||||||
/** Whether to resolve a rough place from the IP address. Off unless asked for. */
|
/** Whether to resolve a rough place from the IP address. Off unless asked for. */
|
||||||
ipGeolocation: boolean;
|
ipGeolocation: boolean;
|
||||||
webEnabled: 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. */
|
/** Undefined when reasoning is off, which is what the API client expects. */
|
||||||
think?: string;
|
think?: string;
|
||||||
/** Whether the transcript lists the tools Lam called. */
|
/** Whether the transcript lists the tools Lam called. */
|
||||||
@@ -202,6 +222,13 @@ export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
|
|||||||
ipGeolocation: raw.ipGeolocation ?? false,
|
ipGeolocation: raw.ipGeolocation ?? false,
|
||||||
// Defaults to on: the preference is a checkbox the user has to actively untick.
|
// Defaults to on: the preference is a checkbox the user has to actively untick.
|
||||||
webEnabled: raw.webSearchEnabled ?? true,
|
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,
|
think: thinking && thinking !== "off" ? thinking : undefined,
|
||||||
showToolCalls: raw.showToolCalls ?? true,
|
showToolCalls: raw.showToolCalls ?? true,
|
||||||
showReasoning: raw.showReasoning ?? true,
|
showReasoning: raw.showReasoning ?? true,
|
||||||
@@ -217,14 +244,14 @@ export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
|
|||||||
/**
|
/**
|
||||||
* Vicinae's own preferences, resolved and normalised. Not exported: everything
|
* Vicinae's own preferences, resolved and normalised. Not exported: everything
|
||||||
* outside this module should go through {@link loadPreferences}, which also
|
* outside this module should go through {@link loadPreferences}, which also
|
||||||
* applies the model chosen in the Models command.
|
* applies the model chosen in the Manage Models command.
|
||||||
*/
|
*/
|
||||||
function getLamPreferences(): ResolvedPreferences {
|
function getLamPreferences(): ResolvedPreferences {
|
||||||
return resolvePreferences(getPreferenceValues<LamPreferences>());
|
return resolvePreferences(getPreferenceValues<LamPreferences>());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The model chosen in the Models command, if there is one.
|
* The model chosen in the Manage Models command, if there is one.
|
||||||
*
|
*
|
||||||
* Never throws. Storage that cannot be read means no selection rather than a
|
* Never throws. Storage that cannot be read means no selection rather than a
|
||||||
* broken command, because the fallback (the preference) is always a valid
|
* broken command, because the fallback (the preference) is always a valid
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ import { errorMessage } from "./utils";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface StartupInput {
|
export interface StartupInput {
|
||||||
/** A conversation already in hand, which is how the sessions command pushes this view. */
|
/** A conversation already in hand, which is how the Chat History command pushes this view. */
|
||||||
hasInitialSession: boolean;
|
hasInitialSession: boolean;
|
||||||
/** A conversation to reopen by id, handed over by the snip command. */
|
/** A conversation to reopen by id, handed over by the snip command. */
|
||||||
initialSessionId?: string;
|
initialSessionId?: string;
|
||||||
|
|||||||
+286
-8
@@ -1,4 +1,16 @@
|
|||||||
import { createSourceList, type SourceList } from "./citations";
|
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 { formatNow, getLocationContext, weekdayName } from "./location";
|
||||||
import { webFetch, webSearch } from "./ollama";
|
import { webFetch, webSearch } from "./ollama";
|
||||||
import type { GeoLookup } from "./geo";
|
import type { GeoLookup } from "./geo";
|
||||||
@@ -8,11 +20,12 @@ import { errorMessage } from "./utils";
|
|||||||
/**
|
/**
|
||||||
* The tools Lam can call.
|
* The tools Lam can call.
|
||||||
*
|
*
|
||||||
* All four are read-only. That is a deliberate boundary for this version: an
|
* All are read-only except the file tools, which are the one family here that can
|
||||||
* assistant that can silently act on the user's machine needs a confirmation
|
* change the machine. They are bounded in three ways at once: they are absent from
|
||||||
* story first, and this release is chat-only. Anything with a side effect
|
* the tool list unless the user switches file access on, the policy in `files.ts`
|
||||||
* (opening URLs, clipboard access, launching apps) belongs behind an approval
|
* decides what they may touch, and every change is approved in a dialog before it
|
||||||
* step that does not exist yet.
|
* happens. Nothing here opens a URL, touches the clipboard or launches a program:
|
||||||
|
* those would be side effects with no approval story of their own.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ToolContext {
|
export interface ToolContext {
|
||||||
@@ -44,6 +57,14 @@ export interface ToolContext {
|
|||||||
* list is made on the spot when none is supplied.
|
* list is made on the spot when none is supplied.
|
||||||
*/
|
*/
|
||||||
sources?: SourceList;
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,26 +144,180 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "list_directory",
|
||||||
|
description:
|
||||||
|
"List what is in a folder on the user's machine: names, which entries are directories, their sizes and how long ago they changed. Use it before reading a file to get the exact name, and to see what a folder holds.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Absolute path, or one starting with ~. A relative path is read as relative to the user's home directory.",
|
||||||
|
},
|
||||||
|
hidden: { type: "boolean", description: "Include dotfiles. Defaults to false." },
|
||||||
|
},
|
||||||
|
required: ["path"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "search_files",
|
||||||
|
description:
|
||||||
|
"Find files by name under a folder, optionally only those whose contents contain a string. Use it when you do not know where something is. Credential files are never returned.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "Folder to search, absolute or starting with ~." },
|
||||||
|
pattern: {
|
||||||
|
type: "string",
|
||||||
|
description: 'Glob for the file name, such as "*.md" or "report*". Use "*" for everything.',
|
||||||
|
},
|
||||||
|
contains: {
|
||||||
|
type: "string",
|
||||||
|
description: "Only files whose contents contain this text, case-insensitively.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["path", "pattern"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "read_file",
|
||||||
|
description:
|
||||||
|
"Read a text file. Lines come back numbered, and a long file comes back in parts: use offset and limit to continue from where the last read stopped rather than asking for the whole thing. Binary files are refused.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "File to read, absolute or starting with ~." },
|
||||||
|
offset: { type: "integer", description: "First line to return, counting from 1." },
|
||||||
|
limit: { type: "integer", description: "How many lines to return. Defaults to 200." },
|
||||||
|
},
|
||||||
|
required: ["path"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "write_file",
|
||||||
|
description:
|
||||||
|
"Create a file, or replace one entirely, with the content you supply. The user approves every change in a dialog before it happens, and a refusal is final: do not retry it, say what you could not do instead. Prefer edit_file for a small change to an existing file.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "File to write, absolute or starting with ~." },
|
||||||
|
content: { type: "string", description: "The complete new contents of the file." },
|
||||||
|
},
|
||||||
|
required: ["path", "content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "edit_file",
|
||||||
|
description:
|
||||||
|
"Replace an exact string in an existing file, leaving the rest of it alone. old_string must appear exactly once, or pass all to replace every occurrence. Include enough surrounding text to make it unique, and never guess at the contents: read the file first.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "File to edit, absolute or starting with ~." },
|
||||||
|
old_string: {
|
||||||
|
type: "string",
|
||||||
|
description: "The exact text to replace, copied from the file.",
|
||||||
|
},
|
||||||
|
new_string: { type: "string", description: "The text to put in its place." },
|
||||||
|
all: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Replace every occurrence rather than requiring one.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["path", "old_string", "new_string"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "delete_path",
|
||||||
|
description:
|
||||||
|
"Delete one file, or one empty folder. A folder with anything in it is refused, because Lam never deletes recursively. The user approves every deletion and it cannot be undone.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "File or empty folder to delete." },
|
||||||
|
},
|
||||||
|
required: ["path"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "move_path",
|
||||||
|
description:
|
||||||
|
"Move or rename a file or folder. Both ends must be somewhere Lam may change, the destination must not already exist, and the user approves every move.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
from: { type: "string", description: "Current path." },
|
||||||
|
to: { type: "string", description: "New path, which must not exist yet." },
|
||||||
|
},
|
||||||
|
required: ["from", "to"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Tools that need Ollama Cloud. A local Ollama server does not implement these endpoints. */
|
/** 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"]);
|
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. */
|
/** Human-readable labels for the activity line in the UI. */
|
||||||
export const TOOL_LABELS: Record<string, string> = {
|
export const TOOL_LABELS: Record<string, string> = {
|
||||||
get_datetime: "Checking the time",
|
get_datetime: "Checking the time",
|
||||||
get_location: "Checking your location",
|
get_location: "Checking your location",
|
||||||
web_search: "Searching the web",
|
web_search: "Searching the web",
|
||||||
web_fetch: "Reading a page",
|
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 {
|
export function toolLabel(name: string): string {
|
||||||
return TOOL_LABELS[name] ?? `Running ${name}`;
|
return TOOL_LABELS[name] ?? `Running ${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filters out the web tools when the user has turned them off. */
|
/** Filters out the tools the user has switched off. */
|
||||||
export function toolsFor(options: { webEnabled: boolean }): ToolDefinition[] {
|
export function toolsFor(options: { webEnabled: boolean; fileEnabled?: boolean }): ToolDefinition[] {
|
||||||
return TOOL_DEFINITIONS.filter((tool) => options.webEnabled || !WEB_TOOL_NAMES.has(tool.function.name));
|
return TOOL_DEFINITIONS.filter((tool) => {
|
||||||
|
const name = tool.function.name;
|
||||||
|
if (WEB_TOOL_NAMES.has(name)) return options.webEnabled;
|
||||||
|
if (FILE_TOOL_NAMES.has(name)) return options.fileEnabled ?? false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function asString(value: unknown): string | undefined {
|
function asString(value: unknown): string | undefined {
|
||||||
@@ -161,6 +336,42 @@ function isWebTool(name: string): boolean {
|
|||||||
return WEB_TOOL_NAMES.has(name);
|
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.
|
* Runs one tool call and always resolves.
|
||||||
*
|
*
|
||||||
@@ -188,6 +399,73 @@ export async function executeTool(
|
|||||||
case "web_fetch":
|
case "web_fetch":
|
||||||
return await runWebFetch(args, context);
|
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:
|
default:
|
||||||
return { content: `Unknown tool "${name}".`, isError: true };
|
return { content: `Unknown tool "${name}".`, isError: true };
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -66,7 +66,7 @@ export async function visionSupport(check: VisionCheck): Promise<VisionSupport>
|
|||||||
* Drops every cached answer.
|
* Drops every cached answer.
|
||||||
*
|
*
|
||||||
* A model can gain vision without its name changing (a re-pull, a different
|
* A model can gain vision without its name changing (a re-pull, a different
|
||||||
* tag on the same account), so `Set Up Lam Agent` calls this rather than
|
* tag on the same account), so `Setup & Status` calls this rather than
|
||||||
* leaving the user pinned to a stale "no".
|
* leaving the user pinned to a stale "no".
|
||||||
*/
|
*/
|
||||||
export async function forgetVisionSupport(): Promise<void> {
|
export async function forgetVisionSupport(): Promise<void> {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function ModelsCommand() {
|
|||||||
// marked up relative to it: which row is current, and whether picking one
|
// marked up relative to it: which row is current, and whether picking one
|
||||||
// would change anything.
|
// would change anything.
|
||||||
if (!preferences) {
|
if (!preferences) {
|
||||||
return <List isLoading navigationTitle="Ollama Models" searchBarPlaceholder="Loading models…" />;
|
return <List isLoading navigationTitle="Manage Models" searchBarPlaceholder="Loading models…" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ModelsScreen preferences={preferences} reloadPreferences={reload} />;
|
return <ModelsScreen preferences={preferences} reloadPreferences={reload} />;
|
||||||
@@ -256,7 +256,7 @@ function ModelsScreen({ preferences, reloadPreferences }: ModelsScreenProps) {
|
|||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
navigationTitle="Ollama Models"
|
navigationTitle="Manage Models"
|
||||||
searchBarPlaceholder="Search models…"
|
searchBarPlaceholder="Search models…"
|
||||||
actions={listActions}
|
actions={listActions}
|
||||||
>
|
>
|
||||||
+32
-18
@@ -1,4 +1,14 @@
|
|||||||
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 { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
import { clearAllChats, clearOrphanedSnips, runAutoDelete } from "./lib/cleanup";
|
import { clearAllChats, clearOrphanedSnips, runAutoDelete } from "./lib/cleanup";
|
||||||
import { usePreferences } from "./lib/hooks/usePreferences";
|
import { usePreferences } from "./lib/hooks/usePreferences";
|
||||||
@@ -46,13 +56,11 @@ export default function SetupCommand() {
|
|||||||
|
|
||||||
// The screen does nothing until the model is known, because half of what it
|
// 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
|
// reports is about that model: whether it can see images, and whether it is the
|
||||||
// one from preferences or from the Models command. Rendering it with the
|
// one from preferences or from the Manage Models command. Rendering it with the
|
||||||
// preference value and correcting it a moment later would flash a wrong answer
|
// preference value and correcting it a moment later would flash a wrong answer
|
||||||
// at the user.
|
// at the user.
|
||||||
if (!preferences) {
|
if (!preferences) {
|
||||||
return (
|
return <List isLoading navigationTitle="Setup & Status" searchBarPlaceholder="Loading preferences…" />;
|
||||||
<List isLoading navigationTitle="Lam Agent Setup" searchBarPlaceholder="Loading preferences…" />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SetupScreen preferences={preferences} reloadPreferences={reload} />;
|
return <SetupScreen preferences={preferences} reloadPreferences={reload} />;
|
||||||
@@ -174,11 +182,7 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) {
|
|||||||
{primary}
|
{primary}
|
||||||
<ActionPanel.Section>
|
<ActionPanel.Section>
|
||||||
<Action title="Open Extension Preferences" icon={Icon.BulletPoints} onAction={openPreferences} />
|
<Action title="Open Extension Preferences" icon={Icon.BulletPoints} onAction={openPreferences} />
|
||||||
<Action
|
<Action title="Test Connection" icon={Icon.ArrowClockwise} onAction={() => void testConnection()} />
|
||||||
title="Test Connection"
|
|
||||||
icon={Icon.ArrowClockwise}
|
|
||||||
onAction={() => void testConnection()}
|
|
||||||
/>
|
|
||||||
</ActionPanel.Section>
|
</ActionPanel.Section>
|
||||||
</ActionPanel>
|
</ActionPanel>
|
||||||
);
|
);
|
||||||
@@ -186,11 +190,7 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) {
|
|||||||
const settingsActions = (
|
const settingsActions = (
|
||||||
<ActionPanel>
|
<ActionPanel>
|
||||||
<Action title="Open Extension Preferences" icon={Icon.BulletPoints} onAction={openPreferences} />
|
<Action title="Open Extension Preferences" icon={Icon.BulletPoints} onAction={openPreferences} />
|
||||||
<Action
|
<Action title="Test Connection" icon={Icon.ArrowClockwise} onAction={() => void testConnection()} />
|
||||||
title="Test Connection"
|
|
||||||
icon={Icon.ArrowClockwise}
|
|
||||||
onAction={() => void testConnection()}
|
|
||||||
/>
|
|
||||||
</ActionPanel>
|
</ActionPanel>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) {
|
|||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
navigationTitle="Lam Agent Setup"
|
navigationTitle="Setup & Status"
|
||||||
searchBarPlaceholder="Search models…"
|
searchBarPlaceholder="Search models…"
|
||||||
actions={settingsActions}
|
actions={settingsActions}
|
||||||
>
|
>
|
||||||
@@ -271,6 +271,12 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) {
|
|||||||
subtitle={preferences.webEnabled ? "Enabled" : "Disabled"}
|
subtitle={preferences.webEnabled ? "Enabled" : "Disabled"}
|
||||||
actions={settingsActions}
|
actions={settingsActions}
|
||||||
/>
|
/>
|
||||||
|
<List.Item
|
||||||
|
icon={Icon.Folder}
|
||||||
|
title="File Access"
|
||||||
|
subtitle={preferences.fileEnabled ? "Enabled" : "Disabled"}
|
||||||
|
actions={settingsActions}
|
||||||
|
/>
|
||||||
<List.Item
|
<List.Item
|
||||||
icon={Icon.Clock}
|
icon={Icon.Clock}
|
||||||
title="Reasoning Effort"
|
title="Reasoning Effort"
|
||||||
@@ -406,7 +412,11 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) {
|
|||||||
subtitle="Create one at ollama.com/settings/keys, then paste it into the extension preferences"
|
subtitle="Create one at ollama.com/settings/keys, then paste it into the extension preferences"
|
||||||
actions={
|
actions={
|
||||||
<ActionPanel>
|
<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" />
|
<Action.OpenInBrowser title="Create an API Key" url="https://ollama.com/settings/keys" />
|
||||||
</ActionPanel>
|
</ActionPanel>
|
||||||
}
|
}
|
||||||
@@ -427,7 +437,11 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) {
|
|||||||
actions={
|
actions={
|
||||||
<ActionPanel>
|
<ActionPanel>
|
||||||
<Action.CopyToClipboard title="Copy Model Name" content={model.name} />
|
<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>
|
</ActionPanel>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+2
-2
@@ -34,7 +34,7 @@ function settleWindow(): Promise<void> {
|
|||||||
* Bind it to a global shortcut for one-key "snip and ask".
|
* Bind it to a global shortcut for one-key "snip and ask".
|
||||||
*/
|
*/
|
||||||
export default async function SnipCommand(props: LaunchProps) {
|
export default async function SnipCommand(props: LaunchProps) {
|
||||||
// Awaited because the model can come from a pick in the Models command, which
|
// Awaited because the model can come from a pick in the Manage Models command, which
|
||||||
// lives in storage. The vision check below is about whichever model is in use,
|
// lives in storage. The vision check below is about whichever model is in use,
|
||||||
// so it has to be the resolved one.
|
// so it has to be the resolved one.
|
||||||
const preferences = await loadPreferences();
|
const preferences = await loadPreferences();
|
||||||
@@ -59,7 +59,7 @@ export default async function SnipCommand(props: LaunchProps) {
|
|||||||
await showHUD(
|
await showHUD(
|
||||||
vision === "no"
|
vision === "no"
|
||||||
? `${preferences.model} cannot see images. Pick a vision model in preferences.`
|
? `${preferences.model} cannot see images. Pick a vision model in preferences.`
|
||||||
: `Could not check whether ${preferences.model} can see images. Run Set Up Lam Agent.`,
|
: `Could not check whether ${preferences.model} can see images. Run Setup & Status.`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user