/** * manage_messages — inspect and mutate the conversation history. * * Works on the active thread's IndexedDB store (careless-thread-{id}) * or falls back to the default key (careless-v2-messages). * * Emits `careless:messages-changed` event so useAgent re-hydrates. */ import { tool } from '@strands-agents/sdk' import { z } from 'zod' import { get, set, del } from 'idb-keyval' const DEFAULT_KEY = 'careless-v2-messages' const THREADS_META_KEY = 'careless-threads-meta' interface ThreadMeta { id: string; title: string; createdAt: number; updatedAt: number; pinned?: boolean } async function getActiveKey(): Promise { try { const meta = await get<{ activeId?: string; threads?: ThreadMeta[] }>(THREADS_META_KEY) if (meta?.activeId) return `careless-thread-${meta.activeId}` } catch { /* ignore */ } return DEFAULT_KEY } async function loadMessages(key: string): Promise { const m = await get(key) return Array.isArray(m) ? m : [] } async function saveMessages(key: string, msgs: any[]) { await set(key, msgs) window.dispatchEvent(new CustomEvent('careless:messages-changed', { detail: { key, count: msgs.length } })) } function previewText(msg: any, maxLen = 120): string { const blocks = Array.isArray(msg.content) ? msg.content : [{ text: String(msg.content || '') }] const parts: string[] = [] for (const b of blocks) { if (b.text) parts.push(b.text) else if (b.toolUse) parts.push(`[toolUse:${b.toolUse.name}#${(b.toolUse.toolUseId || '').slice(0, 8)}]`) else if (b.toolResult) parts.push(`[toolResult#${(b.toolResult.toolUseId || '').slice(0, 8)}]`) else if (b.image) parts.push('[image]') else if (b.document) parts.push(`[document:${b.document.name || '?'}]`) } const joined = parts.join(' ') return joined.length > maxLen ? joined.slice(0, maxLen) + '…' : joined } /** Returns {set, error} — error is non-null if range inputs are invalid. */ function parseTurnIndices(turns: string | undefined, start: number | undefined, end: number | undefined, total: number): { set: Set; error: string | null } { const set = new Set() // Validate range before building if (typeof start === 'number' && typeof end === 'number' && start > end) { return { set, error: `invalid range: start (${start}) > end (${end})` } } if (typeof start === 'number' && start < 0) { return { set, error: `start must be >= 0 (got ${start})` } } if (turns) { for (const t of turns.split(',').map(s => s.trim()).filter(Boolean)) { const n = parseInt(t, 10) if (isNaN(n)) return { set, error: `invalid turn index: "${t}"` } if (n < 0 || n >= total) return { set, error: `turn ${n} out of range [0, ${total})` } set.add(n) } } if (typeof start === 'number') { const e = typeof end === 'number' ? end : start + 1 for (let i = Math.max(0, start); i < Math.min(total, e); i++) set.add(i) } return { set, error: null } } /** Strip tool blocks in a single message; keep text-only content. Returns {msg, removed}. */ function compactMessage(msg: any): { msg: any; removed: number } { const blocks = Array.isArray(msg.content) ? msg.content : [{ text: String(msg.content || '') }] const keep = blocks.filter((b: any) => b.text || b.image || b.document) const removed = blocks.length - keep.length return { msg: { ...msg, content: keep.length ? keep : [{ text: '' }] }, removed } } /** Drop orphan toolUse/toolResult pairs so the conversation stays valid. */ function pruneOrphanTools(msgs: any[]): any[] { const toolUseIds = new Set() const toolResultIds = new Set() for (const m of msgs) { const blocks = Array.isArray(m.content) ? m.content : [] for (const b of blocks) { if (b.toolUse?.toolUseId) toolUseIds.add(b.toolUse.toolUseId) if (b.toolResult?.toolUseId) toolResultIds.add(b.toolResult.toolUseId) } } return msgs.map(m => { const blocks = Array.isArray(m.content) ? m.content : [] const kept = blocks.filter((b: any) => { if (b.toolUse) return toolResultIds.has(b.toolUse.toolUseId) if (b.toolResult) return toolUseIds.has(b.toolResult.toolUseId) return true }) // Only keep message if it has meaningful content (non-empty text or any tool/media) return { ...m, content: kept.length ? kept : [] } }).filter(m => { if (!Array.isArray(m.content) || m.content.length === 0) return false return m.content.some((b: any) => (b.text && String(b.text).length > 0) || b.toolUse || b.toolResult || b.image || b.document ) }) } export const manageMessagesTool = tool({ name: 'manage_messages', description: 'Inspect & modify the active thread\'s conversation history (agent.messages). ' + 'Actions: list | list_tools | stats | drop | drop_tools | compact | clear | export | import. ' + 'Mutating actions fire a reload event so the agent hydrates fresh state.', inputSchema: z.object({ action: z.enum(['list', 'list_tools', 'stats', 'drop', 'drop_tools', 'compact', 'clear', 'export', 'import']), role: z.enum(['user', 'assistant']).optional().describe('Filter for list action'), turns: z.string().optional().describe('Comma-separated turn indices (e.g. "0,2,5") for drop/compact'), start: z.number().int().optional().describe('Range start (inclusive) for drop/compact'), end: z.number().int().optional().describe('Range end (exclusive) for drop/compact'), tool_ids: z.string().optional().describe('Comma-separated toolUseIds for drop_tools'), tool_name: z.string().optional().describe('Drop all calls to this tool name (comma-separated for multiple)'), limit: z.number().int().optional().describe('Max items for list (must be > 0, default 50)'), summary_len: z.number().int().optional().describe('Preview length for list (default 120)'), json: z.string().optional().describe('JSON-stringified message array for import'), thread_key: z.string().optional().describe('Override IDB key (default: active thread)'), }), callback: async (input) => { try { const key = input.thread_key || (await getActiveKey()) const msgs = await loadMessages(key) const total = msgs.length if (input.action === 'list') { if (input.limit !== undefined && input.limit <= 0) { return JSON.stringify({ status: 'error', error: `limit must be > 0 (got ${input.limit})` }) } const maxLen = input.summary_len ?? 120 const limit = input.limit ?? 50 let rows = msgs.map((m, i) => ({ i, role: m.role, preview: previewText(m, maxLen) })) if (input.role) rows = rows.filter(r => r.role === input.role) rows = rows.slice(-limit) return JSON.stringify({ status: 'success', key, total, shown: rows.length, messages: rows }) } if (input.action === 'list_tools') { if (input.limit !== undefined && input.limit <= 0) { return JSON.stringify({ status: 'error', error: `limit must be > 0 (got ${input.limit})` }) } const calls: any[] = [] msgs.forEach((m, i) => { const blocks = Array.isArray(m.content) ? m.content : [] for (const b of blocks) { if (b.toolUse) calls.push({ turn: i, role: m.role, kind: 'toolUse', name: b.toolUse.name, id: b.toolUse.toolUseId, input: b.toolUse.input }) if (b.toolResult) calls.push({ turn: i, role: m.role, kind: 'toolResult', id: b.toolResult.toolUseId, status: b.toolResult.status }) } }) return JSON.stringify({ status: 'success', key, count: calls.length, tools: calls.slice(-(input.limit ?? 100)) }) } if (input.action === 'stats') { let user = 0, asst = 0, tUse = 0, tRes = 0, chars = 0, imgs = 0, docs = 0 for (const m of msgs) { if (m.role === 'user') user++; else asst++ const blocks = Array.isArray(m.content) ? m.content : [] for (const b of blocks) { if (b.text) chars += String(b.text).length if (b.toolUse) tUse++ if (b.toolResult) tRes++ if (b.image) imgs++ if (b.document) docs++ } } const approxTokens = Math.ceil(chars / 4) return JSON.stringify({ status: 'success', key, total, user, assistant: asst, tool_uses: tUse, tool_results: tRes, images: imgs, documents: docs, chars, approx_tokens: approxTokens, }) } if (input.action === 'drop') { const { set: drop, error } = parseTurnIndices(input.turns, input.start, input.end, total) if (error) return JSON.stringify({ status: 'error', error }) if (!drop.size) return JSON.stringify({ status: 'error', error: 'No valid turns to drop (specify turns, or start+end)' }) let next = msgs.filter((_, i) => !drop.has(i)) const beforePrune = next.length next = pruneOrphanTools(next) const orphans_pruned = beforePrune - next.length await saveMessages(key, next) return JSON.stringify({ status: 'success', dropped: drop.size, orphans_pruned, before: total, after: next.length, }) } if (input.action === 'drop_tools') { const ids = new Set((input.tool_ids || '').split(',').map(s => s.trim()).filter(Boolean)) const names = new Set((input.tool_name || '').split(',').map(s => s.trim()).filter(Boolean)) if (!ids.size && !names.size) return JSON.stringify({ status: 'error', error: 'Provide tool_ids or tool_name' }) // First pass: identify toolUseIds to drop (by name OR explicit id match) const dropIds = new Set(ids) if (names.size) { for (const m of msgs) { const blocks = Array.isArray(m.content) ? m.content : [] for (const b of blocks) { if (b.toolUse && names.has(b.toolUse.name) && b.toolUse.toolUseId) { dropIds.add(b.toolUse.toolUseId) } } } } // Second pass: drop toolUse AND matching toolResult by id let removed = 0 const next = msgs.map(m => { const blocks = Array.isArray(m.content) ? m.content : [] const kept = blocks.filter((b: any) => { if (b.toolUse?.toolUseId && dropIds.has(b.toolUse.toolUseId)) { removed++; return false } if (b.toolResult?.toolUseId && dropIds.has(b.toolResult.toolUseId)) { removed++; return false } return true }) return { ...m, content: kept } }) // Only now prune orphans (shouldn't exist since we matched pairs by id) const pruned = pruneOrphanTools(next) await saveMessages(key, pruned) return JSON.stringify({ status: 'success', removed, dropped_ids: [...dropIds], before: total, after: pruned.length, }) } if (input.action === 'compact') { const { set: drop, error } = parseTurnIndices(input.turns, input.start, input.end, total) if (error) return JSON.stringify({ status: 'error', error }) // If no turns/range specified → auto-compact all but last 3 turns const applyAll = drop.size === 0 const keepLast = 3 let touched = 0 let blocks_removed = 0 const next = msgs.map((m, i) => { const shouldCompact = applyAll ? i < total - keepLast : drop.has(i) if (!shouldCompact) return m const { msg, removed } = compactMessage(m) if (removed > 0) { touched++ blocks_removed += removed } return msg }) await saveMessages(key, next) return JSON.stringify({ status: 'success', requested: applyAll ? Math.max(0, total - keepLast) : drop.size, touched, // messages that actually had tool blocks stripped blocks_removed, // number of toolUse/toolResult blocks removed total: next.length, note: touched === 0 ? 'no tool/image/doc blocks to strip in selected range (text-only compact is a no-op)' : undefined, }) } if (input.action === 'clear') { await del(key) window.dispatchEvent(new CustomEvent('careless:messages-changed', { detail: { key, count: 0 } })) return JSON.stringify({ status: 'success', cleared: total, key }) } if (input.action === 'export') { return JSON.stringify({ status: 'success', key, count: total, messages: msgs }) } if (input.action === 'import') { if (!input.json) return JSON.stringify({ status: 'error', error: 'json parameter required' }) let parsed: any try { parsed = JSON.parse(input.json) } catch (e: unknown) { return JSON.stringify({ status: 'error', error: `Parse: ${(e as Error).message}` }) } const arr = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.messages) ? parsed.messages : null) if (!arr) return JSON.stringify({ status: 'error', error: 'json must be an array or {messages: []}' }) // Validate each entry has role + content for (let i = 0; i < arr.length; i++) { const m = arr[i] if (!m || typeof m !== 'object') return JSON.stringify({ status: 'error', error: `message ${i}: not an object` }) if (!m.role) return JSON.stringify({ status: 'error', error: `message ${i}: missing role` }) if (m.content === undefined) return JSON.stringify({ status: 'error', error: `message ${i}: missing content` }) } await saveMessages(key, arr) return JSON.stringify({ status: 'success', imported: arr.length, replaced: total, key }) } return JSON.stringify({ status: 'error', error: 'unknown action' }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) export const MANAGE_MESSAGES_TOOLS = [manageMessagesTool]