diff options
| author | Ben Sima <ben@bensima.com> | 2025-12-13 22:01:49 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-12-13 22:01:49 -0500 |
| commit | 23edd144ed952802f9ea0fd1103a1e83db916b89 (patch) | |
| tree | 9f36979a654d5be69570ecbda2c79801968d786b | |
| parent | fe5e8064a4f7311c8e3fe6eb4d9e95d16e1d0250 (diff) | |
Add hledger tools to Telegram bot
- New Omni/Agent/Tools/Hledger.hs with 5 tools:
- hledger_balance: query account balances
- hledger_register: show transaction history
- hledger_add: create new transactions
- hledger_income_statement: income vs expenses
- hledger_balance_sheet: net worth view
- All tools support currency parameter (default: USD)
- Balance, register, income_statement support period parameter
- Period uses hledger syntax (thismonth, 2024, from X to Y)
- Shell escaping fixed for multi-word period strings
- Authorization: only Ben and Kate get hledger tools
- Max iterations increased from 5 to 10
- Transactions written to ~/fund/telegram-transactions.journal
| -rw-r--r-- | Omni/Agent/Telegram.hs | 30 | ||||
| -rw-r--r-- | Omni/Agent/Tools/Hledger.hs | 489 |
2 files changed, 517 insertions, 2 deletions
diff --git a/Omni/Agent/Telegram.hs b/Omni/Agent/Telegram.hs index 148bb6a..977e590 100644 --- a/Omni/Agent/Telegram.hs +++ b/Omni/Agent/Telegram.hs @@ -86,6 +86,7 @@ import qualified Omni.Agent.Telegram.Messages as Messages import qualified Omni.Agent.Telegram.Reminders as Reminders import qualified Omni.Agent.Telegram.Types as Types import qualified Omni.Agent.Tools.Calendar as Calendar +import qualified Omni.Agent.Tools.Hledger as Hledger import qualified Omni.Agent.Tools.Notes as Notes import qualified Omni.Agent.Tools.Pdf as Pdf import qualified Omni.Agent.Tools.Todos as Todos @@ -827,11 +828,27 @@ processEngagedMessage tgConfig provider engineCfg msg uid userName chatId userMe if Types.isGroupChat msg then "\n\n## Chat Type\nThis is a GROUP CHAT. Apply the group response rules - only respond if appropriate." else "\n\n## Chat Type\nThis is a PRIVATE CHAT. Always respond to the user." + hledgerContext = + if isHledgerAuthorized userName + then + Text.unlines + [ "", + "## hledger (personal finance)", + "", + "you have access to hledger tools for querying and recording financial transactions.", + "account naming: ex (expenses), as (assets), li (liabilities), in (income), eq (equity).", + "level 2 is owner: 'me' (personal) or 'us' (shared/family).", + "level 3 is type: need (necessary), want (discretionary), cash, cred (credit), vest (investments).", + "examples: ex:me:want:grooming, as:us:cash:checking, li:us:cred:chase.", + "when user says 'i spent $X at Y', use hledger_add with appropriate accounts." + ] + else "" systemPrompt = telegramSystemPrompt <> "\n\n## Current Date and Time\n" <> timeStr <> chatContext + <> hledgerContext <> "\n\n## Current User\n" <> "You are talking to: " <> userName @@ -872,13 +889,17 @@ processEngagedMessage tgConfig provider engineCfg msg uid userName chatId userMe Messages.listPendingMessagesTool uid chatId, Messages.cancelMessageTool ] - tools = memoryTools <> searchTools <> webReaderTools <> pdfTools <> notesTools <> calendarTools <> todoTools <> messageTools + hledgerTools = + if isHledgerAuthorized userName + then Hledger.allHledgerTools + else [] + tools = memoryTools <> searchTools <> webReaderTools <> pdfTools <> notesTools <> calendarTools <> todoTools <> messageTools <> hledgerTools let agentCfg = Engine.defaultAgentConfig { Engine.agentSystemPrompt = systemPrompt, Engine.agentTools = tools, - Engine.agentMaxIterations = 5, + Engine.agentMaxIterations = 10, Engine.agentGuardrails = Engine.defaultGuardrails { Engine.guardrailMaxCostCents = 10.0, @@ -930,6 +951,11 @@ maxConversationTokens = 4000 summarizationThreshold :: Int summarizationThreshold = 3000 +isHledgerAuthorized :: Text -> Bool +isHledgerAuthorized userName = + let lowerName = Text.toLower userName + in "ben" `Text.isInfixOf` lowerName || "kate" `Text.isInfixOf` lowerName + checkAndSummarize :: Text -> Text -> Int -> IO () checkAndSummarize openRouterKey uid chatId = do (_, currentTokens) <- Memory.getConversationContext uid chatId maxConversationTokens diff --git a/Omni/Agent/Tools/Hledger.hs b/Omni/Agent/Tools/Hledger.hs new file mode 100644 index 0000000..59e0c05 --- /dev/null +++ b/Omni/Agent/Tools/Hledger.hs @@ -0,0 +1,489 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE NoImplicitPrelude #-} + +-- | Hledger tools for personal finance queries and transaction entry. +-- +-- Provides hledger access for agents via the nix-shell in ~/fund. +-- +-- : out omni-agent-tools-hledger +-- : dep aeson +-- : dep process +-- : dep directory +module Omni.Agent.Tools.Hledger + ( -- * Tools + hledgerBalanceTool, + hledgerRegisterTool, + hledgerAddTool, + hledgerIncomeStatementTool, + hledgerBalanceSheetTool, + + -- * All tools (for easy import) + allHledgerTools, + + -- * Direct API + queryBalance, + queryRegister, + addTransaction, + incomeStatement, + balanceSheet, + + -- * Testing + main, + test, + ) +where + +import Alpha +import Data.Aeson ((.:), (.:?), (.=)) +import qualified Data.Aeson as Aeson +import qualified Data.List as List +import qualified Data.Text as Text +import qualified Data.Text.IO as TextIO +import Data.Time (getCurrentTime, utcToLocalTime) +import Data.Time.Format (defaultTimeLocale, formatTime) +import Data.Time.LocalTime (getCurrentTimeZone) +import qualified Omni.Agent.Engine as Engine +import qualified Omni.Test as Test +import System.Directory (doesFileExist) +import System.Process (readProcessWithExitCode) + +main :: IO () +main = Test.run test + +test :: Test.Tree +test = + Test.group + "Omni.Agent.Tools.Hledger" + [ Test.unit "hledgerBalanceTool has correct name" <| do + Engine.toolName hledgerBalanceTool Test.@=? "hledger_balance", + Test.unit "hledgerRegisterTool has correct name" <| do + Engine.toolName hledgerRegisterTool Test.@=? "hledger_register", + Test.unit "hledgerAddTool has correct name" <| do + Engine.toolName hledgerAddTool Test.@=? "hledger_add", + Test.unit "hledgerIncomeStatementTool has correct name" <| do + Engine.toolName hledgerIncomeStatementTool Test.@=? "hledger_income_statement", + Test.unit "hledgerBalanceSheetTool has correct name" <| do + Engine.toolName hledgerBalanceSheetTool Test.@=? "hledger_balance_sheet", + Test.unit "allHledgerTools has 5 tools" <| do + length allHledgerTools Test.@=? 5 + ] + +fundDir :: FilePath +fundDir = "/home/ben/fund" + +journalFile :: FilePath +journalFile = fundDir <> "/ledger.journal" + +transactionsFile :: FilePath +transactionsFile = fundDir <> "/telegram-transactions.journal" + +runHledgerInFund :: [String] -> IO (Either Text Text) +runHledgerInFund args = do + let fullArgs :: [String] + fullArgs = ["-f", journalFile] <> args + hledgerCmd :: String + hledgerCmd = "hledger " ++ List.unwords fullArgs + cmd :: String + cmd = "cd " ++ fundDir ++ " && " ++ hledgerCmd + result <- + try <| readProcessWithExitCode "nix-shell" [fundDir ++ "/shell.nix", "--run", cmd] "" + case result of + Left (e :: SomeException) -> + pure (Left ("hledger error: " <> tshow e)) + Right (exitCode, stdoutStr, stderrStr) -> + case exitCode of + ExitSuccess -> pure (Right (Text.pack stdoutStr)) + ExitFailure code -> + pure (Left ("hledger failed (" <> tshow code <> "): " <> Text.pack stderrStr)) + +allHledgerTools :: [Engine.Tool] +allHledgerTools = + [ hledgerBalanceTool, + hledgerRegisterTool, + hledgerAddTool, + hledgerIncomeStatementTool, + hledgerBalanceSheetTool + ] + +queryBalance :: Maybe Text -> Maybe Text -> Maybe Text -> IO (Either Text Text) +queryBalance maybePattern maybePeriod maybeCurrency = do + let patternArg = maybe [] (\p -> [Text.unpack p]) maybePattern + periodArg = maybe [] (\p -> ["-p", "'" ++ Text.unpack p ++ "'"]) maybePeriod + currency = maybe "USD" Text.unpack maybeCurrency + currencyArg = ["-X", currency] + runHledgerInFund (["bal", "-1", "--flat"] <> currencyArg <> patternArg <> periodArg) + +queryRegister :: Text -> Maybe Int -> Maybe Text -> Maybe Text -> IO (Either Text Text) +queryRegister accountPattern maybeLimit maybeCurrency maybePeriod = do + let limitArg = maybe ["-n", "10"] (\n -> ["-n", show n]) maybeLimit + currency = maybe "USD" Text.unpack maybeCurrency + currencyArg = ["-X", currency] + periodArg = maybe [] (\p -> ["-p", "'" ++ Text.unpack p ++ "'"]) maybePeriod + runHledgerInFund (["reg", Text.unpack accountPattern] <> currencyArg <> periodArg <> limitArg) + +incomeStatement :: Maybe Text -> Maybe Text -> IO (Either Text Text) +incomeStatement maybePeriod maybeCurrency = do + let periodArg = maybe ["-p", "thismonth"] (\p -> ["-p", "'" ++ Text.unpack p ++ "'"]) maybePeriod + currency = maybe "USD" Text.unpack maybeCurrency + currencyArg = ["-X", currency] + runHledgerInFund (["is"] <> currencyArg <> periodArg) + +balanceSheet :: Maybe Text -> IO (Either Text Text) +balanceSheet maybeCurrency = do + let currency = maybe "USD" Text.unpack maybeCurrency + currencyArg = ["-X", currency] + runHledgerInFund (["bs"] <> currencyArg) + +addTransaction :: Text -> Text -> Text -> Text -> Maybe Text -> IO (Either Text Text) +addTransaction description fromAccount toAccount amount maybeDate = do + now <- getCurrentTime + tz <- getCurrentTimeZone + let localTime = utcToLocalTime tz now + todayStr = formatTime defaultTimeLocale "%Y-%m-%d" localTime + dateStr = maybe todayStr Text.unpack maybeDate + transaction = + Text.unlines + [ "", + Text.pack dateStr <> " " <> description, + " " <> toAccount <> " " <> amount, + " " <> fromAccount + ] + exists <- doesFileExist transactionsFile + unless exists <| do + TextIO.writeFile transactionsFile "; Transactions added via Telegram bot\n" + TextIO.appendFile transactionsFile transaction + pure (Right ("Transaction added:\n" <> transaction)) + +hledgerBalanceTool :: Engine.Tool +hledgerBalanceTool = + Engine.Tool + { Engine.toolName = "hledger_balance", + Engine.toolDescription = + "Query account balances from hledger. " + <> "Account patterns: 'as' (assets), 'li' (liabilities), 'ex' (expenses), 'in' (income), 'eq' (equity). " + <> "Can drill down like 'as:me:cash' or 'ex:us:need'. " + <> "Currency defaults to USD but can be changed (e.g., 'BTC', 'ETH'). " + <> "Period uses hledger syntax: 'thismonth', 'lastmonth', 'thisyear', '2024', '2024-06', " + <> "'from 2024-01-01 to 2024-06-30', 'from 2024-06-01'.", + Engine.toolJsonSchema = + Aeson.object + [ "type" .= ("object" :: Text), + "properties" + .= Aeson.object + [ "account_pattern" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("Account pattern to filter (e.g., 'as:me:cash', 'ex', 'li:us:cred')" :: Text) + ], + "period" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("hledger period: 'thismonth', 'lastmonth', '2024', '2024-06', 'from 2024-01-01 to 2024-06-30'" :: Text) + ], + "currency" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("Currency to display values in (default: 'USD'). Examples: 'BTC', 'ETH', 'EUR'" :: Text) + ] + ], + "required" .= ([] :: [Text]) + ], + Engine.toolExecute = executeBalance + } + +executeBalance :: Aeson.Value -> IO Aeson.Value +executeBalance v = + case Aeson.fromJSON v of + Aeson.Error e -> pure (Aeson.object ["error" .= Text.pack e]) + Aeson.Success (args :: BalanceArgs) -> do + result <- queryBalance (baPattern args) (baPeriod args) (baCurrency args) + case result of + Left err -> pure (Aeson.object ["error" .= err]) + Right output -> + pure + ( Aeson.object + [ "success" .= True, + "balances" .= output + ] + ) + +data BalanceArgs = BalanceArgs + { baPattern :: Maybe Text, + baPeriod :: Maybe Text, + baCurrency :: Maybe Text + } + deriving (Generic) + +instance Aeson.FromJSON BalanceArgs where + parseJSON = + Aeson.withObject "BalanceArgs" <| \v -> + (BalanceArgs </ (v .:? "account_pattern")) + <*> (v .:? "period") + <*> (v .:? "currency") + +hledgerRegisterTool :: Engine.Tool +hledgerRegisterTool = + Engine.Tool + { Engine.toolName = "hledger_register", + Engine.toolDescription = + "Show recent transactions for an account. " + <> "Useful for seeing transaction history and checking recent spending. " + <> "Currency defaults to USD.", + Engine.toolJsonSchema = + Aeson.object + [ "type" .= ("object" :: Text), + "properties" + .= Aeson.object + [ "account_pattern" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("Account pattern to show transactions for (e.g., 'ex:us:need:grocery')" :: Text) + ], + "limit" + .= Aeson.object + [ "type" .= ("integer" :: Text), + "description" .= ("Max transactions to show (default: 10)" :: Text) + ], + "currency" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("Currency to display values in (default: 'USD')" :: Text) + ], + "period" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("hledger period: 'thismonth', 'lastmonth', '2024', '2024-06', 'from 2024-06-01 to 2024-12-31'" :: Text) + ] + ], + "required" .= (["account_pattern"] :: [Text]) + ], + Engine.toolExecute = executeRegister + } + +executeRegister :: Aeson.Value -> IO Aeson.Value +executeRegister v = + case Aeson.fromJSON v of + Aeson.Error e -> pure (Aeson.object ["error" .= Text.pack e]) + Aeson.Success (args :: RegisterArgs) -> do + result <- queryRegister (raPattern args) (raLimit args) (raCurrency args) (raPeriod args) + case result of + Left err -> pure (Aeson.object ["error" .= err]) + Right output -> + pure + ( Aeson.object + [ "success" .= True, + "transactions" .= output + ] + ) + +data RegisterArgs = RegisterArgs + { raPattern :: Text, + raLimit :: Maybe Int, + raCurrency :: Maybe Text, + raPeriod :: Maybe Text + } + deriving (Generic) + +instance Aeson.FromJSON RegisterArgs where + parseJSON = + Aeson.withObject "RegisterArgs" <| \v -> + (RegisterArgs </ (v .: "account_pattern")) + <*> (v .:? "limit") + <*> (v .:? "currency") + <*> (v .:? "period") + +hledgerAddTool :: Engine.Tool +hledgerAddTool = + Engine.Tool + { Engine.toolName = "hledger_add", + Engine.toolDescription = + "Add a new transaction to the ledger. " + <> "Use for recording expenses like 'I spent $30 at the barber'. " + <> "Account naming: ex:me:want (personal discretionary), ex:us:need (shared necessities), " + <> "as:me:cash:checking (bank account), li:us:cred:chase (credit card). " + <> "Common expense accounts: ex:us:need:grocery, ex:us:need:utilities, ex:me:want:dining, ex:me:want:grooming.", + Engine.toolJsonSchema = + Aeson.object + [ "type" .= ("object" :: Text), + "properties" + .= Aeson.object + [ "description" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("Transaction description (e.g., 'Haircut at Joe's Barber')" :: Text) + ], + "from_account" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("Account paying (e.g., 'as:me:cash:checking', 'li:us:cred:chase')" :: Text) + ], + "to_account" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("Account receiving (e.g., 'ex:me:want:grooming')" :: Text) + ], + "amount" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("Amount with currency (e.g., '$30.00', '30 USD')" :: Text) + ], + "date" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("Transaction date YYYY-MM-DD (default: today)" :: Text) + ] + ], + "required" .= (["description", "from_account", "to_account", "amount"] :: [Text]) + ], + Engine.toolExecute = executeAdd + } + +executeAdd :: Aeson.Value -> IO Aeson.Value +executeAdd v = + case Aeson.fromJSON v of + Aeson.Error e -> pure (Aeson.object ["error" .= Text.pack e]) + Aeson.Success (args :: AddArgs) -> do + result <- + addTransaction + (aaDescription args) + (aaFromAccount args) + (aaToAccount args) + (aaAmount args) + (aaDate args) + case result of + Left err -> pure (Aeson.object ["error" .= err]) + Right msg -> + pure + ( Aeson.object + [ "success" .= True, + "message" .= msg + ] + ) + +data AddArgs = AddArgs + { aaDescription :: Text, + aaFromAccount :: Text, + aaToAccount :: Text, + aaAmount :: Text, + aaDate :: Maybe Text + } + deriving (Generic) + +instance Aeson.FromJSON AddArgs where + parseJSON = + Aeson.withObject "AddArgs" <| \v -> + (AddArgs </ (v .: "description")) + <*> (v .: "from_account") + <*> (v .: "to_account") + <*> (v .: "amount") + <*> (v .:? "date") + +hledgerIncomeStatementTool :: Engine.Tool +hledgerIncomeStatementTool = + Engine.Tool + { Engine.toolName = "hledger_income_statement", + Engine.toolDescription = + "Show income statement (income vs expenses) for a period. " + <> "Good for seeing 'how much did I spend this month' or 'what's my net income'. " + <> "Currency defaults to USD. " + <> "Period uses hledger syntax: 'thismonth', 'lastmonth', '2024', 'from 2024-01-01 to 2024-06-30'.", + Engine.toolJsonSchema = + Aeson.object + [ "type" .= ("object" :: Text), + "properties" + .= Aeson.object + [ "period" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("hledger period (default: 'thismonth'): 'lastmonth', '2024', '2024-06', 'from 2024-01-01 to 2024-06-30'" :: Text) + ], + "currency" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("Currency to display values in (default: 'USD')" :: Text) + ] + ], + "required" .= ([] :: [Text]) + ], + Engine.toolExecute = executeIncomeStatement + } + +executeIncomeStatement :: Aeson.Value -> IO Aeson.Value +executeIncomeStatement v = + case Aeson.fromJSON v of + Aeson.Error e -> pure (Aeson.object ["error" .= Text.pack e]) + Aeson.Success (args :: IncomeStatementArgs) -> do + result <- incomeStatement (isaPeriod args) (isaCurrency args) + case result of + Left err -> pure (Aeson.object ["error" .= err]) + Right output -> + pure + ( Aeson.object + [ "success" .= True, + "income_statement" .= output + ] + ) + +data IncomeStatementArgs = IncomeStatementArgs + { isaPeriod :: Maybe Text, + isaCurrency :: Maybe Text + } + deriving (Generic) + +instance Aeson.FromJSON IncomeStatementArgs where + parseJSON = + Aeson.withObject "IncomeStatementArgs" <| \v -> + (IncomeStatementArgs </ (v .:? "period")) + <*> (v .:? "currency") + +hledgerBalanceSheetTool :: Engine.Tool +hledgerBalanceSheetTool = + Engine.Tool + { Engine.toolName = "hledger_balance_sheet", + Engine.toolDescription = + "Show current balance sheet (assets, liabilities, net worth). " + <> "Good for seeing 'what's my net worth' or 'how much do I have'. " + <> "Currency defaults to USD.", + Engine.toolJsonSchema = + Aeson.object + [ "type" .= ("object" :: Text), + "properties" + .= Aeson.object + [ "currency" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("Currency to display values in (default: 'USD')" :: Text) + ] + ], + "required" .= ([] :: [Text]) + ], + Engine.toolExecute = executeBalanceSheet + } + +executeBalanceSheet :: Aeson.Value -> IO Aeson.Value +executeBalanceSheet v = + case Aeson.fromJSON v of + Aeson.Error e -> pure (Aeson.object ["error" .= Text.pack e]) + Aeson.Success (args :: BalanceSheetArgs) -> do + result <- balanceSheet (bsCurrency args) + case result of + Left err -> pure (Aeson.object ["error" .= err]) + Right output -> + pure + ( Aeson.object + [ "success" .= True, + "balance_sheet" .= output + ] + ) + +newtype BalanceSheetArgs = BalanceSheetArgs + { bsCurrency :: Maybe Text + } + deriving (Generic) + +instance Aeson.FromJSON BalanceSheetArgs where + parseJSON = + Aeson.withObject "BalanceSheetArgs" <| \v -> + BalanceSheetArgs </ (v .:? "currency") |
