diff --git a/CLAUDE.md b/CLAUDE.md index d6edf12..3455ec0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -331,8 +331,8 @@ npm run package # package the app (no installers) npm run dist # package + electron-builder → branded installers in dist/ npm run gen:icons # regenerate runtime PNG icons from assets/icon.svg npm run gen:installer # regenerate Windows installer art (.ico + NSIS BMPs) -npm run make # legacy Forge makers (unused — makers: [] in forge.config.ts) -npm run publish # legacy Forge publish (unused; releases ship via GitLab CI) +npm run make # alias for `npm run dist` (Forge has no makers; builds the current-OS installer) +npm run publish # alias for `npm run dist:publish` (build + upload to the GitHub release feed) ``` There is **no `npm run dev`** — use `npm start` (Electron Forge drives Vite). @@ -516,6 +516,25 @@ the real (no-mock) UI. Each owns one responsibility: - **Agent Manager** (`managers/AgentManager.ts`) — drives `@anthropic-ai/claude- agent-sdk` (plan/implement modes), risk-gated `canUseTool`, path-guarded to the workspace, persists transcript/activity/diagnostics, resumes SDK sessions. +- **Attachment Manager** (`managers/attachments/AttachmentManager.ts` + + `validate.ts`) — ChatGPT-style composer attachments as **session-owned + workspace resources**: picker/drag-drop/paste files are validated (realpath + + symlink guard, size/count caps, name sanitize, image magic-byte + NUL sniff, + elevated-risk extension policy — attaching never executes, archives never + extracted), streamed-SHA-256-hashed with live progress (`attachment:progress` + → the composer chip's `CircularProgress` ring), deduped per session by hash, + staged under `userData/attachments//`, and recorded in the + `attachments` table (schema v9; `message_id` NULL = composer draft). The agent + consumes them tool-first: a per-turn `` manifest + SDK + `additionalDirectories` (this session's staging dir only, read tools only via + a scoped `canUseTool` carve-out — writes/Bash stay blocked) so Read/Grep pull + content on demand; raster images additionally ride as base64 vision blocks + via one-shot streaming input (nativeImage downscale above the threshold). A + Read of a staged file flips the chip to `read`. Trash keeps staged files + (restore-safe); purge + a boot orphan sweep delete them. UI: `AttachmentStrip` + / `AttachmentChip` in the Composer (drop zone + paste + paperclip), read-only + chips on sent turns, `useAttachmentStore`, Settings › Attachments + (`settings.attachments`, bounds in `ATTACHMENT_LIMITS`). - **Memory System** (`managers/memory/MemoryManager.ts`) — the **Local Memory System** (see below). - **Search Engine** (`managers/search/SearchManager.ts`) — the **Search Engine** diff --git a/docs/reference/window-limboo-api.md b/docs/reference/window-limboo-api.md index 425181c..a0dc022 100644 --- a/docs/reference/window-limboo-api.md +++ b/docs/reference/window-limboo-api.md @@ -10,12 +10,13 @@ Every method maps to a channel name in [IPC channels reference](ipc-channels.md). Subscriptions (the `on*` methods) return an unsubscribe function. -The API has 17 namespaces: +The API has 18 namespaces: ``` window.limboo.{ window, settings, system, app, events, workspace, session, agent, fs, terminal, git, - worktree, services, memory, search, updates, voice } + worktree, services, memory, search, updates, voice, + attachment } ``` ## window @@ -182,6 +183,18 @@ Local voice subsystem: runtime controls (`getState`, `start`, `stop`, `voice:*` event subscriptions. Mic audio streams main-ward over the fire-and-forget `voice:audio-chunk` send channel. +## attachment + +Attachment Manager — session-owned files staged for the agent's tool loop. + +- `list(sessionId)` — all attachments (drafts + sent), oldest first. +- `pickFiles(sessionId)` — native multi-file picker → stage. +- `addPaths(sessionId, paths)` — stage dropped files (paths from `getPathForFile`). +- `addPasted(sessionId, name, mime, bytes)` — stage a pasted image. +- `remove(sessionId, id)`, `reveal(sessionId, id)` +- `getPathForFile(file)` — resolve a dropped `File`'s real path (webUtils). +- `onChanged(cb)` / `onProgress(cb)` — subscriptions (set changes / staging %). + ## Usage note Renderer calls guard with optional chaining (`window.limboo?.…`) so the UI still diff --git a/electron-builder.yml b/electron-builder.yml index 00307a3..e314329 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -23,10 +23,19 @@ directories: # Publish provider for releases AND the auto-update feed electron-updater reads # (it fetches latest*.yml from this repo's GitHub Releases). +# +# releaseType: release — publish straight to a full GitHub Release (which +# auto-creates the `v${version}` tag) rather than the electron-builder default +# `draft`. This matches the repo's existing releases (all non-draft), so a local +# `npm run publish` never hits the "existing type not compatible with publishing +# type" mismatch, and the auto-generated tag (v) stays consistent with +# package.json. The version is the single source of truth: bump it (git tag or +# `node ci/scripts/apply-tag-version.mjs vX.Y.Z`) and the tag follows. publish: provider: github owner: BotCoder254 repo: limboo + releaseType: release # --------------------------------------------------------------------------- # Windows — the branded, multi-page NSIS wizard (the "first screen of the app"). diff --git a/package-lock.json b/package-lock.json index e431f37..67fcb7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "limboo", - "version": "1.2.5", + "version": "1.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "limboo", - "version": "1.2.5", + "version": "1.3.4", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.195", diff --git a/package.json b/package.json index e1052a5..3c6f98e 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { "name": "limboo", "productName": "Limboo", - "version": "1.2.5", + "version": "1.3.4", "description": "My Electron application description", "main": ".vite/build/main.js", "private": true, "scripts": { "start": "electron-forge start", "package": "electron-forge package", - "make": "electron-forge make", - "publish": "electron-forge publish", + "make": "npm run dist", + "publish": "npm run dist:publish", "dist": "electron-forge package && node scripts/dist.mjs", "dist:publish": "electron-forge package && node scripts/dist.mjs --publish always", "gen:icons": "node scripts/gen-icons.mjs", diff --git a/scripts/dist.mjs b/scripts/dist.mjs index 5282048..0843d7f 100644 --- a/scripts/dist.mjs +++ b/scripts/dist.mjs @@ -15,10 +15,40 @@ * (e.g. `--publish always`) are forwarded straight to electron-builder. */ import { spawnSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { writeAppUpdateYml } from './write-app-update-yml.mjs'; +/** + * Load `.env` (gitignored) from the repo root into `process.env` so a local + * `npm run publish` picks up secrets like `GH_TOKEN` regardless of which shell + * it runs in (setx / export only affect newly-spawned shells). Only fills keys + * that are NOT already set, so CI/CD env variables always take precedence and a + * stale local `.env` can never override them. Never logs values. + */ +function loadDotEnv() { + const envPath = resolve(process.cwd(), '.env'); + if (!existsSync(envPath)) return; + for (const raw of readFileSync(envPath, 'utf8').split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq === -1) continue; + const key = line.slice(0, eq).trim(); + if (!key || (process.env[key] ?? '') !== '') continue; + let value = line.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + process.env[key] = value; + } +} + +loadDotEnv(); + // Forge names the packaged dir from packagerConfig.name ('Limboo') + platform/arch. const prepackaged = resolve( process.cwd(), diff --git a/src/main/db/database.ts b/src/main/db/database.ts index db68fa9..25add79 100644 --- a/src/main/db/database.ts +++ b/src/main/db/database.ts @@ -331,6 +331,36 @@ function migrate(database: Database.Database): void { VALUES (new.rowid, new.name, new.signature); END; + -- Attachment Manager (schema v9) — session-owned files attached in the + -- composer. The staged copy lives under userData/attachments// + -- ; this table is the metadata + lifecycle record. message_id + -- is NULL while the attachment is a composer draft and set when it is sent + -- with a user turn. Deduped per session by content hash. All values bound. + CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + name TEXT NOT NULL, + stored_name TEXT NOT NULL, + mime TEXT NOT NULL, + category TEXT NOT NULL, + size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'ready', + origin TEXT NOT NULL, + risk TEXT NOT NULL DEFAULT 'safe', + message_id TEXT, + thumb TEXT, + error TEXT, + meta TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_attachments_session + ON attachments (session_id, created_at); + CREATE UNIQUE INDEX IF NOT EXISTS idx_attachments_dedupe + ON attachments (session_id, sha256); + -- Recent searches (most-recent-first) per scope. workspace_id NULL = global. CREATE TABLE IF NOT EXISTS search_history ( id TEXT PRIMARY KEY, diff --git a/src/main/index.ts b/src/main/index.ts index 407c3a4..8d90f5c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,6 +30,7 @@ import { SearchManager } from './managers/search/SearchManager'; import { AutoUpdateManager } from './managers/AutoUpdateManager'; import { VoiceManager } from './managers/voice/VoiceManager'; import { VoiceModelManager } from './managers/voice/VoiceModelManager'; +import { AttachmentManager } from './managers/attachments/AttachmentManager'; import { getDb, closeDb } from './db/database'; import { registerAllIpc } from './ipc'; @@ -84,6 +85,7 @@ function bootstrap(): void { let services: ServiceManager; let proxy: ProxyServer; let memory: MemoryManager; + let attachments: AttachmentManager; let search: SearchManager; let updates: AutoUpdateManager; let voiceModels: VoiceModelManager; @@ -133,6 +135,10 @@ function bootstrap(): void { // into prompts before they reach the harness. memory = new MemoryManager(settings); memory.seedDefaults(null); // global / user-scope starters + // The Attachment Manager — session-owned files staged for the agent's tool + // loop. Sweeps orphaned staging dirs shortly after boot (off the hot path). + attachments = new AttachmentManager(sessions, settings); + void attachments.sweepOrphans().catch((err) => logger.warn('attachment sweep failed', err)); // The Search Engine — a platform service owned by the app. Maintains the local // file/symbol index and federates every other subsystem behind one query // interface; also the primary context provider for the coding agent. @@ -149,6 +155,9 @@ function bootstrap(): void { // new memories from commits. Both treat memory as an optional collaborator. agent.setMemoryManager(memory); git.setMemoryManager(memory); + // The agent consumes attachments: manifest + staging-dir read access per + // prompt, vision blocks for images, and read-status tracking on tool use. + agent.setAttachmentManager(attachments); // The Search Engine federates memory / git / sessions at query time, powers the // Global Search UI, and feeds ranked context into the agent prompt. search.setMemoryManager(memory); @@ -189,6 +198,7 @@ function bootstrap(): void { worktree: worktrees, services, memory, + attachments, search, updates, voice, diff --git a/src/main/ipc/agentHandlers.ts b/src/main/ipc/agentHandlers.ts index 39b79f1..72f43ad 100644 --- a/src/main/ipc/agentHandlers.ts +++ b/src/main/ipc/agentHandlers.ts @@ -6,7 +6,7 @@ * prototype-polluting keys. */ import { IpcChannels } from '@shared/ipc-channels'; -import { AGENT_LIMITS } from '@shared/constants'; +import { AGENT_LIMITS, ATTACHMENT_LIMITS } from '@shared/constants'; import type { AgentDiagnostic, AgentInstall, @@ -65,16 +65,37 @@ export function registerAgentHandlers(agent: AgentManager): void { agent.getSnapshot(assertSessionId(sessionId)), ); - handle<[string, string, SessionPermissionMode?, string?], void>( + handle<[string, string, SessionPermissionMode?, string?, string[]?], void>( IpcChannels.agentSend, - async (_event, sessionId, prompt, mode, clientMessageId) => { + async (_event, sessionId, prompt, mode, clientMessageId, attachmentIds) => { const id = assertSessionId(sessionId); - if (typeof prompt !== 'string' || prompt.trim().length === 0) { + // Attachment ids are optional; each must be a short, plain token. The + // Attachment Manager re-validates session ownership before use. + let attachIds: string[] | undefined; + if (attachmentIds !== undefined) { + if (!Array.isArray(attachmentIds) || attachmentIds.length > ATTACHMENT_LIMITS.maxFilesPerMessage.max) { + throw new Error('Invalid attachment list'); + } + attachIds = attachmentIds.filter( + (a): a is string => + typeof a === 'string' && + a.length > 0 && + a.length <= ATTACHMENT_LIMITS.idMax && + /^[A-Za-z0-9_-]+$/.test(a), + ); + if (attachIds.length !== attachmentIds.length) { + throw new Error('Invalid attachment id'); + } + if (attachIds.length === 0) attachIds = undefined; + } + if (typeof prompt !== 'string' || (prompt.trim().length === 0 && !attachIds)) { throw new Error('Prompt must be a non-empty string'); } if (prompt.length > AGENT_LIMITS.promptMax) { throw new Error('Prompt is too long'); } + // An attachments-only send gets a minimal instruction as the visible turn. + const effective = prompt.trim().length === 0 ? 'Review the attached files.' : prompt; // Optional renderer-supplied id so the optimistic bubble and the persisted // message share one id (dedup on echo). Validated: a short, plain string. const clientId = @@ -83,7 +104,7 @@ export function registerAgentHandlers(agent: AgentManager): void { clientMessageId.length <= 64 ? clientMessageId : undefined; - await agent.send(id, prompt, assertMode(mode), clientId); + await agent.send(id, effective, assertMode(mode), clientId, attachIds); }, ); diff --git a/src/main/ipc/attachmentHandlers.ts b/src/main/ipc/attachmentHandlers.ts new file mode 100644 index 0000000..8c2809e --- /dev/null +++ b/src/main/ipc/attachmentHandlers.ts @@ -0,0 +1,89 @@ +/** + * IPC handlers for the Attachment Manager. Reached from the renderer through + * `window.limboo.attachment.*`. Every handler validates and caps its input + * before it touches the manager (CLAUDE.md §6): ids/paths are bounded strings, + * pasted bytes are size-capped, and the manager re-validates everything again + * (realpath, magic bytes, session ownership) as defense in depth. + */ +import { IpcChannels } from '@shared/ipc-channels'; +import { ATTACHMENT_LIMITS } from '@shared/constants'; +import type { AttachmentMeta } from '@shared/types'; +import type { AttachmentManager } from '../managers/attachments/AttachmentManager'; +import { handle } from './registry'; + +function assertSessionId(value: unknown): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 200) { + throw new Error('Expected a valid session id'); + } + return value; +} + +function assertAttachmentId(value: unknown): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > ATTACHMENT_LIMITS.idMax || + !/^[A-Za-z0-9_-]+$/.test(value) + ) { + throw new Error('Expected a valid attachment id'); + } + return value; +} + +export function registerAttachmentHandlers(attachments: AttachmentManager): void { + handle<[string], AttachmentMeta[]>(IpcChannels.attachmentList, (_event, sessionId) => + attachments.list(assertSessionId(sessionId)), + ); + + handle<[string], AttachmentMeta[]>(IpcChannels.attachmentPickFiles, (_event, sessionId) => + attachments.pickAndAdd(assertSessionId(sessionId)), + ); + + handle<[string, string[]], AttachmentMeta[]>( + IpcChannels.attachmentAddPaths, + (_event, sessionId, paths) => { + const id = assertSessionId(sessionId); + if (!Array.isArray(paths) || paths.length === 0) { + throw new Error('Expected a non-empty array of paths'); + } + if (paths.length > ATTACHMENT_LIMITS.maxFilesPerMessage.max) { + throw new Error('Too many files'); + } + for (const p of paths) { + if (typeof p !== 'string' || p.length === 0 || p.length > ATTACHMENT_LIMITS.pathMax) { + throw new Error('Invalid path'); + } + } + return attachments.addFromPaths(id, paths, 'drop'); + }, + ); + + handle<[string, string, string, ArrayBuffer], AttachmentMeta>( + IpcChannels.attachmentAddPasted, + (_event, sessionId, name, mime, bytes) => { + const id = assertSessionId(sessionId); + if (typeof name !== 'string' || name.length > ATTACHMENT_LIMITS.nameMax * 2) { + throw new Error('Invalid file name'); + } + if (typeof mime !== 'string' || mime.length === 0 || mime.length > 64) { + throw new Error('Invalid MIME type'); + } + if (!(bytes instanceof ArrayBuffer) && !ArrayBuffer.isView(bytes)) { + throw new Error('Expected image bytes'); + } + const view = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : new Uint8Array(bytes.buffer); + if (view.byteLength === 0 || view.byteLength > ATTACHMENT_LIMITS.pasteBytesMax) { + throw new Error('Pasted image is too large'); + } + return attachments.addFromBytes(id, name, mime, view); + }, + ); + + handle<[string, string], void>(IpcChannels.attachmentRemove, (_event, sessionId, id) => { + attachments.remove(assertSessionId(sessionId), assertAttachmentId(id)); + }); + + handle<[string, string], void>(IpcChannels.attachmentReveal, (_event, sessionId, id) => { + attachments.reveal(assertSessionId(sessionId), assertAttachmentId(id)); + }); +} diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index f2af127..4427d7f 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -12,6 +12,7 @@ import type { GitManager } from '../managers/GitManager'; import type { WorktreeManager } from '../managers/worktree/WorktreeManager'; import type { ServiceManager } from '../managers/services/ServiceManager'; import type { MemoryManager } from '../managers/memory/MemoryManager'; +import type { AttachmentManager } from '../managers/attachments/AttachmentManager'; import type { SearchManager } from '../managers/search/SearchManager'; import type { AutoUpdateManager } from '../managers/AutoUpdateManager'; import type { VoiceManager } from '../managers/voice/VoiceManager'; @@ -28,6 +29,7 @@ import { registerGitHandlers } from './gitHandlers'; import { registerWorktreeHandlers } from './worktreeHandlers'; import { registerServiceHandlers } from './serviceHandlers'; import { registerMemoryHandlers } from './memoryHandlers'; +import { registerAttachmentHandlers } from './attachmentHandlers'; import { registerSearchHandlers } from './searchHandlers'; import { registerUpdateHandlers } from './updateHandlers'; import { registerVoiceHandlers } from './voiceHandlers'; @@ -44,6 +46,7 @@ export interface IpcDeps { worktree: WorktreeManager; services: ServiceManager; memory: MemoryManager; + attachments: AttachmentManager; search: SearchManager; updates: AutoUpdateManager; voice: VoiceManager; @@ -55,7 +58,7 @@ export function registerAllIpc(deps: IpcDeps): void { registerSettingsHandlers(deps.settings); registerSystemHandlers(deps.notifications); registerWorkspaceHandlers(deps.workspace); - registerSessionHandlers(deps.session, deps.worktree, deps.services, deps.terminal); + registerSessionHandlers(deps.session, deps.worktree, deps.services, deps.terminal, deps.attachments); registerAgentHandlers(deps.agent); registerFsHandlers(deps.fs); registerTerminalHandlers(deps.terminal); @@ -63,6 +66,7 @@ export function registerAllIpc(deps: IpcDeps): void { registerWorktreeHandlers(deps.worktree); registerServiceHandlers(deps.services); registerMemoryHandlers(deps.memory); + registerAttachmentHandlers(deps.attachments); registerSearchHandlers(deps.search); registerUpdateHandlers(deps.updates); registerVoiceHandlers(deps.voice, deps.voiceModels, deps.settings); diff --git a/src/main/ipc/sessionHandlers.ts b/src/main/ipc/sessionHandlers.ts index 5c6562c..99c45c1 100644 --- a/src/main/ipc/sessionHandlers.ts +++ b/src/main/ipc/sessionHandlers.ts @@ -18,6 +18,7 @@ import type { SessionManager } from '../managers/SessionManager'; import type { WorktreeManager } from '../managers/worktree/WorktreeManager'; import type { ServiceManager } from '../managers/services/ServiceManager'; import type { TerminalManager } from '../managers/TerminalManager'; +import type { AttachmentManager } from '../managers/attachments/AttachmentManager'; import { logger } from '../logger'; /** Bounds for session organization inputs (folder / tags). */ @@ -131,6 +132,7 @@ export function registerSessionHandlers( worktrees: WorktreeManager, services: ServiceManager, terminals: TerminalManager, + attachments: AttachmentManager, ): void { handle<[string, boolean?], Session[]>(IpcChannels.sessionList, (_e, workspaceId, trash) => { assertValidId(workspaceId); @@ -215,6 +217,13 @@ export function registerSessionHandlers( } catch (err) { logger.warn('session:purge worktree removal failed', err); } + // Purge is permanent: delete the session's staged attachments too (trash + // keeps them so a restored session still has its files). + try { + await attachments.purgeSession(id); + } catch (err) { + logger.warn('session:purge attachment cleanup failed', err); + } sessions.purge(id); }); diff --git a/src/main/ipc/voiceHandlers.ts b/src/main/ipc/voiceHandlers.ts index dafc5f6..ba8adfb 100644 --- a/src/main/ipc/voiceHandlers.ts +++ b/src/main/ipc/voiceHandlers.ts @@ -37,6 +37,10 @@ export function registerVoiceHandlers( ): void { handle<[], VoiceState>(IpcChannels.voiceGetState, () => voice.getState()); + // Pre-warm the engine (fork worker + load models) on mic intent so the next + // startCapture flips to listening instantly. Fire-and-forget, no state change. + handle<[], void>(IpcChannels.voiceWarm, () => voice.warm()); + handle<[string, string], void>(IpcChannels.voiceStart, async (_event, sessionId, mode) => { assertSessionId(sessionId); assertMode(mode); diff --git a/src/main/managers/AgentManager.ts b/src/main/managers/AgentManager.ts index b504e96..7eddad2 100644 --- a/src/main/managers/AgentManager.ts +++ b/src/main/managers/AgentManager.ts @@ -24,6 +24,7 @@ import type { Options, PermissionResult, SDKMessage, + SDKUserMessage, } from '@anthropic-ai/claude-agent-sdk'; import type { AgentActivityItem, @@ -74,6 +75,7 @@ import type { SessionManager } from './SessionManager'; import type { GitManager } from './GitManager'; import type { MemoryManager } from './memory/MemoryManager'; import { createMemoryMcpServer } from './memory/memoryTools'; +import type { AttachmentManager } from './attachments/AttachmentManager'; import type { SearchManager } from './search/SearchManager'; import { createSearchMcpServer } from './search/searchTools'; @@ -345,6 +347,8 @@ interface ActiveRun { result?: { ok: boolean; text: string }; /** Set true once an ExitPlanMode plan was captured (suppresses the failure throw). */ planCaptured?: boolean; + /** Attachments riding this turn (manifest + vision blocks; reused on retry). */ + attachmentIds?: string[]; } export class AgentManager { @@ -470,6 +474,28 @@ export class AgentManager { this.memory = memory; } + /** Attachment Manager, wired after construction (session-owned staged files). */ + private attachments: AttachmentManager | null = null; + + /** + * Inject the Attachment Manager. Attachments are a platform service owned by + * the app — the agent *consumes* them: a per-turn manifest, read access to the + * session's staging dir, vision blocks for images, and read-status tracking. + */ + setAttachmentManager(attachments: AttachmentManager): void { + this.attachments = attachments; + } + + /** The session's staging dir, or null when it has no attachments. */ + private attachmentsDirFor(sessionId: string): string | null { + if (!this.attachments) return null; + try { + return this.attachments.hasAny(sessionId) ? this.attachments.sessionDir(sessionId) : null; + } catch { + return null; + } + } + /** * Build the system-prompt addition that injects ranked, relevant memories for a * prompt. Returns undefined when memory is disabled / not injecting / empty. @@ -1096,6 +1122,7 @@ export class AgentManager { prompt: string, permMode: SessionPermissionMode = 'default', clientMessageId?: string, + attachmentIds?: string[], ): Promise { const isPlan = permMode === 'plan'; if (this.runs.has(sessionId)) { @@ -1126,6 +1153,13 @@ export class AgentManager { createdAt: Date.now(), }; this.persistMessage(userMsg); + // Bind composer drafts to this turn so the chips ride the echoed message + // (ownership is re-validated in the manager; foreign ids are dropped). + if (attachmentIds && attachmentIds.length > 0 && this.attachments) { + const attached = this.attachments.attachToMessage(sessionId, attachmentIds, userMsg.id); + if (attached.length > 0) userMsg.attachments = attached; + attachmentIds = attached.map((a) => a.id); + } this.pushEvent({ kind: 'message-done', sessionId, message: userMsg }); this.pushActivity(sessionId, 'prompt', 'You', prompt.slice(0, ACTIVITY_LIMITS.labelMax), 'info'); // Name an untitled session after its first prompt (a no-op once renamed). @@ -1141,7 +1175,12 @@ export class AgentManager { const cfg = this.settings.getAll().agent.connection; const abort = new AbortController(); - this.runs.set(sessionId, { abort, query: null, mode: isPlan ? 'plan' : 'implement' }); + this.runs.set(sessionId, { + abort, + query: null, + mode: isPlan ? 'plan' : 'implement', + attachmentIds: attachmentIds && attachmentIds.length > 0 ? attachmentIds : undefined, + }); this.clearIdleTimer(); this.setRequest(sessionId, { phase: 'submitting', @@ -1367,9 +1406,45 @@ export class AgentManager { }; } this.diag('lifecycle', 'debug', 'Handshake — query opened', undefined, sessionId); - const q = query({ prompt, options }) as unknown as AsyncIterable & { - close?: () => void; - }; + // Attachments ride the SDK prompt only (the persisted transcript keeps the + // raw prompt): a compact manifest tells the agent what is staged on disk so + // it Reads on demand, and raster images are additionally sent as vision + // content blocks via a one-shot streaming-input message. + const attachIds = this.runs.get(sessionId)?.attachmentIds ?? []; + const manifest = + attachIds.length > 0 && this.attachments + ? this.attachments.manifestFor(sessionId, attachIds) + : undefined; + const effectivePrompt = manifest ? `${prompt}\n\n${manifest}` : prompt; + const imageBlocks = + attachIds.length > 0 && this.attachments + ? this.attachments.imageBlocksFor(sessionId, attachIds) + : []; + let q: AsyncIterable & { close?: () => void }; + if (imageBlocks.length > 0) { + this.diag('request', 'info', `Attaching ${imageBlocks.length} image(s) for vision`, undefined, sessionId); + const userMessage = { + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: effectivePrompt }, ...imageBlocks], + }, + parent_tool_use_id: null, + session_id: '', + } as unknown as SDKUserMessage; + // One-shot generator: yields the single multimodal turn, then ends the + // input stream (the SDK treats generator return as end-of-input). + const oneShot = async function* (): AsyncGenerator { + yield userMessage; + }; + q = query({ prompt: oneShot(), options }) as unknown as AsyncIterable & { + close?: () => void; + }; + } else { + q = query({ prompt: effectivePrompt, options }) as unknown as AsyncIterable & { + close?: () => void; + }; + } if (run) run.query = q; this.setLifecycle('streaming'); this.setRequest(sessionId, { phase: 'streaming' }); @@ -1545,6 +1620,15 @@ export class AgentManager { // stdout, so this is a record (command now, output on result) — not a live PTY. if (name === 'Bash') this.mirrorCommandStart(sessionId, id, input); + // A read tool opening a staged attachment flips its chip to "read" live. + if (risk === 'read' && this.attachments) { + const file = filePathOf(input); + const dir = file ? this.attachmentsDirFor(sessionId) : null; + if (file && dir && isInside(dir, file)) { + this.attachments.markReadByPath(sessionId, file); + } + } + // Snapshot the pre-edit state before the first write/command of this run. if (risk === 'write' || name === 'Bash') this.maybeAutoCheckpoint(sessionId); @@ -1989,6 +2073,13 @@ export class AgentManager { } if (!agent.webSearch) options.disallowedTools = ['WebSearch', 'WebFetch']; + // Attachments: grant the SDK read access to THIS session's staging dir only + // (never the attachments root, never userData) so Read/Grep/Glob can open + // staged files on demand. Applied on every turn — prior-turn attachments + // stay readable across resumed conversations. + const attachmentsDir = this.attachmentsDirFor(sessionId); + if (attachmentsDir) options.additionalDirectories = [attachmentsDir]; + // Resume the Claude Code session so multi-turn conversations keep context. const sdkSessionId = this.loadSdkSession(sessionId); if (sdkSessionId) options.resume = sdkSessionId; @@ -2020,6 +2111,23 @@ export class AgentManager { return { behavior: 'deny', message: 'Plan captured for your review.', interrupt: true }; } + // Attachment carve-out (before the app-data guard, which would otherwise + // block the staging dir inside userData): READ tools may open files inside + // THIS session's own attachments dir. Writes/edits there stay denied below, + // and Bash referencing userData is still blocked by touchesAppData. + { + const attachmentsDir = this.attachmentsDirFor(sessionId); + const attachmentTarget = attachmentsDir ? filePathOf(input) : null; + if ( + attachmentsDir && + attachmentTarget && + classifyTool(toolName) === 'read' && + isInside(attachmentsDir, attachmentTarget) + ) { + return { behavior: 'allow', updatedInput: input }; + } + } + // App-data guard (defense in depth): the agent must never reach Limboo's own // store directly — the memory tools are the only sanctioned read path. This is // checked before any auto-allow so a Bash `sqlite3 …/limboo.db` can't slip past. @@ -2143,13 +2251,24 @@ export class AgentManager { 'SELECT id, session_id, role, text, created_at FROM agent_messages WHERE session_id = ? ORDER BY created_at ASC', ) .all(sessionId) as Array<{ id: string; session_id: string; role: string; text: string; created_at: number }>; + // Rehydrate the attachment chips of sent turns (message_id links the rows). + const byMessage = new Map(); + if (this.attachments) { + for (const meta of this.attachments.list(sessionId)) { + if (!meta.messageId) continue; + const bucket = byMessage.get(meta.messageId) ?? []; + bucket.push(meta); + byMessage.set(meta.messageId, bucket); + } + } return rows.map((r) => ({ id: r.id, sessionId: r.session_id, - role: r.role === 'assistant' ? 'assistant' : 'user', + role: r.role === 'assistant' ? ('assistant' as const) : ('user' as const), text: r.text, streaming: false, createdAt: r.created_at, + attachments: byMessage.get(r.id), })); } diff --git a/src/main/managers/SettingsManager.ts b/src/main/managers/SettingsManager.ts index aa91a76..43aa771 100644 --- a/src/main/managers/SettingsManager.ts +++ b/src/main/managers/SettingsManager.ts @@ -10,6 +10,7 @@ import type { AppSettings, DeepPartial } from '@shared/types'; import { AGENT_CONNECTION_LIMITS, AGENT_LIMITS, + ATTACHMENT_LIMITS, CHAT_FONTS, DEFAULT_SETTINGS, FONT_SCALE_LIMITS, @@ -204,6 +205,34 @@ export class SettingsManager { } plan.historyLimit = Math.round(clamp(plan.historyLimit, 1, 100)); + // Attachments — clamp the numeric caps, coerce the toggles, and whitelist + // the elevated-risk policy (renderer-supplied values gate real file I/O). + const att = merged.attachments; + const A = ATTACHMENT_LIMITS; + att.enabled = !!att.enabled; + att.maxFileSizeMB = Math.round( + clamp(att.maxFileSizeMB, A.maxFileSizeMB.min, A.maxFileSizeMB.max), + ); + att.maxFilesPerMessage = Math.round( + clamp(att.maxFilesPerMessage, A.maxFilesPerMessage.min, A.maxFilesPerMessage.max), + ); + att.maxTotalPerSession = Math.round( + clamp(att.maxTotalPerSession, A.maxTotalPerSession.min, A.maxTotalPerSession.max), + ); + for (const key of Object.keys(att.categories) as (keyof typeof att.categories)[]) { + att.categories[key] = !!att.categories[key]; + } + att.images.attachAsVision = !!att.images.attachAsVision; + att.images.downscaleThresholdMB = clamp( + att.images.downscaleThresholdMB, + A.downscaleThresholdMB.min, + A.downscaleThresholdMB.max, + ); + att.autoIndex = !!att.autoIndex; + if (!['block', 'warn'].includes(att.elevatedRiskPolicy)) { + att.elevatedRiskPolicy = 'block'; + } + merged.updates.autoCheck = !!merged.updates.autoCheck; merged.updates.autoDownload = !!merged.updates.autoDownload; diff --git a/src/main/managers/attachments/AttachmentManager.ts b/src/main/managers/attachments/AttachmentManager.ts new file mode 100644 index 0000000..da2146d --- /dev/null +++ b/src/main/managers/attachments/AttachmentManager.ts @@ -0,0 +1,740 @@ +/** + * Attachment Manager — the single owner of user-attached files. + * + * Files attached in the composer (picker / drag-drop / paste) become + * session-owned workspace resources: validated, hashed (SHA-256), MIME-sniffed, + * copied into a per-session staging directory under + * `userData/attachments//`, and recorded in `limboo.db`. The agent + * never receives raw attachment bytes in the prompt — it gets a compact + * manifest plus read access to the staging dir and pulls content on demand + * through its tool loop (images may additionally ride as vision blocks). + * + * Security (CLAUDE.md §6): the renderer never touches the filesystem; every + * source path is realpath-resolved (symlink-safe) and must be a regular file + * outside Limboo's own data dir; sizes/counts/lengths are capped; stored names + * are generated main-side; elevated-risk extensions are blocked by policy; + * archives are never extracted; SQL uses bound parameters only; staging writes + * are atomic (temp sibling + rename). Attaching NEVER executes anything. + */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { app, BrowserWindow, dialog, nativeImage, shell } from 'electron'; +import { IpcEvents } from '@shared/ipc-channels'; +import { ATTACHMENT_LIMITS } from '@shared/constants'; +import type { + AttachmentMeta, + AttachmentOrigin, + AttachmentProgress, + AttachmentStatus, +} from '@shared/types'; +import { getDb } from '../../db/database'; +import { logger } from '../../logger'; +import type { SettingsManager } from '../SettingsManager'; +import type { SessionManager } from '../SessionManager'; +import { + AttachmentError, + classifyCategory, + classifyRisk, + imageMagicMatches, + looksBinary, + mimeFor, + sanitizeName, + VISION_MEDIA_TYPES, +} from './validate'; + +/** Session ids are UUID-shaped; validated before ever joining into a path. */ +const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; +/** Attachment ids are generated main-side (randomUUID). */ +const ATTACHMENT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +/** Bytes sniffed from the head of a staged file for magic/NUL checks. */ +const SNIFF_BYTES = 16; + +/** One image content block for the Messages API (vision). */ +export interface AttachmentImageBlock { + type: 'image'; + source: { type: 'base64'; media_type: string; data: string }; +} + +interface AttachmentRow { + id: string; + session_id: string; + workspace_id: string; + name: string; + stored_name: string; + mime: string; + category: string; + size: number; + sha256: string; + status: string; + origin: string; + risk: string; + message_id: string | null; + thumb: string | null; + error: string | null; + created_at: number; + updated_at: number; +} + +function rowToMeta(row: AttachmentRow): AttachmentMeta { + return { + id: row.id, + sessionId: row.session_id, + workspaceId: row.workspace_id, + name: row.name, + storedName: row.stored_name, + mime: row.mime, + category: row.category as AttachmentMeta['category'], + size: row.size, + sha256: row.sha256, + status: row.status as AttachmentStatus, + origin: row.origin as AttachmentOrigin, + risk: row.risk as AttachmentMeta['risk'], + messageId: row.message_id, + thumb: row.thumb ?? undefined, + error: row.error ?? undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export class AttachmentManager { + private readonly db = getDb(); + + /** Root of all per-session staging dirs: `userData/attachments`. */ + private readonly root: string; + + /** One native picker at a time (mirrors workspace:pickDirectory). */ + private pickerOpen = false; + + constructor( + private readonly sessions: SessionManager, + private readonly settings: SettingsManager, + ) { + this.root = path.join(app.getPath('userData'), 'attachments'); + } + + /* ---------------------------------------------------------------- */ + /* Paths */ + /* ---------------------------------------------------------------- */ + + /** Absolute per-session staging dir. Validates the id before joining. */ + sessionDir(sessionId: string): string { + if (!SESSION_ID_RE.test(sessionId)) throw new AttachmentError('Invalid session id.'); + return path.join(this.root, sessionId); + } + + /* ---------------------------------------------------------------- */ + /* Queries */ + /* ---------------------------------------------------------------- */ + + list(sessionId: string): AttachmentMeta[] { + if (!SESSION_ID_RE.test(sessionId)) return []; + const rows = this.db + .prepare('SELECT * FROM attachments WHERE session_id = ? ORDER BY created_at') + .all(sessionId) as AttachmentRow[]; + return rows.map(rowToMeta); + } + + /** Attachments not yet bound to a sent message (the composer strip). */ + listDrafts(sessionId: string): AttachmentMeta[] { + return this.list(sessionId).filter((a) => a.messageId === null); + } + + /** True when the session has any staged attachment (drives additionalDirectories). */ + hasAny(sessionId: string): boolean { + if (!SESSION_ID_RE.test(sessionId)) return false; + const row = this.db + .prepare('SELECT 1 AS one FROM attachments WHERE session_id = ? LIMIT 1') + .get(sessionId) as { one: number } | undefined; + return !!row; + } + + /** Metas for the given ids, restricted to the session (foreign ids dropped). */ + private byIds(sessionId: string, ids: string[]): AttachmentMeta[] { + const all = new Map(this.list(sessionId).map((a) => [a.id, a])); + const out: AttachmentMeta[] = []; + for (const id of ids) { + const meta = typeof id === 'string' && ATTACHMENT_ID_RE.test(id) ? all.get(id) : undefined; + if (meta && meta.status !== 'error' && meta.status !== 'uploading') out.push(meta); + } + return out; + } + + /* ---------------------------------------------------------------- */ + /* Ingest */ + /* ---------------------------------------------------------------- */ + + /** Open the native multi-file picker, then stage the selection. */ + async pickAndAdd(sessionId: string): Promise { + if (this.pickerOpen) return []; + this.pickerOpen = true; + try { + const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed()); + const opts = { + title: 'Attach files', + properties: ['openFile', 'multiSelections'] as Array<'openFile' | 'multiSelections'>, + }; + const result = win ? await dialog.showOpenDialog(win, opts) : await dialog.showOpenDialog(opts); + if (result.canceled || result.filePaths.length === 0) return []; + return await this.addFromPaths(sessionId, result.filePaths, 'pick'); + } finally { + this.pickerOpen = false; + } + } + + /** + * Stage absolute source paths (picker / drop). Per-file failures do not abort + * the batch: successes stage + broadcast, then one error summarizing the + * failures is thrown for the renderer toast. + */ + async addFromPaths( + sessionId: string, + paths: string[], + origin: 'pick' | 'drop', + ): Promise { + const cfg = this.gate(sessionId); + if (!Array.isArray(paths) || paths.length === 0) return []; + if (paths.length > cfg.maxFilesPerMessage) { + throw new AttachmentError(`At most ${cfg.maxFilesPerMessage} files per message.`); + } + + const added: AttachmentMeta[] = []; + const failures: string[] = []; + for (const p of paths) { + try { + added.push(await this.stageOne(sessionId, p, origin)); + } catch (err) { + const name = typeof p === 'string' ? path.basename(p) : 'file'; + failures.push(`${name}: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (failures.length > 0) { + throw new AttachmentError(failures.join(' · ')); + } + return added; + } + + /** Stage pasted image bytes (renderer clipboard, already size-capped at IPC). */ + async addFromBytes( + sessionId: string, + name: string, + mime: string, + bytes: Uint8Array, + ): Promise { + const cfg = this.gate(sessionId); + if (!cfg.categories.images) throw new AttachmentError('Image attachments are disabled.'); + const buf = Buffer.from(bytes); + if (buf.length === 0) throw new AttachmentError('Empty image.'); + if (buf.length > ATTACHMENT_LIMITS.pasteBytesMax || buf.length > cfg.maxFileSizeMB * 1024 * 1024) { + throw new AttachmentError(`Image exceeds the ${cfg.maxFileSizeMB} MB limit.`); + } + if (!VISION_MEDIA_TYPES.has(mime)) throw new AttachmentError('Unsupported image type.'); + if (!imageMagicMatches(mime, buf.subarray(0, SNIFF_BYTES))) { + throw new AttachmentError('Image data does not match its type.'); + } + this.assertSessionCap(sessionId, 1, cfg.maxTotalPerSession); + + const safeName = sanitizeName(name || `pasted-${Date.now()}.png`); + const sha256 = crypto.createHash('sha256').update(buf).digest('hex'); + const existing = this.findByHash(sessionId, sha256); + if (existing) return this.touch(existing); + + const session = this.sessions.get(sessionId); + if (!session) throw new AttachmentError('Unknown session.'); + + const dir = this.ensureDir(sessionId); + const storedName = `${sha256.slice(0, 12)}-${safeName}`; + const finalPath = path.join(dir, storedName); + const tmp = path.join(dir, `.limboo-tmp-${crypto.randomBytes(6).toString('hex')}`); + fs.writeFileSync(tmp, buf); + fs.renameSync(tmp, finalPath); + + const meta = this.insertRow({ + sessionId, + workspaceId: session.workspaceId, + name: safeName, + storedName, + mime, + category: 'image', + size: buf.length, + sha256, + status: 'ready', + origin: 'paste', + risk: 'safe', + thumb: this.makeThumb(finalPath), + }); + this.broadcast(sessionId); + return meta; + } + + /** Remove one attachment: unlink the staged copy, delete the row. */ + remove(sessionId: string, id: string): void { + const meta = this.byIds(sessionId, [id])[0] ?? this.list(sessionId).find((a) => a.id === id); + if (!meta) return; + try { + fs.rmSync(path.join(this.sessionDir(sessionId), meta.storedName), { force: true }); + } catch (err) { + logger.warn(`Attachment unlink failed for ${meta.storedName}`, err); + } + this.db.prepare('DELETE FROM attachments WHERE id = ? AND session_id = ?').run(id, sessionId); + this.broadcast(sessionId); + } + + /** Reveal the staged copy in the OS file manager. */ + reveal(sessionId: string, id: string): void { + const meta = this.list(sessionId).find((a) => a.id === id); + if (!meta) return; + const target = path.join(this.sessionDir(sessionId), meta.storedName); + if (fs.existsSync(target)) shell.showItemInFolder(target); + } + + /* ---------------------------------------------------------------- */ + /* Message binding + agent lifecycle */ + /* ---------------------------------------------------------------- */ + + /** Bind composer drafts to the sent user message; status → 'referenced'. */ + attachToMessage(sessionId: string, ids: string[], messageId: string): AttachmentMeta[] { + const metas = this.byIds(sessionId, ids).filter((a) => a.messageId === null); + if (metas.length === 0) return []; + const now = Date.now(); + const stmt = this.db.prepare( + `UPDATE attachments SET message_id = ?, status = 'referenced', updated_at = ? + WHERE id = ? AND session_id = ? AND message_id IS NULL`, + ); + for (const meta of metas) stmt.run(messageId, now, meta.id, sessionId); + this.broadcast(sessionId); + return this.byIds(sessionId, metas.map((m) => m.id)); + } + + /** A read tool touched a staged path → mark that attachment 'read'. */ + markReadByPath(sessionId: string, absPath: string): void { + if (!SESSION_ID_RE.test(sessionId)) return; + const storedName = path.basename(absPath); + const changed = this.db + .prepare( + `UPDATE attachments SET status = 'read', updated_at = ? + WHERE session_id = ? AND stored_name = ? AND status IN ('ready', 'referenced')`, + ) + .run(Date.now(), sessionId, storedName); + if (changed.changes > 0) this.broadcast(sessionId); + } + + /** + * Render the `` manifest appended to the SDK prompt (never the + * persisted transcript). Lists the staged files so the agent reads them on + * demand with its tools instead of assuming contents. + */ + manifestFor(sessionId: string, ids: string[]): string | undefined { + const metas = this.byIds(sessionId, ids); + if (metas.length === 0) return undefined; + const dir = this.sessionDir(sessionId); + const header = + `\n` + + 'The user attached these files. They are staged on disk — read the ones you ' + + 'need with the Read/Grep tools (never assume contents). Do not modify them.\n'; + const footer = ''; + const lines: string[] = []; + let budget = ATTACHMENT_LIMITS.manifestCharBudget - header.length - footer.length - 64; + let omitted = 0; + for (const meta of metas) { + const visionNote = + meta.category === 'image' && VISION_MEDIA_TYPES.has(meta.mime) + ? ' (also provided inline as an image)' + : ''; + const line = + `- name="${meta.name}" type=${meta.mime} category=${meta.category} size=${meta.size}` + + `${visionNote}\n path="${path.join(dir, meta.storedName)}"\n`; + if (line.length > budget) { + omitted += 1; + continue; + } + budget -= line.length; + lines.push(line); + } + const tail = omitted > 0 ? `(+${omitted} more attachments not listed)\n` : ''; + return `${header}${lines.join('')}${tail}${footer}`; + } + + /** + * Base64 image content blocks for the vision send. Oversized images are + * downscaled via nativeImage; anything still above the API cap is skipped + * (it remains readable through the manifest path). + */ + imageBlocksFor(sessionId: string, ids: string[]): AttachmentImageBlock[] { + const cfg = this.settings.getAll().attachments; + if (!cfg.images.attachAsVision) return []; + const dir = this.sessionDir(sessionId); + const blocks: AttachmentImageBlock[] = []; + for (const meta of this.byIds(sessionId, ids)) { + if (meta.category !== 'image' || !VISION_MEDIA_TYPES.has(meta.mime)) continue; + const file = path.join(dir, meta.storedName); + let buf: Buffer; + try { + buf = fs.readFileSync(file); + } catch { + continue; + } + let mediaType = meta.mime; + const threshold = cfg.images.downscaleThresholdMB * 1024 * 1024; + if (buf.length > threshold) { + const img = nativeImage.createFromPath(file); + if (!img.isEmpty()) { + const { width, height } = img.getSize(); + const scale = Math.sqrt(threshold / buf.length); + const resized = img.resize({ + width: Math.max(64, Math.round(width * scale)), + height: Math.max(64, Math.round(height * scale)), + }); + buf = resized.toJPEG(80); + mediaType = 'image/jpeg'; + } + } + if (buf.length > ATTACHMENT_LIMITS.imageVisionMaxBytes) { + logger.warn(`Attachment ${meta.name} exceeds the vision size cap after downscale; manifest-only.`); + continue; + } + blocks.push({ + type: 'image', + source: { type: 'base64', media_type: mediaType, data: buf.toString('base64') }, + }); + } + return blocks; + } + + /* ---------------------------------------------------------------- */ + /* Teardown */ + /* ---------------------------------------------------------------- */ + + /** Permanently delete a session's staged files + rows (session purge). */ + async purgeSession(sessionId: string): Promise { + const dir = this.sessionDir(sessionId); + try { + await fs.promises.rm(dir, { recursive: true, force: true }); + } catch (err) { + logger.warn(`Attachment purge failed for ${sessionId}`, err); + } + this.db.prepare('DELETE FROM attachments WHERE session_id = ?').run(sessionId); + } + + /** + * Boot-time sweep: remove staging dirs whose session row no longer exists + * (covers purges that crashed mid-way). Trashed sessions keep their files. + */ + async sweepOrphans(): Promise { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(this.root, { withFileTypes: true }); + } catch { + return; // No staging root yet. + } + const rows = this.db.prepare('SELECT id FROM sessions').all() as Array<{ id: string }>; + const liveSessionIds = new Set(rows.map((r) => r.id)); + for (const entry of entries) { + if (!entry.isDirectory() || liveSessionIds.has(entry.name)) continue; + if (!SESSION_ID_RE.test(entry.name)) continue; + try { + await fs.promises.rm(path.join(this.root, entry.name), { recursive: true, force: true }); + this.db.prepare('DELETE FROM attachments WHERE session_id = ?').run(entry.name); + logger.info(`Swept orphaned attachment dir ${entry.name}`); + } catch (err) { + logger.warn(`Orphan sweep failed for ${entry.name}`, err); + } + } + } + + /* ---------------------------------------------------------------- */ + /* Internals */ + /* ---------------------------------------------------------------- */ + + /** Master-switch + per-call settings snapshot. */ + private gate(sessionId: string) { + const cfg = this.settings.getAll().attachments; + if (!cfg.enabled) throw new AttachmentError('Attachments are disabled in Settings.'); + if (!SESSION_ID_RE.test(sessionId)) throw new AttachmentError('Invalid session id.'); + return cfg; + } + + private assertSessionCap(sessionId: string, adding: number, maxTotal: number): void { + const row = this.db + .prepare('SELECT COUNT(*) AS n FROM attachments WHERE session_id = ?') + .get(sessionId) as { n: number }; + if (row.n + adding > maxTotal) { + throw new AttachmentError(`Session attachment limit (${maxTotal}) reached.`); + } + } + + private findByHash(sessionId: string, sha256: string): AttachmentMeta | undefined { + const row = this.db + .prepare('SELECT * FROM attachments WHERE session_id = ? AND sha256 = ?') + .get(sessionId, sha256) as AttachmentRow | undefined; + return row ? rowToMeta(row) : undefined; + } + + /** Bump updated_at on a deduped hit and return the existing row. */ + private touch(meta: AttachmentMeta): AttachmentMeta { + this.db + .prepare('UPDATE attachments SET updated_at = ? WHERE id = ?') + .run(Date.now(), meta.id); + this.broadcast(meta.sessionId); + return { ...meta, updatedAt: Date.now() }; + } + + private ensureDir(sessionId: string): string { + const dir = this.sessionDir(sessionId); + fs.mkdirSync(dir, { recursive: true }); + return dir; + } + + /** + * Full validation + staging pipeline for one source file. Inserts a + * placeholder 'uploading' row first so the chip renders with a progress ring, + * then finalizes (or removes) it. + */ + private async stageOne( + sessionId: string, + sourcePath: string, + origin: AttachmentOrigin, + ): Promise { + const cfg = this.settings.getAll().attachments; + if (typeof sourcePath !== 'string' || sourcePath.length === 0 || sourcePath.length > ATTACHMENT_LIMITS.pathMax) { + throw new AttachmentError('Invalid path.'); + } + if (sourcePath.includes('\0')) throw new AttachmentError('Invalid path.'); + + // Resolve symlinks and require a regular file outside our own data dir. + let real: string; + try { + real = fs.realpathSync(sourcePath); + } catch { + throw new AttachmentError('File does not exist or is not accessible.'); + } + let userData: string; + try { + userData = fs.realpathSync(app.getPath('userData')); + } catch { + userData = app.getPath('userData'); + } + const rel = path.relative(userData, real); + if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) { + throw new AttachmentError("Cannot attach files from Limboo's own data directory."); + } + let stat: fs.Stats; + try { + stat = fs.statSync(real); + } catch { + throw new AttachmentError('Could not read that path.'); + } + if (!stat.isFile()) throw new AttachmentError('Not a regular file.'); + if (stat.size > cfg.maxFileSizeMB * 1024 * 1024) { + throw new AttachmentError(`Exceeds the ${cfg.maxFileSizeMB} MB limit.`); + } + if (stat.size === 0) throw new AttachmentError('File is empty.'); + + const name = sanitizeName(path.basename(sourcePath)); + let category = classifyCategory(name); + const risk = classifyRisk(name); + if (risk === 'elevated' && cfg.elevatedRiskPolicy === 'block') { + throw new AttachmentError('Executable/script files are blocked (Settings › Attachments).'); + } + const allowed = + category === 'image' ? cfg.categories.images + : category === 'code' ? cfg.categories.code + : category === 'archive' ? cfg.categories.archives + : category === 'document' || category === 'data' ? cfg.categories.documents + : true; + if (!allowed) throw new AttachmentError(`${category} attachments are disabled in Settings.`); + + // Head sniff: image magic consistency + text/binary confusion. + const mime = mimeFor(name, category); + const head = Buffer.alloc(Math.min(SNIFF_BYTES, stat.size)); + const fd = fs.openSync(real, 'r'); + try { + fs.readSync(fd, head, 0, head.length, 0); + } finally { + fs.closeSync(fd); + } + if (category === 'image' && VISION_MEDIA_TYPES.has(mime) && !imageMagicMatches(mime, head)) { + throw new AttachmentError('Image data does not match its extension.'); + } + if ((category === 'code' || category === 'document' || category === 'data') && looksBinary(head)) { + category = 'other'; + } + + this.assertSessionCap(sessionId, 1, cfg.maxTotalPerSession); + const session = this.sessions.get(sessionId); + if (!session) throw new AttachmentError('Unknown session.'); + + // Placeholder row so the chip renders immediately with a progress ring. + // sha256 gets a unique placeholder until hashing completes (dedupe index). + const id = crypto.randomUUID(); + const uploading = this.insertRow({ + id, + sessionId, + workspaceId: session.workspaceId, + name, + storedName: '', + mime, + category, + size: stat.size, + sha256: `pending-${id}`, + status: 'uploading', + origin, + risk, + }); + this.broadcast(sessionId); + + const dir = this.ensureDir(sessionId); + const tmp = path.join(dir, `.limboo-tmp-${crypto.randomBytes(6).toString('hex')}`); + try { + const sha256 = await this.hashCopy(sessionId, id, real, tmp, stat.size); + + const existing = this.findByHash(sessionId, sha256); + if (existing) { + // Same content already staged — drop the temp + placeholder row. + await fs.promises.rm(tmp, { force: true }); + this.db.prepare('DELETE FROM attachments WHERE id = ?').run(id); + return this.touch(existing); + } + + const storedName = `${sha256.slice(0, 12)}-${name}`; + const finalPath = path.join(dir, storedName); + fs.renameSync(tmp, finalPath); + const thumb = category === 'image' ? this.makeThumb(finalPath) : undefined; + this.db + .prepare( + `UPDATE attachments SET stored_name = ?, sha256 = ?, status = 'ready', + thumb = ?, updated_at = ? WHERE id = ?`, + ) + .run(storedName, sha256, thumb ?? null, Date.now(), id); + this.broadcast(sessionId); + return { ...uploading, storedName, sha256, status: 'ready', thumb, updatedAt: Date.now() }; + } catch (err) { + await fs.promises.rm(tmp, { force: true }).catch(() => undefined); + this.db.prepare('DELETE FROM attachments WHERE id = ?').run(id); + this.broadcast(sessionId); + throw err; + } + } + + /** Stream source → temp while hashing, pushing throttled progress events. */ + private async hashCopy( + sessionId: string, + id: string, + source: string, + dest: string, + total: number, + ): Promise { + const hash = crypto.createHash('sha256'); + let copied = 0; + let lastPush = 0; + const push = (percent: number) => { + const now = Date.now(); + if (now - lastPush < ATTACHMENT_LIMITS.progressThrottleMs && percent < 100) return; + lastPush = now; + const progress: AttachmentProgress = { sessionId, id, percent }; + this.send(IpcEvents.attachmentProgress, progress); + }; + const meter = new Transform({ + transform(chunk: Buffer, _enc, cb) { + hash.update(chunk); + copied += chunk.length; + push(Math.min(100, Math.round((copied / total) * 100))); + cb(null, chunk); + }, + }); + await pipeline(fs.createReadStream(source), meter, fs.createWriteStream(dest, { flags: 'wx' })); + push(100); + return hash.digest('hex'); + } + + /** Tiny data-URL thumbnail via nativeImage (images only; SVG unsupported). */ + private makeThumb(file: string): string | undefined { + try { + const img = nativeImage.createFromPath(file); + if (img.isEmpty()) return undefined; + const url = img.resize({ height: ATTACHMENT_LIMITS.thumbEdgePx }).toDataURL(); + return url.length <= ATTACHMENT_LIMITS.thumbDataUrlMax ? url : undefined; + } catch { + return undefined; + } + } + + private insertRow(input: { + id?: string; + sessionId: string; + workspaceId: string; + name: string; + storedName: string; + mime: string; + category: string; + size: number; + sha256: string; + status: AttachmentStatus; + origin: AttachmentOrigin; + risk: 'safe' | 'elevated'; + thumb?: string; + }): AttachmentMeta { + const now = Date.now(); + const id = input.id ?? crypto.randomUUID(); + this.db + .prepare( + `INSERT INTO attachments + (id, session_id, workspace_id, name, stored_name, mime, category, size, + sha256, status, origin, risk, message_id, thumb, error, meta, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, NULL, '{}', ?, ?)`, + ) + .run( + id, + input.sessionId, + input.workspaceId, + input.name, + input.storedName, + input.mime, + input.category, + input.size, + input.sha256, + input.status, + input.origin, + input.risk, + input.thumb ?? null, + now, + now, + ); + return { + id, + sessionId: input.sessionId, + workspaceId: input.workspaceId, + name: input.name, + storedName: input.storedName, + mime: input.mime, + category: input.category as AttachmentMeta['category'], + size: input.size, + sha256: input.sha256, + status: input.status, + origin: input.origin, + risk: input.risk, + messageId: null, + thumb: input.thumb, + createdAt: now, + updatedAt: now, + }; + } + + /** Push the session's full attachment set to every window. */ + private broadcast(sessionId: string): void { + this.send(IpcEvents.attachmentsChanged, { + sessionId, + attachments: this.list(sessionId), + }); + } + + private send(channel: string, payload: unknown): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(channel, payload); + } + } +} diff --git a/src/main/managers/attachments/validate.ts b/src/main/managers/attachments/validate.ts new file mode 100644 index 0000000..ec96976 --- /dev/null +++ b/src/main/managers/attachments/validate.ts @@ -0,0 +1,162 @@ +/** + * Attachment validation — classification, name sanitizing, and content sniffing + * for files entering the per-session staging area. Pure functions, no I/O except + * the head-bytes sniff helpers (callers pass buffers). + * + * Security (CLAUDE.md §6): display names are reduced to a safe character set, + * Windows reserved device names are refused, image extensions must match their + * magic bytes, and executables/scripts/installers are flagged as elevated risk. + * Attaching NEVER executes anything; archives are never extracted. + */ +import path from 'node:path'; +import { + ATTACHMENT_ARCHIVE_EXTENSIONS, + ATTACHMENT_ELEVATED_EXTENSIONS, + ATTACHMENT_LIMITS, + FS_LIMITS, +} from '@shared/constants'; +import type { AttachmentCategory, AttachmentRisk } from '@shared/types'; + +/** Thrown when an attachment is rejected for a security/validation reason. */ +export class AttachmentError extends Error { + constructor(message: string) { + super(message); + this.name = 'AttachmentError'; + } +} + +/** Windows reserved device names (case-insensitive, extension-independent). */ +const RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + +/** Reduce a user-supplied filename to a safe display/storage basename. */ +export function sanitizeName(input: string): string { + const base = path.basename(String(input)); + const cleaned = base + .replace(/[^A-Za-z0-9._ -]+/g, '_') + .replace(/\s+/g, ' ') + .replace(/^[. ]+/, '') + .trim() + .slice(0, ATTACHMENT_LIMITS.nameMax); + if (!cleaned || /^\.+$/.test(cleaned)) { + throw new AttachmentError('Invalid file name.'); + } + const stem = cleaned.includes('.') ? cleaned.slice(0, cleaned.indexOf('.')) : cleaned; + if (RESERVED_NAMES.test(stem)) { + throw new AttachmentError('Reserved file name.'); + } + return cleaned; +} + +/** Lower-cased extension without the dot ('' when none). */ +export function extOf(name: string): string { + const e = path.extname(name).toLowerCase(); + return e.startsWith('.') ? e.slice(1) : e; +} + +const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif']); +const CODE_EXTS = new Set([ + 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts', 'py', 'rb', 'go', 'rs', + 'java', 'kt', 'kts', 'c', 'h', 'cpp', 'cc', 'hpp', 'cs', 'php', 'swift', 'm', + 'scala', 'lua', 'pl', 'r', 'dart', 'vue', 'svelte', 'astro', 'css', 'scss', + 'less', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', +]); +const DOCUMENT_EXTS = new Set([ + 'md', 'markdown', 'txt', 'pdf', 'doc', 'docx', 'rtf', 'odt', 'html', 'htm', + 'log', 'rst', 'adoc', 'tex', 'epub', +]); +const DATA_EXTS = new Set([ + 'json', 'jsonl', 'yaml', 'yml', 'toml', 'xml', 'csv', 'tsv', 'sql', 'ini', + 'env', 'lock', 'properties', 'plist', 'proto', 'graphql', +]); +const ARCHIVE_EXTS = new Set(ATTACHMENT_ARCHIVE_EXTENSIONS); +const ELEVATED_EXTS = new Set(ATTACHMENT_ELEVATED_EXTENSIONS); + +/** Extension → coarse category (icons, gates, handling). */ +export function classifyCategory(name: string): AttachmentCategory { + const ext = extOf(name); + if (IMAGE_EXTS.has(ext)) return 'image'; + if (ARCHIVE_EXTS.has(ext)) return 'archive'; + if (CODE_EXTS.has(ext)) return 'code'; + if (DOCUMENT_EXTS.has(ext)) return 'document'; + if (DATA_EXTS.has(ext)) return 'data'; + return 'other'; +} + +/** Executables / scripts / installers are elevated risk (never executed). */ +export function classifyRisk(name: string): AttachmentRisk { + return ELEVATED_EXTS.has(extOf(name)) ? 'elevated' : 'safe'; +} + +/** Minimal extension → MIME map (display + agent manifest + vision gating). */ +const MIME_BY_EXT: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + bmp: 'image/bmp', + ico: 'image/x-icon', + avif: 'image/avif', + pdf: 'application/pdf', + json: 'application/json', + yaml: 'application/yaml', + yml: 'application/yaml', + xml: 'application/xml', + csv: 'text/csv', + md: 'text/markdown', + txt: 'text/plain', + log: 'text/plain', + html: 'text/html', + htm: 'text/html', + zip: 'application/zip', + tar: 'application/x-tar', + gz: 'application/gzip', + tgz: 'application/gzip', + '7z': 'application/x-7z-compressed', + rar: 'application/vnd.rar', +}; + +/** Best-effort MIME for a filename (text/plain for code, octet-stream fallback). */ +export function mimeFor(name: string, category: AttachmentCategory): string { + const byExt = MIME_BY_EXT[extOf(name)]; + if (byExt) return byExt; + if (category === 'code' || category === 'data' || category === 'document') return 'text/plain'; + return 'application/octet-stream'; +} + +/** Image media types the Messages API accepts as vision content blocks. */ +export const VISION_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); + +/** NUL-byte sniff of the head of a buffer (same heuristic as the File Reader). */ +export function looksBinary(buf: Buffer): boolean { + const len = Math.min(buf.length, FS_LIMITS.binarySniffBytes); + for (let i = 0; i < len; i += 1) { + if (buf[i] === 0) return true; + } + return false; +} + +/** + * Verify the head bytes of a claimed raster image actually match its magic + * number, so a renamed executable can never masquerade as a screenshot. SVG + * (text) and less common formats are exempt — they are never vision-sent. + */ +export function imageMagicMatches(mime: string, head: Buffer): boolean { + switch (mime) { + case 'image/png': + return head.length >= 4 && head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47; + case 'image/jpeg': + return head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff; + case 'image/gif': + return head.length >= 4 && head.subarray(0, 4).toString('latin1') === 'GIF8'; + case 'image/webp': + return ( + head.length >= 12 && + head.subarray(0, 4).toString('latin1') === 'RIFF' && + head.subarray(8, 12).toString('latin1') === 'WEBP' + ); + default: + return true; + } +} diff --git a/src/main/managers/voice/VoiceManager.ts b/src/main/managers/voice/VoiceManager.ts index 46d05b4..0ad0eca 100644 --- a/src/main/managers/voice/VoiceManager.ts +++ b/src/main/managers/voice/VoiceManager.ts @@ -133,23 +133,51 @@ export class VoiceManager { this.applyInterruption(); this.setState({ phase: 'starting', sessionId, error: undefined }); + const activation = voice.input.activation; try { await this.ensureWorker(); - await this.ensureLoaded('vad'); - await this.ensureLoaded('stt'); + // Auto mode needs the VAD loaded before audio flows (the worker feeds every + // chunk through it to detect speech). Manual mode just accumulates raw audio, + // so it needs neither VAD nor STT to begin — start listening instantly. + if (activation === 'auto') await this.ensureLoaded('vad'); } catch (err) { this.setState({ phase: 'unavailable', error: String(err) }); throw err; } + // STT (Parakeet, ~600 MB) is the slow model and is only needed at transcription + // time, not to start listening — load it in the background. The worker queues any + // segment captured before it finishes (see worker.ts `sttReady`), so nothing is + // lost. Manual mode also warms the VAD lazily (harmless if unused). + void this.ensureLoaded('stt').catch((err) => logger.warn('voice: stt load failed', err)); + if (activation !== 'auto') { + void this.ensureLoaded('vad').catch(() => undefined); + } + this.captureSessionId = sessionId; this.captureMode = mode; - const activation = voice.input.activation; this.post({ t: 'capture-start', mode: activation === 'auto' ? 'auto' : 'manual' }); this.setState({ phase: activation === 'auto' ? 'listening' : 'recording', sessionId }); this.touchActivity(); } + /** + * Warm the speech engine ahead of an actual capture: fork the worker and load + * the VAD + STT models in the background so the next `startCapture` flips to + * listening instantly. Fire-and-forget, no state change; safe to call on mic + * hover/focus. Model memory is still reclaimed by the idle timer. + */ + warm(): void { + const voice = this.settings.getAll().voice; + if (!voice.enabled) return; + this.refreshModelsReady(); + if (!this.state.modelsReady.stt || !this.state.modelsReady.vad) return; + void this.ensureWorker() + .then(() => Promise.all([this.ensureLoaded('vad'), this.ensureLoaded('stt')])) + .then(() => this.touchActivity()) + .catch((err) => logger.warn('voice: warm failed', err)); + } + /** One mic PCM chunk from the renderer (already size-capped by the handler). */ pushAudio(pcm: ArrayBuffer): void { if (!this.captureSessionId || !this.worker) return; diff --git a/src/main/voice/worker.ts b/src/main/voice/worker.ts index 1d3ac80..5c1952e 100644 --- a/src/main/voice/worker.ts +++ b/src/main/voice/worker.ts @@ -67,6 +67,19 @@ let recognizer: OfflineRecognizer | null = null; let tts: OfflineTts | null = null; let vad: Vad | null = null; +/** + * Resolves once the STT model has finished loading (or failed). STT is loaded + * lazily in the background so a capture can start listening before it is ready + * (see VoiceManager.startCapture); `transcribe()` awaits this so any segment + * captured during the load window is decoded as soon as the model is available + * instead of being silently dropped. Resolved on failure too, so the decode + * chain never hangs (a null recognizer just yields no transcript). + */ +let markSttReady!: () => void; +const sttReady: Promise = new Promise((resolve) => { + markSttReady = resolve; +}); + async function loadStt(paths: SttModelPaths, numThreads: number): Promise { recognizer = await OfflineRecognizer.createAsync({ featConfig: { sampleRate: VOICE_SAMPLE_RATE, featureDim: 80 }, @@ -148,11 +161,16 @@ function resetCapture(): void { } function transcribe(samples: Float32Array): void { - const rec = recognizer; - if (!rec || samples.length === 0) return; + if (samples.length === 0) return; const durationMs = Math.round((samples.length / VOICE_SAMPLE_RATE) * 1000); decodeChain = decodeChain .then(async () => { + // STT may still be loading (deferred so listening can start instantly) — + // wait for it, then read the recognizer at decode time. Keeps segments in + // order and guarantees nothing captured during the load window is lost. + await sttReady; + const rec = recognizer; + if (!rec) return; const stream = rec.createStream(); stream.acceptWaveform({ samples, sampleRate: VOICE_SAMPLE_RATE }); const result = await rec.decodeAsync(stream); @@ -357,7 +375,18 @@ parentPort.on('message', (e) => { const fail = (err: unknown) => send({ t: 'load-error', kind: msg.kind, message: String(err) }); try { - if (msg.kind === 'stt') void loadStt(msg.paths, msg.numThreads).then(done, fail); + if (msg.kind === 'stt') + void loadStt(msg.paths, msg.numThreads).then( + () => { + markSttReady(); + done(); + }, + (err) => { + // Unblock any queued transcribe() so the decode chain never hangs. + markSttReady(); + fail(err); + }, + ); else if (msg.kind === 'tts') void loadTts(msg.paths, msg.numThreads).then(done, fail); else { loadVad(msg.paths, msg.sensitivity, msg.silenceMs); diff --git a/src/preload/index.ts b/src/preload/index.ts index e8cb6e9..1ff7ba9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -18,6 +18,8 @@ import type { AgentState, AppInfo, AppSettings, + AttachmentMeta, + AttachmentProgress, ClarificationDecision, ClarificationRequest, CommandId, @@ -251,8 +253,9 @@ const agentApi = { prompt: string, mode?: SessionPermissionMode, clientMessageId?: string, + attachmentIds?: string[], ): Promise => - ipcRenderer.invoke(IpcChannels.agentSend, sessionId, prompt, mode, clientMessageId), + ipcRenderer.invoke(IpcChannels.agentSend, sessionId, prompt, mode, clientMessageId, attachmentIds), stop: (sessionId: string): Promise => ipcRenderer.invoke(IpcChannels.agentStop, sessionId), getPlan: (sessionId: string): Promise => ipcRenderer.invoke(IpcChannels.agentGetPlan, sessionId), @@ -489,6 +492,46 @@ const memoryApi = { onChanged: (cb: () => void): (() => void) => subscribe(IpcEvents.memoryChanged, cb), }; +const attachmentApi = { + /** All attachments of a session (drafts + sent), oldest first. */ + list: (sessionId: string): Promise => + ipcRenderer.invoke(IpcChannels.attachmentList, sessionId), + /** Open the native multi-file picker and stage the selection. */ + pickFiles: (sessionId: string): Promise => + ipcRenderer.invoke(IpcChannels.attachmentPickFiles, sessionId), + /** Stage dropped files by absolute path (from `getPathForFile`). */ + addPaths: (sessionId: string, paths: string[]): Promise => + ipcRenderer.invoke(IpcChannels.attachmentAddPaths, sessionId, paths), + /** Stage a pasted image (clipboard bytes; validated + capped in main). */ + addPasted: ( + sessionId: string, + name: string, + mime: string, + bytes: ArrayBuffer, + ): Promise => + ipcRenderer.invoke(IpcChannels.attachmentAddPasted, sessionId, name, mime, bytes), + remove: (sessionId: string, id: string): Promise => + ipcRenderer.invoke(IpcChannels.attachmentRemove, sessionId, id), + /** Show the staged copy in the OS file manager. */ + reveal: (sessionId: string, id: string): Promise => + ipcRenderer.invoke(IpcChannels.attachmentReveal, sessionId, id), + /** + * Resolve the real path of a dropped/picked File object (Electron 32+ removed + * `File.path`). The path is handed straight to the validated attachment IPC — + * the renderer never touches the filesystem itself. + */ + getPathForFile: (file: File): string => webUtils.getPathForFile(file), + onChanged: ( + cb: (payload: { sessionId: string; attachments: AttachmentMeta[] }) => void, + ): (() => void) => + subscribe<{ sessionId: string; attachments: AttachmentMeta[] }>( + IpcEvents.attachmentsChanged, + cb, + ), + onProgress: (cb: (progress: AttachmentProgress) => void): (() => void) => + subscribe(IpcEvents.attachmentProgress, cb), +}; + const searchApi = { /** Unified, cross-subsystem search — ranked hits grouped by originating source. */ global: (query: string, opts: SearchQueryOptions): Promise => @@ -542,6 +585,8 @@ const updatesApi = { const voiceApi = { /** Current voice runtime state (for hydration on mount). */ getState: (): Promise => ipcRenderer.invoke(IpcChannels.voiceGetState), + /** Pre-warm the speech engine (fork worker + load models) on mic intent. */ + warm: (): Promise => ipcRenderer.invoke(IpcChannels.voiceWarm), /** Begin a capture session bound to a session (same mode as a typed send). */ start: (sessionId: string, mode: SessionPermissionMode): Promise => ipcRenderer.invoke(IpcChannels.voiceStart, sessionId, mode), @@ -602,6 +647,7 @@ const limbooApi = { search: searchApi, updates: updatesApi, voice: voiceApi, + attachment: attachmentApi, }; contextBridge.exposeInMainWorld('limboo', limbooApi); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index bfa4468..e2bf6e6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -28,6 +28,7 @@ import { useMemoryStore } from '@/renderer/stores/useMemoryStore'; import { useSearchStore } from '@/renderer/stores/useSearchStore'; import { useUpdateStore } from '@/renderer/stores/useUpdateStore'; import { useVoiceStore } from '@/renderer/stores/useVoiceStore'; +import { useAttachmentStore } from '@/renderer/stores/useAttachmentStore'; export function App() { useKeyboardShortcuts(); @@ -70,6 +71,8 @@ export function App() { void useVoiceStore.getState().hydrate(); // Subscribe to supervised-service lifecycle pushes (Services strip). useServiceStore.getState().hydrate(); + // Subscribe to attachment set + staging-progress pushes (composer chips). + useAttachmentStore.getState().hydrate(); }, []); return ( diff --git a/src/renderer/features/settings/catalog.tsx b/src/renderer/features/settings/catalog.tsx index 60bafa6..4f71fe0 100644 --- a/src/renderer/features/settings/catalog.tsx +++ b/src/renderer/features/settings/catalog.tsx @@ -17,6 +17,7 @@ import { Info, ListTodo, Mic, + Paperclip, TerminalSquare, ArrowUpCircle, type LucideIcon, @@ -30,6 +31,7 @@ import { PlanTasksPanel } from './panels/PlanTasksPanel'; import { TerminalPanel } from './panels/TerminalPanel'; import { GitPanel } from './panels/GitPanel'; import { MemoryPanel } from './panels/MemoryPanel'; +import { AttachmentsPanel } from './panels/AttachmentsPanel'; import { VoicePanel } from './panels/VoicePanel'; import { ShortcutsPanel } from './panels/ShortcutsPanel'; import { UpdatesPanel } from './panels/UpdatesPanel'; @@ -232,6 +234,27 @@ export const SETTINGS_CATALOG: SettingsCategory[] = [ ], Panel: GitPanel, }, + { + id: 'attachments', + label: 'Attachments', + icon: Paperclip, + keywords: ['file', 'files', 'upload', 'attach', 'attachment', 'image', 'screenshot', 'paste', 'drag', 'drop', 'vision', 'pdf', 'document', 'archive', 'staging'], + fields: [ + { id: 'attEnabled', label: 'Enable attachments', keywords: ['on', 'off', 'toggle', 'master'] }, + { id: 'attMaxFileSize', label: 'Max file size', keywords: ['size', 'mb', 'limit', 'cap'] }, + { id: 'attMaxPerMessage', label: 'Files per message', keywords: ['count', 'limit', 'multiple'] }, + { id: 'attMaxPerSession', label: 'Files per session', keywords: ['count', 'limit', 'total'] }, + { id: 'attCatImages', label: 'Images', keywords: ['png', 'jpg', 'screenshot', 'category'] }, + { id: 'attCatDocuments', label: 'Documents & data', keywords: ['pdf', 'markdown', 'json', 'logs', 'category'] }, + { id: 'attCatCode', label: 'Source code', keywords: ['code', 'category'] }, + { id: 'attCatArchives', label: 'Archives', keywords: ['zip', 'tar', 'category', 'extract'] }, + { id: 'attRiskPolicy', label: 'Executables & scripts', keywords: ['risk', 'block', 'warn', 'security', 'exe', 'script'] }, + { id: 'attVision', label: 'Send images to the model', keywords: ['vision', 'multimodal', 'see', 'image'] }, + { id: 'attDownscale', label: 'Downscale above', keywords: ['resize', 'image', 'size', 'vision'] }, + { id: 'attAutoIndex', label: 'Index into Search', keywords: ['search', 'index', 'find'] }, + ], + Panel: AttachmentsPanel, + }, { id: 'memory', label: 'Memory & Search', diff --git a/src/renderer/features/settings/panels/AttachmentsPanel.tsx b/src/renderer/features/settings/panels/AttachmentsPanel.tsx new file mode 100644 index 0000000..460169f --- /dev/null +++ b/src/renderer/features/settings/panels/AttachmentsPanel.tsx @@ -0,0 +1,162 @@ +/** + * Attachment settings — files attached in the composer (picker / drag-drop / + * paste) become session-owned staged copies under the app's user-data dir. The + * agent reads them on demand through its tool loop; images can additionally be + * sent to the model as vision blocks. Attaching never executes anything and + * archives are never extracted. + */ +import { ATTACHMENT_LIMITS, clamp } from '@shared/constants'; +import { useSettingsStore } from '@/renderer/stores/useSettingsStore'; +import { Field, Section, SegmentedControl, Select, Toggle } from '../controls'; + +export function AttachmentsPanel() { + const attachments = useSettingsStore((s) => s.settings.attachments); + const update = useSettingsStore((s) => s.update); + const A = ATTACHMENT_LIMITS; + + return ( +
+
+ + void update({ attachments: { enabled: v } })} + /> + + + + value={clamp(attachments.maxFileSizeMB, A.maxFileSizeMB.min, A.maxFileSizeMB.max)} + options={[5, 10, 25, 50, 100].map((n) => ({ value: n, label: `${n} MB` }))} + onChange={(v) => void update({ attachments: { maxFileSizeMB: v } })} + /> + + + + value={clamp(attachments.maxFilesPerMessage, A.maxFilesPerMessage.min, A.maxFilesPerMessage.max)} + options={[3, 5, 10, 15, 20].map((n) => ({ value: n, label: String(n) }))} + onChange={(v) => void update({ attachments: { maxFilesPerMessage: v } })} + /> + + + + value={clamp(attachments.maxTotalPerSession, A.maxTotalPerSession.min, A.maxTotalPerSession.max)} + options={[25, 50, 100, 250, 500].map((n) => ({ value: n, label: String(n) }))} + onChange={(v) => void update({ attachments: { maxTotalPerSession: v } })} + /> + +
+ +
+ + void update({ attachments: { categories: { images: v } } })} + /> + + + void update({ attachments: { categories: { documents: v } } })} + /> + + + void update({ attachments: { categories: { code: v } } })} + /> + + + void update({ attachments: { categories: { archives: v } } })} + /> + + + + value={attachments.elevatedRiskPolicy} + options={[ + { value: 'block', label: 'Block' }, + { value: 'warn', label: 'Warn' }, + ]} + onChange={(v) => void update({ attachments: { elevatedRiskPolicy: v } })} + /> + +
+ +
+ + void update({ attachments: { images: { attachAsVision: v } } })} + /> + + {attachments.images.attachAsVision && ( + + + value={clamp( + attachments.images.downscaleThresholdMB, + A.downscaleThresholdMB.min, + A.downscaleThresholdMB.max, + )} + options={[1, 2, 3, 4, 5].map((n) => ({ value: n, label: `${n} MB` }))} + onChange={(v) => void update({ attachments: { images: { downscaleThresholdMB: v } } })} + /> + + )} +
+ +
+ + void update({ attachments: { autoIndex: v } })} + /> + +
+
+ ); +} diff --git a/src/renderer/features/workspace/AttachmentChip.tsx b/src/renderer/features/workspace/AttachmentChip.tsx new file mode 100644 index 0000000..5c99ad5 --- /dev/null +++ b/src/renderer/features/workspace/AttachmentChip.tsx @@ -0,0 +1,98 @@ +/** + * One attachment chip — the compact card rendered in the composer strip + * (drafts, removable) and on sent user messages (read-only). Leading visual is + * the image thumbnail when available, else the per-language file icon; the + * trailing slot reflects the lifecycle: a circular progress ring while staging, + * a check once the agent actually read the file, a warning for elevated-risk + * or errored files, and a remove button for drafts. + */ +import { AlertTriangle, Check, X } from 'lucide-react'; +import type { AttachmentMeta } from '@shared/types'; +import { cn } from '@/renderer/lib/cn'; +import { CircularProgress } from '@/renderer/components/ui'; +import { getFileIcon } from '@/renderer/lib/fileIcons'; +import { formatBytes } from '@/renderer/lib/format'; + +/** Short human label for the type column (extension over raw MIME noise). */ +function typeLabel(meta: AttachmentMeta): string { + const dot = meta.name.lastIndexOf('.'); + if (dot > 0 && dot < meta.name.length - 1) return meta.name.slice(dot + 1).toUpperCase(); + return meta.category; +} + +export function AttachmentChip({ + meta, + progress, + onRemove, + onClick, +}: { + meta: AttachmentMeta; + /** Live staging progress (0–100) while `status === 'uploading'`. */ + progress?: number; + /** Present only for composer drafts — sent chips are read-only. */ + onRemove?: () => void; + /** Optional activation (e.g. reveal in the OS file manager). */ + onClick?: () => void; +}) { + const { icon: Icon, className: iconClass } = getFileIcon(meta.name); + const uploading = meta.status === 'uploading'; + const warn = meta.status === 'error' || meta.risk === 'elevated'; + + return ( +
+ {meta.thumb ? ( + + ) : ( + + + + )} + + + {meta.name} + + {typeLabel(meta)} · {formatBytes(meta.size)} + {meta.status === 'read' && ' · read'} + + + + + {uploading ? ( + + ) : meta.status === 'error' ? ( + + ) : meta.risk === 'elevated' ? ( + + ) : meta.status === 'read' ? ( + + ) : null} + {onRemove && ( + + )} + +
+ ); +} diff --git a/src/renderer/features/workspace/AttachmentStrip.tsx b/src/renderer/features/workspace/AttachmentStrip.tsx new file mode 100644 index 0000000..8b96fb2 --- /dev/null +++ b/src/renderer/features/workspace/AttachmentStrip.tsx @@ -0,0 +1,35 @@ +/** + * The composer's attachment strip — draft attachments rendered as compact + * chips above the input row, ChatGPT-style. Lives INSIDE the composer card so + * the whole surface reads as one input; hidden entirely while empty. + */ +import type { AttachmentMeta } from '@shared/types'; +import { useAttachmentStore } from '@/renderer/stores/useAttachmentStore'; +import { AttachmentChip } from './AttachmentChip'; + +export function AttachmentStrip({ + sessionId, + drafts, +}: { + sessionId: string; + drafts: AttachmentMeta[]; +}) { + const progress = useAttachmentStore((s) => s.progress); + const remove = useAttachmentStore((s) => s.remove); + const reveal = useAttachmentStore((s) => s.reveal); + + if (drafts.length === 0) return null; + return ( +
+ {drafts.map((meta) => ( + void remove(sessionId, meta.id)} + onClick={meta.status === 'uploading' ? undefined : () => reveal(sessionId, meta.id)} + /> + ))} +
+ ); +} diff --git a/src/renderer/features/workspace/CenterWorkspace.tsx b/src/renderer/features/workspace/CenterWorkspace.tsx index eb2457f..7226b29 100644 --- a/src/renderer/features/workspace/CenterWorkspace.tsx +++ b/src/renderer/features/workspace/CenterWorkspace.tsx @@ -26,6 +26,7 @@ import { useAgentStore } from '@/renderer/stores/useAgentStore'; import { useLayoutStore } from '@/renderer/stores/useLayoutStore'; import { useGitStore } from '@/renderer/stores/useGitStore'; import { useUIStore } from '@/renderer/stores/useUIStore'; +import { useAttachmentStore } from '@/renderer/stores/useAttachmentStore'; export function CenterWorkspace() { const session = useSessionStore((s) => @@ -43,7 +44,11 @@ export function CenterWorkspace() { // Restore the transcript whenever the selected session changes. useEffect(() => { - if (session) void loadSession(session.id); + if (session) { + void loadSession(session.id); + // Restore the session's attachment set (composer drafts + sent chips). + void useAttachmentStore.getState().loadSession(session.id); + } }, [session?.id, loadSession]); return ( diff --git a/src/renderer/features/workspace/Composer.tsx b/src/renderer/features/workspace/Composer.tsx index 4b01f4b..299499b 100644 --- a/src/renderer/features/workspace/Composer.tsx +++ b/src/renderer/features/workspace/Composer.tsx @@ -13,7 +13,7 @@ * rate limits, expired auth, pending approvals, and context warnings. */ import { useEffect, useRef, useState } from 'react'; -import type { KeyboardEvent } from 'react'; +import type { ClipboardEvent, DragEvent, KeyboardEvent } from 'react'; import { ArrowUp, CircleStop, Mic, Paperclip, Sparkles, Volume2 } from 'lucide-react'; import type { SessionPermissionMode } from '@shared/types'; import { cn } from '@/renderer/lib/cn'; @@ -24,12 +24,15 @@ import { useSettingsStore } from '@/renderer/stores/useSettingsStore'; import { useWorkspaceStore } from '@/renderer/stores/useWorkspaceStore'; import { useVoiceStore } from '@/renderer/stores/useVoiceStore'; import { useUIStore } from '@/renderer/stores/useUIStore'; +import { useAttachmentStore, draftAttachments } from '@/renderer/stores/useAttachmentStore'; +import { useFileDragActive } from '@/renderer/hooks/usePreventFileDrop'; import { lifecycleMeta, phaseLabel } from '@/renderer/features/agent/status'; import { RUNNING_PHASES } from '@/renderer/features/sessions/useSessionRunning'; import { ComposerControls } from './ComposerControls'; import { ComposerModeSwitch } from './ComposerModeSwitch'; import { ComposerBanner } from './ComposerBanner'; import { ComposerVoiceOverlay } from './ComposerVoiceOverlay'; +import { AttachmentStrip } from './AttachmentStrip'; /** Voice phases during which the composer shows the recording overlay. */ const VOICE_CAPTURE_PHASES = new Set(['starting', 'listening', 'recording', 'transcribing']); @@ -80,6 +83,45 @@ export function Composer({ disabled = false }: { disabled?: boolean }) { const restricted = lifecycle === 'rate-limited' || lifecycle === 'auth-required'; const blocked = disabled || !installed || busy || restricted; + // Attachments — ChatGPT-style file chips above the input. Drafts belong to + // the SESSION (main-process Attachment Manager), so they survive reloads and + // session switches until sent or removed. + const attachmentsEnabled = useSettingsStore((s) => s.settings.attachments.enabled); + const sessionAttachments = useAttachmentStore((s) => + sessionId ? s.bySession[sessionId] : undefined, + ); + const drafts = draftAttachments(sessionAttachments); + // Only fully staged drafts ride a send; uploads still in flight stay behind. + const readyDraftIds = drafts + .filter((d) => d.status !== 'uploading' && d.status !== 'error') + .map((d) => d.id); + const pickFiles = useAttachmentStore((s) => s.pickFiles); + const addDropped = useAttachmentStore((s) => s.addDropped); + const pasteImage = useAttachmentStore((s) => s.pasteImage); + const canAttach = !blocked && attachmentsEnabled && !!sessionId; + + // Highlight the card as a drop target only while a file drag is in flight. + const fileDragActive = useFileDragActive(); + const [dragOver, setDragOver] = useState(false); + + const onDrop = (e: DragEvent) => { + e.preventDefault(); + setDragOver(false); + if (!canAttach || !sessionId) return; + const files = Array.from(e.dataTransfer?.files ?? []); + if (files.length > 0) void addDropped(sessionId, files); + }; + + const onPaste = (e: ClipboardEvent) => { + if (!canAttach || !sessionId) return; + const images = Array.from(e.clipboardData?.files ?? []).filter((f) => + f.type.startsWith('image/'), + ); + if (images.length === 0) return; + e.preventDefault(); + for (const img of images) void pasteImage(sessionId, img); + }; + // Voice — speech is another input for the SAME session (never a separate // conversation). The mic is the DEFAULT primary button while the textarea is // empty; typing morphs it into the send arrow (ChatGPT-style). @@ -92,6 +134,12 @@ export function Composer({ disabled = false }: { disabled?: boolean }) { const voiceReady = voiceEnabled && modelsReady.stt && modelsReady.vad; const voiceCapturing = VOICE_CAPTURE_PHASES.has(voicePhase) && voiceSessionId === sessionId; + // Warm the speech engine when the user reaches for the mic (hover/focus) so the + // click flips to listening instantly. No-op if models aren't installed. + const warmVoice = () => { + void window.limboo?.voice?.warm().catch(() => undefined); + }; + const beginVoice = () => { if (blocked || !sessionId) return; if (!voiceReady) { @@ -129,8 +177,9 @@ export function Composer({ disabled = false }: { disabled?: boolean }) { const submit = () => { const text = value.trim(); - if (!text || blocked || !sessionId) return; - void send(sessionId, text, mode); + // Attachments-only sends are allowed (main substitutes a review prompt). + if ((!text && readyDraftIds.length === 0) || blocked || !sessionId) return; + void send(sessionId, text, mode, readyDraftIds.length > 0 ? readyDraftIds : undefined); setValue(''); if (ref.current) { ref.current.style.height = 'auto'; @@ -150,16 +199,42 @@ export function Composer({ disabled = false }: { disabled?: boolean }) {
-
+
{ + if (!canAttach) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={onDrop} + className={cn( + 'flex flex-col rounded-3xl border bg-surface-2 px-4 py-2.5 shadow-[0_8px_30px_rgba(0,0,0,0.6)] transition-colors focus-within:border-line-strong', + fileDragActive && canAttach + ? dragOver + ? 'border-accent' + : 'border-line-strong border-dashed' + : 'border-line', + )} + > + {sessionId && } +
{voiceCapturing ? ( ) : ( <> @@ -173,6 +248,7 @@ export function Composer({ disabled = false }: { disabled?: boolean }) { autoGrow(); }} onKeyDown={onKeyDown} + onPaste={onPaste} placeholder={composerPlaceholder(disabled, installed, restricted, mode)} className="flex-1 resize-none bg-transparent py-1 text-[13px] leading-relaxed text-fg placeholder:text-faint focus:outline-none disabled:cursor-not-allowed" style={{ maxHeight: MAX_HEIGHT }} @@ -186,12 +262,14 @@ export function Composer({ disabled = false }: { disabled?: boolean }) { Stop - ) : value.trim().length === 0 ? ( - /* Default (empty) state: the voice button — typing morphs it - into the send arrow, ChatGPT-style. */ + ) : value.trim().length === 0 && readyDraftIds.length === 0 ? ( + /* Default (empty) state: the voice button — typing (or a + staged attachment) morphs it into the send arrow. */
{/* One-line footer: as the center column narrows (side panels dragged diff --git a/src/renderer/features/workspace/ConversationView.tsx b/src/renderer/features/workspace/ConversationView.tsx index 4c97646..c598bc9 100644 --- a/src/renderer/features/workspace/ConversationView.tsx +++ b/src/renderer/features/workspace/ConversationView.tsx @@ -15,7 +15,7 @@ */ import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { Check, ChevronRight, CircleAlert } from 'lucide-react'; -import type { AgentActivityItem, AgentToolCall, ChatMessage, PermissionRequest } from '@shared/types'; +import type { AgentActivityItem, AgentToolCall, AttachmentMeta, ChatMessage, PermissionRequest } from '@shared/types'; import { Logo } from '@/renderer/components/brand/Logo'; import { Spinner } from '@/renderer/components/ui'; import { DiffStat } from '@/renderer/components/ui/DiffStat'; @@ -23,6 +23,8 @@ import { cn } from '@/renderer/lib/cn'; import { useAgentStore, EMPTY_SNAPSHOT } from '@/renderer/stores/useAgentStore'; import { useLayoutStore } from '@/renderer/stores/useLayoutStore'; import { useGitStore } from '@/renderer/stores/useGitStore'; +import { useAttachmentStore } from '@/renderer/stores/useAttachmentStore'; +import { AttachmentChip } from './AttachmentChip'; import { Markdown } from './Markdown'; import { MessageSkeleton, ThinkingPulse } from './MessageSkeleton'; import { InlineApproval } from './InlineApproval'; @@ -326,7 +328,10 @@ function WaitingForDecision() { function UserBubble({ message }: { message: ChatMessage }) { return ( -
+
+ {message.attachments && message.attachments.length > 0 && ( + + )}
{message.text}
@@ -334,6 +339,32 @@ function UserBubble({ message }: { message: ChatMessage }) { ); } +/** + * Read-only attachment chips on a sent user turn. Status is looked up live in + * the attachment store (so a chip flips to "read" the moment the agent opens + * the file), with the persisted meta on the message as fallback. + */ +function MessageAttachments({ + sessionId, + attachments, +}: { + sessionId: string; + attachments: AttachmentMeta[]; +}) { + const live = useAttachmentStore((s) => s.bySession[sessionId]); + const reveal = useAttachmentStore((s) => s.reveal); + return ( +
+ {attachments.map((meta) => { + const current = live?.find((a) => a.id === meta.id) ?? meta; + return ( + reveal(sessionId, meta.id)} /> + ); + })} +
+ ); +} + /** The assistant's response for a turn: a single avatar plus a vertical flow of * chronologically-interleaved sub-items (text, tool rows, status markers, and an * optional trailing approval). */ diff --git a/src/renderer/stores/useAgentStore.ts b/src/renderer/stores/useAgentStore.ts index d41c621..507567a 100644 --- a/src/renderer/stores/useAgentStore.ts +++ b/src/renderer/stores/useAgentStore.ts @@ -29,6 +29,7 @@ import type { SessionPermissionMode, } from '@shared/types'; import { useUIStore } from './useUIStore'; +import { useAttachmentStore } from './useAttachmentStore'; function emptySnapshot(): AgentSessionSnapshot { return { messages: [], activity: [], changes: [], tasks: [], toolCalls: [], plan: null }; @@ -76,7 +77,12 @@ interface AgentStoreState { hydrate: () => Promise; loadSession: (sessionId: string) => Promise; loadDiagnostics: (sessionId?: string | null) => Promise; - send: (sessionId: string, prompt: string, mode?: SessionPermissionMode) => Promise; + send: ( + sessionId: string, + prompt: string, + mode?: SessionPermissionMode, + attachmentIds?: string[], + ) => Promise; stop: (sessionId: string) => void; clear: (sessionId: string) => void; clearRateLimit: () => void; @@ -342,7 +348,7 @@ export const useAgentStore = create((set, get) => { set({ diagnostics: diagnostics.slice(-MAX_DIAGNOSTICS) }); }, - send: async (sessionId, prompt, mode) => { + send: async (sessionId, prompt, mode, attachmentIds) => { const api = window.limboo?.agent; if (!api) return; // Optimistic render: show the user's turn the instant Send is clicked, @@ -353,13 +359,22 @@ export const useAgentStore = create((set, get) => { typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `local-${Date.now()}-${Math.random().toString(36).slice(2)}`; + // Attachment chips render on the optimistic bubble too; the echoed message + // carries the authoritative (main-validated) set. + const draftMetas = attachmentIds?.length + ? (useAttachmentStore.getState().bySession[sessionId] ?? []).filter((a) => + attachmentIds.includes(a.id), + ) + : undefined; const optimistic: ChatMessage = { id: clientMessageId, sessionId, role: 'user', - text: prompt, + // An attachments-only send shows main's substituted instruction. + text: prompt.trim().length === 0 && attachmentIds?.length ? 'Review the attached files.' : prompt, streaming: false, createdAt: Date.now(), + attachments: draftMetas && draftMetas.length > 0 ? draftMetas : undefined, }; set((state) => { const snapshot = state.bySession[sessionId] ?? emptySnapshot(); @@ -374,7 +389,7 @@ export const useAgentStore = create((set, get) => { }; }); try { - await api.send(sessionId, prompt, mode, clientMessageId); + await api.send(sessionId, prompt, mode, clientMessageId, attachmentIds); } catch (err) { useUIStore.getState().addToast({ title: 'Could not reach the agent', diff --git a/src/renderer/stores/useAttachmentStore.ts b/src/renderer/stores/useAttachmentStore.ts new file mode 100644 index 0000000..b998f82 --- /dev/null +++ b/src/renderer/stores/useAttachmentStore.ts @@ -0,0 +1,164 @@ +/** + * Attachment store — the renderer mirror of the main-process Attachment + * Manager. Holds every session's attachment set (drafts + sent) plus live + * staging progress. All mutations go through `window.limboo.attachment.*`; + * main pushes the authoritative set back via `attachment:changed`, so this + * store never invents state — it only reflects it. + */ +import { create } from 'zustand'; +import type { AttachmentMeta } from '@shared/types'; +import { useUIStore } from './useUIStore'; + +interface AttachmentStoreState { + /** All attachments per session (drafts + sent), oldest first. */ + bySession: Record; + /** Live staging progress per attachment id (0–100). */ + progress: Record; + /** True once the push-event subscriptions are installed. */ + hydrated: boolean; + + /** Install push-event subscriptions (once, on boot). */ + hydrate: () => void; + /** Load a session's attachment set (on session open/switch). */ + loadSession: (sessionId: string) => Promise; + /** Open the native file picker and stage the selection. */ + pickFiles: (sessionId: string) => Promise; + /** Stage files dropped onto the composer. */ + addDropped: (sessionId: string, files: File[]) => Promise; + /** Stage an image pasted into the composer. */ + pasteImage: (sessionId: string, file: File) => Promise; + remove: (sessionId: string, id: string) => Promise; + reveal: (sessionId: string, id: string) => void; +} + +/** Composer drafts = attachments not yet bound to a sent message. */ +export function draftAttachments(list: AttachmentMeta[] | undefined): AttachmentMeta[] { + return (list ?? []).filter((a) => a.messageId === null); +} + +function toastError(title: string, err: unknown): void { + useUIStore.getState().addToast({ + title, + description: err instanceof Error ? err.message : String(err), + tone: 'danger', + }); +} + +export const useAttachmentStore = create((set, get) => ({ + bySession: {}, + progress: {}, + hydrated: false, + + hydrate: () => { + const api = window.limboo?.attachment; + if (!api || get().hydrated) return; + api.onChanged(({ sessionId, attachments }) => { + set((state) => { + // Drop progress entries for attachments that finished staging. + const uploading = new Set( + attachments.filter((a) => a.status === 'uploading').map((a) => a.id), + ); + const progress: Record = {}; + for (const [id, pct] of Object.entries(state.progress)) { + if (uploading.has(id)) progress[id] = pct; + } + return { + bySession: { ...state.bySession, [sessionId]: attachments }, + progress, + }; + }); + }); + api.onProgress(({ id, percent }) => { + set((state) => ({ progress: { ...state.progress, [id]: percent } })); + }); + set({ hydrated: true }); + }, + + loadSession: async (sessionId) => { + const api = window.limboo?.attachment; + if (!api) return; + try { + const attachments = await api.list(sessionId); + set((state) => ({ bySession: { ...state.bySession, [sessionId]: attachments } })); + } catch { + /* Session may be gone; the empty state is fine. */ + } + }, + + pickFiles: async (sessionId) => { + const api = window.limboo?.attachment; + if (!api) return; + const before = new Set((get().bySession[sessionId] ?? []).map((a) => a.id)); + try { + const metas = await api.pickFiles(sessionId); + notifyDuplicates(metas, before); + } catch (err) { + toastError('Could not attach files', err); + } + }, + + addDropped: async (sessionId, files) => { + const api = window.limboo?.attachment; + if (!api || files.length === 0) return; + const paths: string[] = []; + for (const file of files) { + try { + const p = api.getPathForFile(file); + if (p) paths.push(p); + } catch { + /* Non-filesystem drop (e.g. text selection) — skipped. */ + } + } + if (paths.length === 0) return; + const before = new Set((get().bySession[sessionId] ?? []).map((a) => a.id)); + try { + const metas = await api.addPaths(sessionId, paths); + notifyDuplicates(metas, before); + } catch (err) { + toastError('Could not attach files', err); + } + }, + + pasteImage: async (sessionId, file) => { + const api = window.limboo?.attachment; + if (!api) return; + const before = new Set((get().bySession[sessionId] ?? []).map((a) => a.id)); + try { + const bytes = await file.arrayBuffer(); + const meta = await api.addPasted( + sessionId, + file.name || `pasted-${Date.now()}.png`, + file.type, + bytes, + ); + notifyDuplicates([meta], before); + } catch (err) { + toastError('Could not attach the image', err); + } + }, + + remove: async (sessionId, id) => { + const api = window.limboo?.attachment; + if (!api) return; + try { + await api.remove(sessionId, id); + } catch (err) { + toastError('Could not remove the attachment', err); + } + }, + + reveal: (sessionId, id) => { + void window.limboo?.attachment?.reveal(sessionId, id); + }, +})); + +/** Content-identical re-attaches return the existing row — tell the user. */ +function notifyDuplicates(metas: AttachmentMeta[], before: Set): void { + const dupes = metas.filter((m) => before.has(m.id)); + if (dupes.length === 0) return; + useUIStore.getState().addToast({ + title: dupes.length === 1 ? 'Already attached' : 'Some files were already attached', + description: dupes.map((d) => d.name).join(', '), + tone: 'warning', + }); +} diff --git a/src/renderer/stores/useVoiceStore.ts b/src/renderer/stores/useVoiceStore.ts index 5b99905..33ffb07 100644 --- a/src/renderer/stores/useVoiceStore.ts +++ b/src/renderer/stores/useVoiceStore.ts @@ -87,19 +87,55 @@ export const useVoiceStore = create((set, get) => ({ const api = window.limboo?.voice; if (!api) return; set({ error: null }); + + // Open the mic in parallel with the main-side start so getUserMedia + the + // AudioWorklet warm up while the worker forks and models load, instead of + // strictly after. Buffer PCM until main reports a mic phase — its pushAudio + // drops audio until the capture session is armed — then flush, so the very + // first sound is never lost. + const deviceId = useSettingsStore.getState().settings.voice.input.deviceId; + /** ~2 s of 16 kHz mono Int16 (16000 samples/s × 2 bytes × 2 s). */ + const MAX_BUFFERED_BYTES = 16000 * 2 * 2; + let pending: ArrayBuffer[] = []; + let pendingBytes = 0; + let live = false; + + const onChunk = (pcm: ArrayBuffer) => { + if (live) { + api.pushAudio(pcm); + return; + } + if (MIC_PHASES.has(get().state.phase)) { + live = true; + for (const buffered of pending) api.pushAudio(buffered); + pending = []; + pendingBytes = 0; + api.pushAudio(pcm); + return; + } + // Still starting: hold the chunk, keeping only the most recent ~2 s. + pending.push(pcm); + pendingBytes += pcm.byteLength; + while (pendingBytes > MAX_BUFFERED_BYTES && pending.length > 1) { + const dropped = pending.shift(); + if (dropped) pendingBytes -= dropped.byteLength; + } + }; + + const capturePromise = startCapture({ deviceId: deviceId || undefined, onChunk }); + try { - // Main first: it validates models + settings and flips to `starting`. + // Validates models + settings and flips to `starting`/`listening`. await api.start(sessionId, mode); } catch (err) { + // Main rejected (models missing / disabled): tear the mic back down once it opens. + void capturePromise.then(() => stopCapture()).catch(() => undefined); set({ error: friendlyError(err) }); throw err; } + try { - const deviceId = useSettingsStore.getState().settings.voice.input.deviceId; - await startCapture({ - deviceId: deviceId || undefined, - onChunk: (pcm) => api.pushAudio(pcm), - }); + await capturePromise; } catch (err) { // Mic denied / missing: abandon the main-side capture session too. await api.cancel().catch(() => undefined); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 005a743..291df1e 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,7 +1,7 @@ import type { AppSettings, WorkspaceConfig } from './types'; /** Bumped whenever the {@link AppSettings} shape changes incompatibly. */ -export const SETTINGS_VERSION = 11; +export const SETTINGS_VERSION = 12; /** The agent providers Limboo can show a glyph for (Claude Code = Anthropic). */ export type AgentProvider = 'anthropic'; @@ -210,6 +210,55 @@ export const VOICE_LIMITS = { progressThrottleMs: 150, } as const; +/** + * Bounds + caps for the Attachment Manager (main + renderer both clamp). + * Attachments are session-owned staged copies under + * `userData/attachments//` — never executed, never extracted. + */ +export const ATTACHMENT_LIMITS = { + /** Per-file size cap the user can tune (MB). */ + maxFileSizeMB: { min: 1, max: 100, default: 25 }, + /** Files attachable to a single message. */ + maxFilesPerMessage: { min: 1, max: 20, default: 10 }, + /** Total attachments a session may accumulate. */ + maxTotalPerSession: { min: 10, max: 500, default: 100 }, + /** Messages API hard cap per image content block (decoded bytes). */ + imageVisionMaxBytes: 5 * 1024 * 1024, + /** Threshold (MB) above which an image is downscaled before vision send. */ + downscaleThresholdMB: { min: 1, max: 5, default: 3 }, + /** Longest edge (px) of the chip thumbnail. */ + thumbEdgePx: 96, + /** Cap on the stored thumbnail data URL (chars). */ + thumbDataUrlMax: 65_536, + /** Display-name length cap (sanitized basename). */ + nameMax: 120, + /** Source-path length cap accepted from the renderer. */ + pathMax: 4096, + /** Attachment id length cap. */ + idMax: 64, + /** Char budget of the `` manifest appended to a prompt. */ + manifestCharBudget: 4_000, + /** Max bytes accepted for one pasted (in-memory) image over IPC. */ + pasteBytesMax: 32 * 1024 * 1024, + /** Min interval (ms) between staging-progress pushes to the renderer. */ + progressThrottleMs: 100, +} as const; + +/** + * Extensions classified as elevated risk (executables / scripts / installers). + * Attaching NEVER executes anything; policy `block` refuses these, `warn` + * stages them flagged so the UI shows a warning tone. + */ +export const ATTACHMENT_ELEVATED_EXTENSIONS = [ + 'exe', 'dll', 'msi', 'scr', 'com', 'bat', 'cmd', 'ps1', 'psm1', 'vbs', + 'vbe', 'jse', 'wsf', 'jar', 'lnk', 'sh', 'app', 'dmg', 'pkg', 'deb', 'rpm', +] as const; + +/** Archive extensions — attachable when enabled, but never auto-extracted. */ +export const ATTACHMENT_ARCHIVE_EXTENSIONS = [ + 'zip', 'tar', 'gz', 'tgz', '7z', 'rar', 'bz2', 'xz', 'zst', +] as const; + export const FONT_SCALE_LIMITS = { min: 0.85, max: 1.3, default: 1 } as const; /** @@ -375,6 +424,24 @@ export const DEFAULT_SETTINGS: AppSettings = { fuzzy: true, openOnClick: true, }, + attachments: { + enabled: true, + maxFileSizeMB: ATTACHMENT_LIMITS.maxFileSizeMB.default, + maxFilesPerMessage: ATTACHMENT_LIMITS.maxFilesPerMessage.default, + maxTotalPerSession: ATTACHMENT_LIMITS.maxTotalPerSession.default, + categories: { + images: true, + documents: true, + code: true, + archives: false, + }, + images: { + attachAsVision: true, + downscaleThresholdMB: ATTACHMENT_LIMITS.downscaleThresholdMB.default, + }, + autoIndex: false, + elevatedRiskPolicy: 'block', + }, updates: { autoCheck: true, autoDownload: true, @@ -427,7 +494,7 @@ export function clamp(value: number, min: number, max: number): number { /* ------------------------------------------------------------------ */ /** Bumped whenever the workspace DB schema changes incompatibly. */ -export const WORKSPACE_SCHEMA_VERSION = 8; +export const WORKSPACE_SCHEMA_VERSION = 9; /** Input caps the main process enforces on renderer-supplied session values. */ export const SESSION_LIMITS = { diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 1c25868..2434023 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -178,8 +178,17 @@ export const IpcChannels = { searchSavedCreate: 'search:savedCreate', searchSavedDelete: 'search:savedDelete', + // Attachment Manager — session-owned files staged for the agent's tool loop. + attachmentList: 'attachment:list', + attachmentPickFiles: 'attachment:pickFiles', + attachmentAddPaths: 'attachment:addPaths', + attachmentAddPasted: 'attachment:addPasted', + attachmentRemove: 'attachment:remove', + attachmentReveal: 'attachment:reveal', + // Voice subsystem — local STT/TTS as another modality for the agent session. voiceGetState: 'voice:getState', + voiceWarm: 'voice:warm', voiceStart: 'voice:start', voiceStop: 'voice:stop', voiceCancel: 'voice:cancel', @@ -262,6 +271,10 @@ export const IpcEvents = { searchChanged: 'search:changed', /** Progress of an in-flight search index pass. */ searchIndexProgress: 'search:index-progress', + /** A session's attachment set changed (staged / sent / read / removed). */ + attachmentsChanged: 'attachment:changed', + /** Staging progress for one attachment (hash + copy percent). */ + attachmentProgress: 'attachment:progress', /** The auto-update lifecycle advanced (checking / available / progress / ready). */ updateStatus: 'update:status', /** The voice runtime state changed (idle / listening / transcribing / speaking). */ diff --git a/src/shared/types.ts b/src/shared/types.ts index 4181f52..1d331ca 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -343,6 +343,40 @@ export interface AppSettings { /** Title-bar search box opens the modal on click; off = only the ⌘P/Ctrl+P shortcut. */ openOnClick: boolean; }; + /** + * Attachment Manager — user-supplied files attached in the composer become + * session-owned staged copies under `userData/attachments//`. The + * agent reads them on demand through its tool loop (never inlined wholesale); + * images can additionally ride the prompt as vision blocks. Attaching never + * executes anything and archives are never extracted. + */ + attachments: { + /** Master switch for attaching files (composer button, drop, paste). */ + enabled: boolean; + /** Per-file size cap (MB). */ + maxFileSizeMB: number; + /** Files attachable to a single message. */ + maxFilesPerMessage: number; + /** Total attachments a session may accumulate. */ + maxTotalPerSession: number; + /** Per-category attach permissions (archives are off by default). */ + categories: { + images: boolean; + documents: boolean; + code: boolean; + archives: boolean; + }; + images: { + /** Send attached images to the model as vision content blocks. */ + attachAsVision: boolean; + /** Downscale images above this size (MB) before the vision send. */ + downscaleThresholdMB: number; + }; + /** Index text attachments into the Search Engine (hook point; off = never). */ + autoIndex: boolean; + /** Executables/scripts: refuse outright, or stage flagged with a warning. */ + elevatedRiskPolicy: 'block' | 'warn'; + }; /** * In-app auto-update (electron-updater + GitHub releases). Only ever active in * a packaged build; a no-op in dev. Limboo downloads updates over HTTPS from @@ -1598,6 +1632,61 @@ export interface ChatMessage { /** True while assistant tokens are still streaming in. */ streaming: boolean; createdAt: number; + /** Files the user attached to this turn (hydrated main-side; user role only). */ + attachments?: AttachmentMeta[]; +} + +/* ------------------------------------------------------------------ */ +/* Attachment Manager */ +/* ------------------------------------------------------------------ */ + +/** Coarse file classification driving icons, category gates, and handling. */ +export type AttachmentCategory = 'image' | 'code' | 'document' | 'data' | 'archive' | 'other'; + +/** + * Lifecycle of an attachment: `uploading` while hashing/staging, `ready` once + * staged, `referenced` when sent with a message, `read` once the agent actually + * opened it through a read tool, `error` when staging failed. + */ +export type AttachmentStatus = 'uploading' | 'ready' | 'referenced' | 'read' | 'error'; + +/** How the file entered the workspace. */ +export type AttachmentOrigin = 'pick' | 'drop' | 'paste'; + +/** Elevated = executable/script/installer class; staged only under `warn` policy. */ +export type AttachmentRisk = 'safe' | 'elevated'; + +/** Metadata record for one session-owned attachment (the staged copy is on disk). */ +export interface AttachmentMeta { + id: string; + sessionId: string; + workspaceId: string; + /** Sanitized display name (original basename). */ + name: string; + /** On-disk name inside the session staging dir (`-`). */ + storedName: string; + mime: string; + category: AttachmentCategory; + size: number; + sha256: string; + status: AttachmentStatus; + origin: AttachmentOrigin; + risk: AttachmentRisk; + /** Null while a composer draft; set when sent with a user message. */ + messageId: string | null; + /** Tiny base64 data-URL thumbnail (images only, capped). */ + thumb?: string; + error?: string; + createdAt: number; + updatedAt: number; +} + +/** Staging progress pushed while a file is hashed + copied. */ +export interface AttachmentProgress { + sessionId: string; + id: string; + /** 0–100. */ + percent: number; } /** Risk class used to gate a tool call. */