feat: auto-delete old conversations, and stop a tool call that repeats (#15)

This commit was merged in pull request #15.
This commit is contained in:
2026-10-05 09:05:15 +00:00
parent 576c5946f7
commit 05fd9ff13b
25 changed files with 2372 additions and 59 deletions
+49 -2
View File
@@ -17,6 +17,8 @@ Hit a global shortcut, type a question, get a streamed answer without leaving wh
- **Readable code.** Fenced blocks render in a dark panel with the language named in a header, syntax colouring, and a Copy button.
- **Real markdown.** Tables, bulleted and numbered lists, headings, blockquotes, bold, italics and links all render natively, and the default prompt asks for the shape that fits the answer rather than flattening everything into prose.
- **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.
- **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.
- **Optional auto-deletion.** Set a retention window and Lam clears out the conversations you have stopped using the next time you open a command, taking their screenshots with them and collecting any capture you never sent. Off by default, and nothing at all is read or written while it is off. The setup screen also has both tidy-ups as explicit actions, confirmed first, for when you would rather press the button yourself.
- **Screenshots, two ways.** Snip a region into the conversation you are already in from the action panel, or run **Snip to Lam** to start a new chat about it. The launcher window gets out of the way first, so it never ends up in the shot, and the capture appears in the transcript as a thumbnail with Open Screenshot for the full image. Neither is offered unless the current model can actually see images.
- **Chat only.** Lam answers questions. It does not touch your files or run commands. The one exception is capture, and it only ever happens because you pressed the key for it.
@@ -113,7 +115,7 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
| **Chat with Lam** | The chat surface. Bind this to your global shortcut. |
| **Snip to Lam** | Capture a region and start a new chat about it. A `no-view` command, so the launcher window closes before the selector appears. Bind this to a second shortcut for one-key "snip and ask". |
| **Chat Sessions** | Browse, reopen, and delete past conversations. |
| **Set Up Lam Agent** | Verify the connection, list available models, and check whether the selected model can see images. |
| **Set Up Lam Agent** | Verify the connection, list available models, check whether the selected model can see images, and clear out chats or unattached photos. |
## Configuration
@@ -130,6 +132,8 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
| **Show Tool Calls** | on | Lists the tools Lam called under each answer. |
| **Show Reasoning** | on | Includes the model's thinking trace. Turn off to keep replies short. |
| **Start New Chat After** | 1 hour | Idle time past which opening the command starts a fresh chat. `Never` always reopens the last one. The old chat is never deleted either way. |
| **Delete Old Chats After** | Disabled | Permanently deletes a conversation once you have not opened it for this long, along with any screenshots it captured. Nothing is ever deleted while this is off. See [Auto-deletion](#auto-deletion). |
| **Tool Call Limit** | 8 rounds | How many rounds of tool calls Lam may make before it has to answer with what it has. `Unlimited` removes the cap. A call that repeats itself with identical arguments ends the turn either way. |
## Tools
@@ -160,7 +164,46 @@ This extension does not use GPS. That would mean CoreLocation on macOS, which ha
Web search results and fetched pages can be large. Lam truncates them before they reach the model.
**A turn that runs out of tool calls still ends in an answer.** The loop stops after 8 rounds, and the first version of it stopped by throwing away every preamble the model had written and returning a notice about the loop: asked for the fish in every Valheim biome, it researched for five rounds and then said nothing useful at all. The budget is now spent on a *closing round* with the tools taken away and an instruction to answer from what was gathered and to name what it could not confirm. So the worst case is a partial answer that says what is missing, rather than an app-generated message about its own control flow. If the closing round produces nothing either, the last preamble is used, and only then a notice. The closing round is why the cap is 8 rather than 5: the extra round is not more searching, it is the answer.
**A turn that runs out of tool calls still ends in an answer.** The loop stops after 8 rounds by default, and the first version of it stopped by throwing away every preamble the model had written and returning a notice about the loop: asked for the fish in every Valheim biome, it researched for five rounds and then said nothing useful at all. The budget is now spent on a *closing round* with the tools taken away and an instruction to answer from what was gathered and to name what it could not confirm. So the worst case is a partial answer that says what is missing, rather than an app-generated message about its own control flow. If the closing round produces nothing either, the last preamble is used, and only then a notice. The closing round is why the default cap is 8 rather than 5: the extra round is not more searching, it is the answer.
The cap is a preference rather than a constant, because how many rounds a question needs is a property of the question and of the model. `Unlimited` is there for research that genuinely needs a dozen pages.
**Two identical calls run, a third refuses to.** No round cap can be a complete guard, because one of the settings is `Unlimited`. What a stuck model actually does is repeat itself: the same tool, the same arguments, the same non-answer. So `agent.ts` counts the tool name together with its sorted arguments, per turn, allows the call twice (a retry after a search that failed is reasonable), and on the third occurrence does not run it. The round finishes, the loop breaks, and the closing round is reached with an instruction that names the repeated call instead of claiming a budget ran out. The refusal is recorded on the turn with `repeated: true`, so the transcript can say what happened.
Arguments are compared with their keys sorted, because models are not consistent about key order and a call that differs only by key order is still the same call.
**What Lam is doing is visible while it is doing it, even with tool calls hidden.** `Show Tool Calls` governs the finished transcript, where a list of names adds little. The live activity line is separate and always shown, because during a turn the difference between working and circling is the entire question: `_Reading a page…_` for a first call, and `_Reading a page · the same call again (2)_` once it has come round. Hiding that would leave the user watching a spinner they cannot interpret, which is the situation the repeat guard exists to fix. A call that failed, or that was refused for repeating, is also listed in the transcript even with tool calls hidden: those are the two cases where hiding the line leaves an answer the user cannot account for.
## Auto-deletion
Off by default. When it is on, Lam deletes a conversation once you have not used it for the configured period, and deletes the screenshots that belonged to it.
It is checked when any Lam command opens (chat, sessions, setup) and after every answer, so an expired conversation goes the next time you use Lam. The alternative is a timer, which would mean waking up to do work while nothing is open.
**Age is counted from the last time you used a conversation, not from when you started it.** A chat started last month and continued this morning is not an old chat, and deleting it because of its birthday would be a surprise.
**The conversation you are in is never deleted**, even if it is expired. It is about to be rendered, and being the one the command opens by default is the clearest possible sign that it is still in use. The practical consequence is that a conversation you keep returning to outlives the window indefinitely, which is the intended reading of "conversations you have not opened". Sweeping *after* the startup resolution rather than before is what makes this work: sweeping first would delete the very conversation on its way to the screen.
**A screenshot the user never sent is collected too.** Snips are written to the support directory before the chat view opens, so a capture you abandon belongs to no conversation and no amount of session deletion would ever reach it. The orphan pass reads the snip directory directly, compares it against the paths recorded on the conversation index, and deletes anything unreferenced that is more than a day old. That wait is the safety story: a snip is on disk seconds after it is taken and stays unreferenced until you send it, so without a grace period the sweep would delete the file out from under a question you were still typing. Files belonging to a conversation that has just been deleted go immediately with it instead of waiting, because the conversation they belonged to is already gone.
The index entry for a conversation carries its image paths (`SessionSummary.attachmentPaths`), so deciding what is still referenced does not mean reading every transcript, which the sweep would otherwise do after every single answer. An index written before that field existed is repaired from its transcripts before anything is deleted, because a missing list is not the same as an empty one: reading it as empty would mark every screenshot in your history as an orphan.
**The whole design leans towards deleting nothing.** Retention off means nothing is read and nothing is written. An unrecognised value for the preference resolves to off. The orphan pass is skipped entirely when the referenced set cannot be established, because an incomplete set of references looks exactly like a pile of orphans and guessing wrong destroys real images. A path is checked to be inside the snip directory before anything is removed, so a hand-edited entry in storage cannot reach anything else on the disk. And the sweep never throws, so a failed tidy-up cannot break the command you opened.
### Doing it by hand
**Set Up Lam Agent** has a Maintenance section with the same two jobs as explicit actions:
| Action | What it deletes |
| --- | --- |
| **Clear Out All Chats** | Every conversation, and every screenshot they captured. |
| **Clear Out Unattached Photos** | Captures that no conversation refers to any more. |
Both ask for confirmation first, and both say what they did even when the answer is nothing, because a button that appears to do nothing reads as a bug. That is deliberately the opposite of the background sweep, which stays silent unless it deleted something.
The photo clean-up applies exactly the same rule as the sweep, including the one-day wait, and it works whether or not **Delete Old Chats After** is switched on. So it covers the case that setting exists for: you would rather decide when your history is cleared than have Lam decide it for you.
When a conversation cannot be read there is no way to know which captures are still in use, so that action says it could not check and deletes nothing, rather than risking a screenshot a surviving chat still shows.
## Architecture
@@ -178,6 +221,8 @@ src/
attachments.ts # image files -> base64 payloads
vision.ts # cached "can this model see?" answer
sessions.ts # LocalStorage persistence
retention.ts # what gets deleted, and when
cleanup.ts # the sweep + its notification
history.ts # turns -> API messages
location.ts # time/place, derived locally
thread.ts # turns -> flat message list
@@ -224,6 +269,8 @@ The rendered height stays capped at 200px whatever is asked for (`Math.min(_rawH
**Streamed deltas are buffered in refs and flushed to state on a timer** (`FLUSH_INTERVAL_MS`). Re-rendering the transcript per token makes the launcher visibly janky.
**Stopping a turn is an action, not a keybinding.** The stop action is labelled Force Stop and stays the primary action for the whole turn, because the failure it exists for is a model spending a long time on a question you have given up on. It aborts the request, which propagates through the tool loop and into the tool that is running, so it works mid-search rather than only between rounds. There is no separate stop command: an extension cannot reach into a view running in another command, so a stop command would have to poll a flag in `LocalStorage` and could only stop at round boundaries, which is both slower and less reliable than the thing it would replace.
**The logic layer has no Vicinae imports.** `agent.ts`, `ollama.ts`, `tools.ts` and friends are plain TypeScript, which is why they unit test without a runtime. A test enforces this.
**Sessions are capped twice over**, once per turn and once per session, because `LocalStorage` has no documented ceiling.