diff --git a/src/__tests__/history.test.ts b/src/__tests__/history.test.ts index a1b2818..671df14 100644 --- a/src/__tests__/history.test.ts +++ b/src/__tests__/history.test.ts @@ -4,7 +4,16 @@ import { buildMessages, composeSystemPrompt, describeRuntime, type RuntimeContex import type { ChatTurn } from "../lib/types"; function turn(prompt: string, response: string): ChatTurn { - return { id: prompt, prompt, response, thinking: "", model: "m", createdAt: 0, toolCalls: [], attachments: [] }; + return { + id: prompt, + prompt, + response, + thinking: "", + model: "m", + createdAt: 0, + toolCalls: [], + attachments: [], + }; } describe("buildMessages", () => { @@ -179,3 +188,46 @@ describe("buildMessages images", () => { expect(messages.some((message) => message.images !== undefined)).toBe(false); }); }); + +describe("the runtime paragraph and file access", () => { + const context = (overrides: Partial = {}): RuntimeContext => ({ + 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."); + }); +}); diff --git a/src/lib/history.ts b/src/lib/history.ts index ff93376..eaa395d 100644 --- a/src/lib/history.ts +++ b/src/lib/history.ts @@ -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"; /** @@ -51,6 +51,15 @@ export interface RuntimeContext { selfHosted: boolean; webTools: boolean; vision: VisionSupport; + /** + * Whether the file tools are available this session. + * + * Optional, so a caller that predates file access, and every test literal, keeps + * describing a session where Lam cannot read files, which is what it could not. + */ + fileTools?: boolean; + /** The roots, tilde shortened, for the paragraph that names them. */ + fileRoots?: { read: string[]; write: string[] }; } /** @@ -75,7 +84,12 @@ export function describeRuntime(context: RuntimeContext): string { const lines = [ "What you are, for questions about your own setup. Do not speculate beyond this:", `- You are Lam, running inside the Vicinae launcher on the user's desktop, and answering as \`${context.model}\` served by ${served}.`, - "- 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) { @@ -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.", ); + if (context.fileTools) { + const read = context.fileRoots?.read ?? ["~"]; + const write = context.fileRoots?.write ?? []; + + lines.push( + "- You have file tools: `list_directory`, `search_files`, `read_file`, `write_file`, `edit_file`, `delete_path` and `move_path`.", + `- You may read ${andList(read)} and change ${andList(write)}. Paths outside those are refused outright, and credential stores such as ~/.ssh are never readable.`, + `- ${FILE_TOOL_GUIDANCE}`, + ); + } + lines.push( context.vision === "yes" ? "- You can read images. A screenshot attached to a question is yours to describe, so describe it." @@ -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 * replacing the default prompt should not silently remove it, and a user writing * their own prompt has even less reason to expect the model to know what it is. - */ -export function composeSystemPrompt(customPrompt: string, context: RuntimeContext): string { + */ export function composeSystemPrompt(customPrompt: string, context: RuntimeContext): string { const parts = [customPrompt.trim() || DEFAULT_SYSTEM_PROMPT]; if (context.webTools) parts.push(RECENCY_HINT); parts.push(describeRuntime(context)); return parts.join("\n\n"); } + +/** "a, b and c", so a list of roots reads as a sentence rather than as data. */ +function andList(items: string[]): string { + if (items.length === 0) return "nothing"; + if (items.length === 1) return items[0]; + return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`; +} diff --git a/src/lib/hooks/useChat.ts b/src/lib/hooks/useChat.ts index 57da239..6873233 100644 --- a/src/lib/hooks/useChat.ts +++ b/src/lib/hooks/useChat.ts @@ -1,6 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { environment } from "@vicinae/api"; import { runAgent } from "../agent"; +import { confirmWithAlert, createApprovalLedger } from "../approval"; import { toStoredAttachment } from "../attachments"; +import { createPolicy, displayPath } from "../files"; import { ipPlace } from "../geo"; import { buildMessages, composeSystemPrompt } from "../history"; import { isSelfHosted } from "../ollama"; @@ -205,6 +208,18 @@ export function useChat(options: UseChatOptions): UseChat { const controller = new AbortController(); abortRef.current = controller; + // Built per turn rather than per session: the ledger inside it is the record of + // what the user approved while answering this one question, and the next question + // starts by asking again. The policy is cheap to rebuild and holds no state. + const policy = createPolicy({ + supportPath: environment.supportPath, + readRoots: preferences.extraReadRoots, + writeRoots: preferences.extraWriteRoots, + }); + const fileAccess = preferences.fileEnabled + ? { policy, approve: createApprovalLedger(confirmWithAlert).approve } + : undefined; + const messages = buildMessages( composeSystemPrompt(preferences.systemPrompt, { model: preferences.model, @@ -212,6 +227,11 @@ export function useChat(options: UseChatOptions): UseChat { selfHosted: isSelfHosted(preferences.host), webTools: preferences.webEnabled, vision: visionRef.current, + fileTools: preferences.fileEnabled, + fileRoots: { + read: policy.readRoots.map((root) => displayPath(root, policy.home)), + write: policy.writeRoots.map((root) => displayPath(root, policy.home)), + }, }), base.turns, prompt, @@ -233,7 +253,10 @@ export function useChat(options: UseChatOptions): UseChat { // cap, so the two conventions meet here. maxToolRounds: preferences.maxToolRounds ?? null, messages, - tools: toolsFor({ webEnabled: preferences.webEnabled }), + tools: toolsFor({ + webEnabled: preferences.webEnabled, + fileEnabled: preferences.fileEnabled, + }), toolContext: { host: preferences.host, 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 // cannot reach the network for a location at all. ipPlace: preferences.ipGeolocation ? ipPlace : undefined, + files: fileAccess, }, signal: controller.signal, events: { diff --git a/src/setup.tsx b/src/setup.tsx index b1fd83e..32d948a 100644 --- a/src/setup.tsx +++ b/src/setup.tsx @@ -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 { clearAllChats, clearOrphanedSnips, runAutoDelete } from "./lib/cleanup"; import { usePreferences } from "./lib/hooks/usePreferences"; @@ -50,9 +60,7 @@ export default function SetupCommand() { // preference value and correcting it a moment later would flash a wrong answer // at the user. if (!preferences) { - return ( - - ); + return ; } return ; @@ -174,11 +182,7 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) { {primary} - void testConnection()} - /> + void testConnection()} /> ); @@ -186,11 +190,7 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) { const settingsActions = ( - void testConnection()} - /> + void testConnection()} /> ); @@ -271,6 +271,12 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) { subtitle={preferences.webEnabled ? "Enabled" : "Disabled"} actions={settingsActions} /> + - + } @@ -427,7 +437,11 @@ function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) { actions={ - + } />