import Anthropic from "@anthropic-ai/sdk" import * as actualApi from "@actual-app/api" import * as p from "@clack/prompts" import { loadConfig } from "../config" import { initActual } from "../actual" import type { Config } from "../config" const api = actualApi as any export async function aiCommand(args: string[]) { const config = loadConfig() if (!config.anthropicApiKey) { console.error("Error: No Anthropic API key configured. Run `sb1-actual init` to add one.") process.exit(1) } const subcommand = args[0] if (!subcommand) { printHelp() process.exit(1) } const client = new Anthropic({ apiKey: config.anthropicApiKey }) p.intro(`sb1-actual ai ${subcommand}`) await initActual(config.actual) switch (subcommand) { case "consolidate-payees": await consolidatePayees(client) break case "summarize": await summarize(client, config, args.slice(1)) break case "insights": await insights(client, config, args.slice(1)) break case "add-notes": await addNotes(client, config, args.slice(1)) break case "query": await query(client, config, args.slice(1)) break default: console.error(`Unknown subcommand: ${subcommand}`) printHelp() process.exit(1) } } function printHelp() { console.log("Usage: sb1-actual ai [options]") console.log("") console.log("Subcommands:") console.log(" query [--since=YYYY-MM-DD] [question] Ask a free-form question about your data") console.log(" consolidate-payees Identify and merge duplicate/similar payees") console.log(" summarize [--since=YYYY-MM-DD] Summarize spending by category and payee") console.log(" insights [--since=YYYY-MM-DD] Get AI insights into spending patterns") console.log(" add-notes [--since=YYYY-MM-DD] Add descriptive notes to transactions") console.log(" add-notes --dry-run Preview notes without applying them") } function extractJson(text: string): any { // Strip markdown code fences: ```json ... ``` or ``` ... ``` const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/) const raw = fenced ? fenced[1] : text // Find the first [...] or {...} block const match = raw.match(/(\[[\s\S]*\]|\{[\s\S]*\})/) if (!match) throw new Error("No JSON found in response") return JSON.parse(match[1]) } function defaultSince(): string { const d = new Date() d.setDate(d.getDate() - 90) return d.toISOString().split("T")[0] } async function loadTransactions(config: Config, since?: string) { const startDate = since ?? defaultSince() const allTxns: any[] = [] for (const mapping of config.mappings) { const txns = await api.getTransactions(mapping.actualId, startDate, undefined) allTxns.push(...txns) } return allTxns } async function resolveNames(transactions: any[]) { const payees: Array<{ id: string; name: string }> = await api.getPayees() const categories: Array<{ id: string; name: string }> = await api.getCategories() const payeeMap = new Map(payees.map(p => [p.id, p.name])) const categoryMap = new Map(categories.map(c => [c.id, c.name])) return { payees, payeeMap, categoryMap, formatted: transactions .filter(t => !t.is_parent) .map(t => ({ id: t.id, date: t.date, amount: (t.amount / 100).toFixed(2), payee: payeeMap.get(t.payee) ?? t.imported_payee ?? "Unknown", category: categoryMap.get(t.category) ?? "Uncategorized", notes: t.notes ?? "", })), } } // ─── consolidate-payees ──────────────────────────────────────────────────── async function consolidatePayees(client: Anthropic) { const spinner = p.spinner() spinner.start("Loading payees...") const payees: Array<{ id: string; name: string; transfer_acct?: string }> = await api.getPayees() const realPayees = payees.filter(p => !p.transfer_acct && p.name?.trim()) spinner.stop(`Loaded ${realPayees.length} payees`) if (realPayees.length === 0) { p.outro("No payees found.") return } spinner.start("Analyzing payees with Claude...") const payeeList = realPayees.map(p => `${p.id}: ${p.name}`).join("\n") const response = await client.messages.create({ model: "claude-haiku-4-5", max_tokens: 4096, tools: [{ name: "submit_merge_groups", description: "Submit groups of payees that should be merged together", input_schema: { type: "object" as const, properties: { groups: { type: "array", items: { type: "object", properties: { keepId: { type: "string", description: "ID of the payee whose name to keep" }, keepName: { type: "string", description: "The canonical display name" }, mergeIds: { type: "array", items: { type: "string" }, description: "IDs to merge in" }, }, required: ["keepId", "keepName", "mergeIds"], }, description: "Merge groups. Empty array if no duplicates found.", }, }, required: ["groups"], }, }], tool_choice: { type: "tool", name: "submit_merge_groups" }, system: "You are a personal finance assistant. Identify payees that represent the same merchant (e.g. 'Rema 1000', 'REMA 1000 OSLO', 'REMA1000'). Only group clear duplicates/variants.", messages: [{ role: "user", content: `Payees:\n${payeeList}`, }] }) spinner.stop("Analysis complete") const toolUse = response.content.find(b => b.type === "tool_use") as any const groups: Array<{ keepId: string; keepName: string; mergeIds: string[] }> = toolUse?.input?.groups?.filter((g: any) => g.mergeIds?.length > 0) ?? [] if (groups.length === 0) { p.outro("No duplicate or similar payees found.") return } console.log(`\nFound ${groups.length} merge group(s):\n`) for (const group of groups) { const keep = realPayees.find(p => p.id === group.keepId) const merging = group.mergeIds.map(id => realPayees.find(p => p.id === id)?.name ?? id) console.log(` Keep: "${keep?.name ?? group.keepName}"`) console.log(` Merge: ${merging.map(n => `"${n}"`).join(", ")}`) console.log() } const confirm = await p.confirm({ message: "Apply these merges?" }) if (p.isCancel(confirm) || !confirm) { p.cancel("Cancelled.") return } const applySpinner = p.spinner() applySpinner.start("Applying merges...") let mergedCount = 0 for (const group of groups) { await api.mergePayees(group.keepId, group.mergeIds) mergedCount += group.mergeIds.length } await actualApi.sync() applySpinner.stop(`Merged ${mergedCount} payees into ${groups.length} groups`) p.outro("Done!") } // ─── summarize ──────────────────────────────────────────────────────────── async function summarize(client: Anthropic, config: Config, args: string[]) { const since = args.find(a => a.startsWith("--since="))?.split("=")[1] const spinner = p.spinner() spinner.start("Loading transactions...") const transactions = await loadTransactions(config, since) const { formatted } = await resolveNames(transactions) spinner.stop(`Loaded ${formatted.length} transactions`) if (formatted.length === 0) { p.outro("No transactions found.") return } const txnText = formatted .map(t => `${t.date} | ${Number(t.amount) >= 0 ? "+" : ""}${t.amount} NOK | ${t.payee} | ${t.category}${t.notes ? ` | ${t.notes}` : ""}`) .join("\n") console.log("") const stream = client.messages.stream({ model: "claude-haiku-4-5", max_tokens: 4096, system: "You are a personal finance assistant. Summarize transaction data clearly. Group by category and list top payees with totals. Use NOK currency. Use markdown formatting.", messages: [{ role: "user", content: `Summarize my spending${since ? ` since ${since}` : ` (last 90 days)`}.\n\nTransactions:\n${txnText}` }] }) for await (const event of stream) { if (event.type === "content_block_delta" && event.delta.type === "text_delta") { process.stdout.write(event.delta.text) } } console.log("\n") p.outro("Done!") } // ─── insights ───────────────────────────────────────────────────────────── async function insights(client: Anthropic, config: Config, args: string[]) { const since = args.find(a => a.startsWith("--since="))?.split("=")[1] const spinner = p.spinner() spinner.start("Loading transactions...") const transactions = await loadTransactions(config, since) const { formatted } = await resolveNames(transactions) spinner.stop(`Loaded ${formatted.length} transactions`) if (formatted.length === 0) { p.outro("No transactions found.") return } const txnText = formatted .map(t => `${t.date} | ${Number(t.amount) >= 0 ? "+" : ""}${t.amount} NOK | ${t.payee} | ${t.category}${t.notes ? ` | ${t.notes}` : ""}`) .join("\n") console.log("") const stream = client.messages.stream({ model: "claude-haiku-4-5", max_tokens: 8192, system: "You are a personal finance advisor. Analyze transactions and give actionable insights: spending patterns, trends, anomalies, potential savings, and notable observations. Use NOK currency. Use markdown. Be specific and helpful.", messages: [{ role: "user", content: `Analyze my spending${since ? ` since ${since}` : ` (last 90 days)`} and give me insights.\n\nTransactions:\n${txnText}` }] }) for await (const event of stream) { if (event.type === "content_block_delta" && event.delta.type === "text_delta") { process.stdout.write(event.delta.text) } } console.log("\n") p.outro("Done!") } // ─── add-notes ──────────────────────────────────────────────────────────── async function addNotes(client: Anthropic, config: Config, args: string[]) { const since = args.find(a => a.startsWith("--since="))?.split("=")[1] const dryRun = args.includes("--dry-run") const spinner = p.spinner() spinner.start("Loading transactions...") const transactions = await loadTransactions(config, since) const { payeeMap } = await resolveNames(transactions) const withoutNotes = transactions .filter(t => !t.is_parent && (!t.notes || !t.notes.trim())) .slice(0, 50) spinner.stop(`Found ${withoutNotes.length} transactions without notes (processing up to 50)`) if (withoutNotes.length === 0) { p.outro("All transactions already have notes!") return } const toProcess = withoutNotes.map(t => ({ id: t.id, date: t.date, amount: (t.amount / 100).toFixed(2), payee: payeeMap.get(t.payee) ?? t.imported_payee ?? "Unknown", })) spinner.start("Generating notes with Claude...") const response = await client.messages.create({ model: "claude-haiku-4-5", max_tokens: 4096, system: `You are a personal finance assistant. For each transaction, suggest a brief helpful note (max 60 chars) adding context not obvious from the payee name alone. If the transaction is already self-explanatory, return null for the note. Return ONLY a JSON array: [{"id": "...", "note": "..." | null}]`, messages: [{ role: "user", content: `Add helpful notes to these transactions:\n${JSON.stringify(toProcess, null, 2)}` }] }) spinner.stop("Notes generated") const text = response.content.find(b => b.type === "text") as any const rawText = text?.text ?? "" let suggestions: Array<{ id: string; note: string | null }> = [] try { suggestions = extractJson(rawText) } catch { console.error("Failed to parse Claude's response:") console.log(rawText) return } const toUpdate = suggestions.filter(s => s.note !== null && s.note?.trim()) if (toUpdate.length === 0) { p.outro("No notes to add — all transactions are self-explanatory.") return } console.log(`\nWill add notes to ${toUpdate.length} transaction(s):`) const preview = toUpdate.slice(0, 10) for (const s of preview) { const t = toProcess.find(f => f.id === s.id) console.log(` ${t?.date} ${t?.payee}: "${s.note}"`) } if (toUpdate.length > 10) console.log(` ... and ${toUpdate.length - 10} more`) if (dryRun) { console.log("\nDry run — no changes applied.") p.outro("Done!") return } const confirm = await p.confirm({ message: `Apply ${toUpdate.length} note(s)?` }) if (p.isCancel(confirm) || !confirm) { p.cancel("Cancelled.") return } const applySpinner = p.spinner() applySpinner.start("Applying notes...") for (const s of toUpdate) { await api.updateTransaction(s.id, { notes: s.note }) } await actualApi.sync() applySpinner.stop(`Added notes to ${toUpdate.length} transactions`) p.outro("Done!") } // ─── query ──────────────────────────────────────────────────────────────── async function query(client: Anthropic, config: Config, args: string[]) { const since = args.find(a => a.startsWith("--since="))?.split("=")[1] const inlineQuestion = args.filter(a => !a.startsWith("--")).join(" ").trim() const question = inlineQuestion || (await p.text({ message: "What do you want to know about your transactions?", placeholder: "e.g. How much did I spend on groceries last month?", validate: v => v?.trim() ? undefined : "Please enter a question", }) as string) if (p.isCancel(question)) { p.cancel("Cancelled.") return } const spinner = p.spinner() spinner.start("Loading transactions...") const transactions = await loadTransactions(config, since) const { formatted } = await resolveNames(transactions) spinner.stop(`Loaded ${formatted.length} transactions`) if (formatted.length === 0) { p.outro("No transactions found.") return } const txnText = formatted .map(t => `${t.date} | ${Number(t.amount) >= 0 ? "+" : ""}${t.amount} NOK | ${t.payee} | ${t.category}${t.notes ? ` | ${t.notes}` : ""}`) .join("\n") console.log("") const stream = client.messages.stream({ model: "claude-haiku-4-5", max_tokens: 4096, system: "You are a personal finance assistant with access to the user's transaction data. Answer questions clearly and concisely. Use NOK currency. Use markdown where helpful.", messages: [{ role: "user", content: `My transactions${since ? ` since ${since}` : ` (last 90 days)`}:\n\n${txnText}\n\n${question}`, }] }) for await (const event of stream) { if (event.type === "content_block_delta" && event.delta.type === "text_delta") { process.stdout.write(event.delta.text) } } console.log("\n") p.outro("Done!") }