summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Omni/Agent/Engine.hs26
-rw-r--r--Omni/Agent/Memory.hs15
-rw-r--r--Omni/Agent/Subagent.hs3
-rw-r--r--Omni/Agent/Subagent/Coder.hs3
-rw-r--r--Omni/Agent/Telegram.hs54
-rwxr-xr-xOmni/Ava.hs11
-rw-r--r--Omni/Ava/Trace.hs148
-rw-r--r--Omni/Ava/Web.hs84
-rw-r--r--Omni/Ava/Web/trace.html71
-rw-r--r--Omni/Bild.nix2
-rw-r--r--Omni/Bild/Deps/Haskell.nix1
-rw-r--r--Omni/Dev/Beryllium/Ava.nix9
-rwxr-xr-xOmni/Ide/hooks/pre-push4
13 files changed, 419 insertions, 12 deletions
diff --git a/Omni/Agent/Engine.hs b/Omni/Agent/Engine.hs
index f137ddb..0dc7c50 100644
--- a/Omni/Agent/Engine.hs
+++ b/Omni/Agent/Engine.hs
@@ -14,6 +14,7 @@
-- : dep http-conduit
-- : dep aeson
-- : dep case-insensitive
+-- : dep time
module Omni.Agent.Engine
( Tool (..),
LLM (..),
@@ -56,6 +57,7 @@ import Data.IORef (newIORef, writeIORef)
import qualified Data.Map.Strict as Map
import qualified Data.Text as Text
import qualified Data.Text.Encoding as TE
+import qualified Data.Time as Time
import qualified Network.HTTP.Simple as HTTP
import qualified Omni.Agent.Provider as Provider
import qualified Omni.Test as Test
@@ -378,7 +380,8 @@ data EngineConfig = EngineConfig
engineOnToolResult :: Text -> Bool -> Text -> IO (),
engineOnComplete :: IO (),
engineOnError :: Text -> IO (),
- engineOnGuardrail :: GuardrailResult -> IO ()
+ engineOnGuardrail :: GuardrailResult -> IO (),
+ engineOnToolTrace :: Text -> Text -> Text -> Int -> IO (Maybe Text)
}
defaultEngineConfig :: EngineConfig
@@ -392,7 +395,8 @@ defaultEngineConfig =
engineOnToolResult = \_ _ _ -> pure (),
engineOnComplete = pure (),
engineOnError = \_ -> pure (),
- engineOnGuardrail = \_ -> pure ()
+ engineOnGuardrail = \_ -> pure (),
+ engineOnToolTrace = \_ _ _ _ -> pure Nothing
}
data AgentResult = AgentResult
@@ -791,14 +795,18 @@ executeToolCallsWithTracking engineCfg toolMap tcs initialTestFailures initialEd
engineOnToolResult engineCfg name False errMsg
pure (Message ToolRole errMsg Nothing (Just callId), 0, 0)
Just args -> do
+ startTime <- Time.getCurrentTime
resultValue <- toolExecute tool args
- let resultText = TE.decodeUtf8 (BL.toStrict (Aeson.encode resultValue))
+ endTime <- Time.getCurrentTime
+ let durationMs = round (Time.diffUTCTime endTime startTime * 1000)
+ resultText = TE.decodeUtf8 (BL.toStrict (Aeson.encode resultValue))
isTestCall = name == "bash" && ("bild --test" `Text.isInfixOf` argsText || "bild -t" `Text.isInfixOf` argsText)
isTestFailure = isTestCall && isFailureResult resultValue
testDelta = if isTestFailure then 1 else 0
isEditFailure = name == "edit_file" && isOldStrNotFoundError resultValue
editDelta = if isEditFailure then 1 else 0
engineOnToolResult engineCfg name True resultText
+ _ <- engineOnToolTrace engineCfg name argsText resultText durationMs
pure (Message ToolRole resultText Nothing (Just callId), testDelta, editDelta)
isFailureResult :: Aeson.Value -> Bool
@@ -976,14 +984,18 @@ runAgentWithProvider engineCfg provider agentCfg userPrompt = do
engineOnToolResult eCfg name False errMsg
pure (Provider.Message Provider.ToolRole errMsg Nothing (Just callId), 0, 0)
Just args -> do
+ startTime <- Time.getCurrentTime
resultValue <- toolExecute tool args
- let resultText = TE.decodeUtf8 (BL.toStrict (Aeson.encode resultValue))
+ endTime <- Time.getCurrentTime
+ let durationMs = round (Time.diffUTCTime endTime startTime * 1000)
+ resultText = TE.decodeUtf8 (BL.toStrict (Aeson.encode resultValue))
isTestCall = name == "bash" && ("bild --test" `Text.isInfixOf` argsText || "bild -t" `Text.isInfixOf` argsText)
isTestFailure = isTestCall && isFailureResultProvider resultValue
testDelta = if isTestFailure then 1 else 0
isEditFailure = name == "edit_file" && isOldStrNotFoundProvider resultValue
editDelta = if isEditFailure then 1 else 0
engineOnToolResult eCfg name True resultText
+ _ <- engineOnToolTrace eCfg name argsText resultText durationMs
pure (Provider.Message Provider.ToolRole resultText Nothing (Just callId), testDelta, editDelta)
isFailureResultProvider :: Aeson.Value -> Bool
@@ -1157,14 +1169,18 @@ runAgentWithProviderStreaming engineCfg provider agentCfg userPrompt onStreamChu
engineOnToolResult eCfg name False errMsg
pure (Provider.Message Provider.ToolRole errMsg Nothing (Just callId), 0, 0)
Just args -> do
+ startTime <- Time.getCurrentTime
resultValue <- toolExecute tool args
- let resultText = TE.decodeUtf8 (BL.toStrict (Aeson.encode resultValue))
+ endTime <- Time.getCurrentTime
+ let durationMs = round (Time.diffUTCTime endTime startTime * 1000)
+ resultText = TE.decodeUtf8 (BL.toStrict (Aeson.encode resultValue))
isTestCall = name == "bash" && ("bild --test" `Text.isInfixOf` argsText || "bild -t" `Text.isInfixOf` argsText)
isTestFailure = isTestCall && isFailureResultStreaming resultValue
testDelta = if isTestFailure then 1 else 0
isEditFailure = name == "edit_file" && isOldStrNotFoundStreaming resultValue
editDelta = if isEditFailure then 1 else 0
engineOnToolResult eCfg name True resultText
+ _ <- engineOnToolTrace eCfg name argsText resultText durationMs
pure (Provider.Message Provider.ToolRole resultText Nothing (Just callId), testDelta, editDelta)
isFailureResultStreaming :: Aeson.Value -> Bool
diff --git a/Omni/Agent/Memory.hs b/Omni/Agent/Memory.hs
index d59104c..c869b26 100644
--- a/Omni/Agent/Memory.hs
+++ b/Omni/Agent/Memory.hs
@@ -795,6 +795,21 @@ initMemoryDb conn = do
SQL.execute_
conn
"CREATE INDEX IF NOT EXISTS idx_chat_history_time ON chat_history(created_at)"
+ SQL.execute_
+ conn
+ "CREATE TABLE IF NOT EXISTS tool_traces (\
+ \ id TEXT PRIMARY KEY,\
+ \ created_at TEXT NOT NULL,\
+ \ tool_name TEXT NOT NULL,\
+ \ input TEXT NOT NULL,\
+ \ output TEXT NOT NULL,\
+ \ duration_ms INTEGER NOT NULL,\
+ \ user_id TEXT,\
+ \ chat_id TEXT\
+ \)"
+ SQL.execute_
+ conn
+ "CREATE INDEX IF NOT EXISTS idx_traces_created ON tool_traces(created_at)"
-- | Migrate conversation_messages to add sender_name and thread_id columns.
migrateConversationMessages :: SQL.Connection -> IO ()
diff --git a/Omni/Agent/Subagent.hs b/Omni/Agent/Subagent.hs
index 9f3052d..cb8c090 100644
--- a/Omni/Agent/Subagent.hs
+++ b/Omni/Agent/Subagent.hs
@@ -830,7 +830,8 @@ runGenericSubagent keys config callbacks = do
Engine.engineOnToolResult = \_ _ _ -> pure (),
Engine.engineOnComplete = pure (),
Engine.engineOnError = \_ -> pure (),
- Engine.engineOnGuardrail = \_ -> pure ()
+ Engine.engineOnGuardrail = \_ -> pure (),
+ Engine.engineOnToolTrace = \_ _ _ _ -> pure Nothing
}
let timeoutMicros = subagentTimeout config * 1000000
diff --git a/Omni/Agent/Subagent/Coder.hs b/Omni/Agent/Subagent/Coder.hs
index 865a97e..ad97ee7 100644
--- a/Omni/Agent/Subagent/Coder.hs
+++ b/Omni/Agent/Subagent/Coder.hs
@@ -139,9 +139,10 @@ defaultCoderConfig namespace task =
}
-- | Run a bash command and capture output
+-- Uses direnv exec to ensure the nix shell environment is loaded
runBashCapture :: Text -> IO (Exit.ExitCode, Text, Text)
runBashCapture cmd = do
- (code, out, err) <- Process.readProcessWithExitCode "bash" ["-c", Text.unpack cmd] ""
+ (code, out, err) <- Process.readProcessWithExitCode "direnv" ["exec", ".", "bash", "-c", Text.unpack cmd] ""
pure (code, Text.pack out, Text.pack err)
-- | Phase 1: Initialize - check environment, detect broken state
diff --git a/Omni/Agent/Telegram.hs b/Omni/Agent/Telegram.hs
index 7b2beaa..7183592 100644
--- a/Omni/Agent/Telegram.hs
+++ b/Omni/Agent/Telegram.hs
@@ -76,6 +76,7 @@ import Data.Aeson ((.=))
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.KeyMap as KeyMap
import qualified Data.ByteString.Lazy as BL
+import Data.IORef (newIORef, readIORef, writeIORef)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as TE
import Data.Time (getCurrentTime, utcToLocalTime)
@@ -110,6 +111,7 @@ import qualified Omni.Agent.Tools.Python as Python
import qualified Omni.Agent.Tools.Todos as Todos
import qualified Omni.Agent.Tools.WebReader as WebReader
import qualified Omni.Agent.Tools.WebSearch as WebSearch
+import qualified Omni.Ava.Trace as Trace
import qualified Omni.Test as Test
import System.Environment (lookupEnv)
import Text.Printf (printf)
@@ -524,6 +526,14 @@ leaveChat cfg chatId = do
runTelegramBot :: Types.TelegramConfig -> Provider.Provider -> IO ()
runTelegramBot tgConfig provider = do
putText "Starting Telegram bot..."
+
+ cleanedCount <- Memory.withMemoryDb Trace.cleanupOldTraces
+ when (cleanedCount > 0)
+ <| putText
+ <| "Cleaned up "
+ <> tshow cleanedCount
+ <> " old tool traces"
+
offsetVar <- newTVarIO 0
botUsername <- getBotUsername tgConfig
@@ -551,7 +561,23 @@ runTelegramBot tgConfig provider = do
Engine.engineOnToolResult = \toolName success result ->
putText <| "Tool result: " <> toolName <> " " <> (if success then "ok" else "err") <> " " <> Text.take 200 result,
Engine.engineOnActivity = \activity ->
- putText <| "Agent: " <> activity
+ putText <| "Agent: " <> activity,
+ Engine.engineOnToolTrace = \toolName input output durationMs -> do
+ now <- getCurrentTime
+ let truncatedOutput = Text.take 1000000 output
+ traceRecord =
+ Trace.TraceRecord
+ { Trace.trcId = "",
+ Trace.trcCreatedAt = tshow now,
+ Trace.trcToolName = toolName,
+ Trace.trcInput = input,
+ Trace.trcOutput = truncatedOutput,
+ Trace.trcDurationMs = durationMs,
+ Trace.trcUserId = Nothing,
+ Trace.trcChatId = Nothing
+ }
+ tid <- Memory.withMemoryDb <| \conn -> Trace.insertTrace conn traceRecord
+ pure (Just tid)
}
let processBatch = handleMessageBatch tgConfig provider engineCfg botName
@@ -1061,6 +1087,17 @@ processEngagedMessage ::
processEngagedMessage tgConfig provider engineCfg msg uid userName chatId userMessage conversationContext = do
let isGroup = Types.isGroupChat msg
+ lastTraceIdRef <- newIORef (Nothing :: Maybe Text)
+ let engineCfgWithTrace =
+ engineCfg
+ { Engine.engineOnToolTrace = \toolName input output durationMs -> do
+ maybeTid <- Engine.engineOnToolTrace engineCfg toolName input output durationMs
+ case maybeTid of
+ Just tid -> writeIORef lastTraceIdRef (Just tid)
+ Nothing -> pure ()
+ pure maybeTid
+ }
+
personalMemories <- Memory.recallMemories uid userMessage 5
groupMemories <-
if isGroup
@@ -1245,7 +1282,11 @@ processEngagedMessage tgConfig provider engineCfg msg uid userName chatId userMe
result <-
withTypingIndicator tgConfig chatId
- <| Engine.runAgentWithProvider engineCfg provider agentCfg userMessage
+ <| Engine.runAgentWithProvider engineCfgWithTrace provider agentCfg userMessage
+
+ lastTraceId <- readIORef lastTraceIdRef
+ maybeWebUrl <- lookupEnv "AVA_WEB_URL"
+ let traceLink = formatTraceLink lastTraceId (Text.pack </ maybeWebUrl)
case result of
Left err -> do
@@ -1253,7 +1294,8 @@ processEngagedMessage tgConfig provider engineCfg msg uid userName chatId userMe
_ <- Messages.enqueueImmediate (Just uid) chatId (Types.tmThreadId msg) "sorry, i hit an error. please try again." (Just "agent_error") Nothing
pure ()
Right agentResult -> do
- let response = Engine.resultFinalMessage agentResult
+ let baseResponse = Engine.resultFinalMessage agentResult
+ response = baseResponse <> traceLink
threadId = Types.tmThreadId msg
putText <| "Response text: " <> Text.take 200 response
@@ -1367,6 +1409,12 @@ mergeTooShort (x : y : rest)
| Text.length x < 100 = mergeTooShort ((x <> "\n\n" <> y) : rest)
| otherwise = x : mergeTooShort (y : rest)
+formatTraceLink :: Maybe Text -> Maybe Text -> Text
+formatTraceLink Nothing _ = ""
+formatTraceLink _ Nothing = ""
+formatTraceLink (Just tid) (Just baseUrl) =
+ "\n\n[view trace](" <> baseUrl <> "/trace/" <> tid <> ")"
+
enqueueMultipart :: Maybe Text -> Int -> Maybe Int -> [Text] -> Maybe Text -> IO ()
enqueueMultipart _ _ _ [] _ = pure ()
enqueueMultipart mUid chatId mThreadId parts msgType = do
diff --git a/Omni/Ava.hs b/Omni/Ava.hs
index 3640fc2..6058425 100755
--- a/Omni/Ava.hs
+++ b/Omni/Ava.hs
@@ -14,22 +14,29 @@
-- : out ava
-- : dep aeson
-- : dep http-conduit
+-- : dep http-types
+-- : dep mustache
-- : dep stm
-- : dep time
-- : dep uuid
+-- : dep wai
+-- : dep warp
module Omni.Ava where
import Alpha
+import qualified Control.Concurrent as Concurrent
import qualified Data.Aeson as Aeson
import qualified Data.Text as Text
import qualified Data.Text.IO as TextIO
import qualified Data.Time as Time
import qualified Omni.Agent.AuditLog as AuditLog
import qualified Omni.Agent.Telegram as Telegram
+import qualified Omni.Ava.Web as Web
import qualified Omni.Cli as Cli
import qualified Omni.Test as Test
import qualified System.Console.Docopt as Docopt
import qualified System.Directory as Dir
+import qualified System.Environment as Environment
import qualified System.IO as IO
main :: IO ()
@@ -70,6 +77,10 @@ move args = do
if args `Cli.has` Cli.command "logs"
then showLogs args
else do
+ webPort <- Environment.lookupEnv "AVA_WEB_PORT" /> maybe Web.defaultPort (fromMaybe Web.defaultPort <. readMaybe)
+ dataRoot <- Environment.getEnv "AVA_DATA_ROOT"
+ let dbPath = dataRoot <> "/ava.db"
+ _ <- Concurrent.forkIO <| Web.startWebServer webPort dbPath
let maybeToken = fmap Text.pack (Cli.getArg args (Cli.longOption "token"))
Telegram.startBot maybeToken
diff --git a/Omni/Ava/Trace.hs b/Omni/Ava/Trace.hs
new file mode 100644
index 0000000..6dbdf51
--- /dev/null
+++ b/Omni/Ava/Trace.hs
@@ -0,0 +1,148 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Tool trace storage for Ava.
+--
+-- Records tool execution traces for debugging and analytics.
+--
+-- : out omni-ava-trace
+-- : dep aeson
+-- : dep sqlite-simple
+-- : dep uuid
+module Omni.Ava.Trace
+ ( TraceRecord (..),
+ insertTrace,
+ getTrace,
+ cleanupOldTraces,
+ main,
+ test,
+ )
+where
+
+import Alpha
+import Data.Aeson ((.=))
+import qualified Data.Aeson as Aeson
+import qualified Data.Text as Text
+import qualified Data.UUID as UUID
+import qualified Data.UUID.V4 as UUID
+import qualified Database.SQLite.Simple as SQL
+import qualified Database.SQLite.Simple.ToField as SQL
+import qualified Omni.Test as Test
+
+main :: IO ()
+main = Test.run test
+
+test :: Test.Tree
+test =
+ Test.group
+ "Omni.Ava.Trace"
+ [ Test.unit "TraceRecord JSON roundtrip" <| do
+ let tr =
+ TraceRecord
+ { trcId = "trace-123",
+ trcCreatedAt = "2024-01-15T10:30:00Z",
+ trcToolName = "web_search",
+ trcInput = "{\"query\":\"test\"}",
+ trcOutput = "{\"results\":[]}",
+ trcDurationMs = 150,
+ trcUserId = Just "user-456",
+ trcChatId = Just "chat-789"
+ }
+ case Aeson.decode (Aeson.encode tr) of
+ Nothing -> Test.assertFailure "Failed to decode TraceRecord"
+ Just decoded -> do
+ trcToolName decoded Test.@=? "web_search"
+ trcDurationMs decoded Test.@=? 150
+ ]
+
+data TraceRecord = TraceRecord
+ { trcId :: Text,
+ trcCreatedAt :: Text,
+ trcToolName :: Text,
+ trcInput :: Text,
+ trcOutput :: Text,
+ trcDurationMs :: Int,
+ trcUserId :: Maybe Text,
+ trcChatId :: Maybe Text
+ }
+ deriving (Show, Eq, Generic)
+
+instance Aeson.ToJSON TraceRecord where
+ toJSON tr =
+ Aeson.object
+ [ "id" .= trcId tr,
+ "created_at" .= trcCreatedAt tr,
+ "tool_name" .= trcToolName tr,
+ "input" .= trcInput tr,
+ "output" .= trcOutput tr,
+ "duration_ms" .= trcDurationMs tr,
+ "user_id" .= trcUserId tr,
+ "chat_id" .= trcChatId tr
+ ]
+
+instance Aeson.FromJSON TraceRecord where
+ parseJSON =
+ Aeson.withObject "TraceRecord" <| \v ->
+ (TraceRecord </ (v Aeson..: "id"))
+ <*> (v Aeson..: "created_at")
+ <*> (v Aeson..: "tool_name")
+ <*> (v Aeson..: "input")
+ <*> (v Aeson..: "output")
+ <*> (v Aeson..: "duration_ms")
+ <*> (v Aeson..:? "user_id")
+ <*> (v Aeson..:? "chat_id")
+
+instance SQL.FromRow TraceRecord where
+ fromRow =
+ (TraceRecord </ SQL.field)
+ <*> SQL.field
+ <*> SQL.field
+ <*> SQL.field
+ <*> SQL.field
+ <*> SQL.field
+ <*> SQL.field
+ <*> SQL.field
+
+instance SQL.ToRow TraceRecord where
+ toRow tr =
+ [ SQL.toField (trcId tr),
+ SQL.toField (trcCreatedAt tr),
+ SQL.toField (trcToolName tr),
+ SQL.toField (trcInput tr),
+ SQL.toField (trcOutput tr),
+ SQL.toField (trcDurationMs tr),
+ SQL.toField (trcUserId tr),
+ SQL.toField (trcChatId tr)
+ ]
+
+insertTrace :: SQL.Connection -> TraceRecord -> IO Text
+insertTrace conn tr = do
+ tid <-
+ if Text.null (trcId tr)
+ then (Text.pack <. UUID.toString) </ UUID.nextRandom
+ else pure (trcId tr)
+ let trWithId = tr {trcId = tid}
+ SQL.execute
+ conn
+ "INSERT INTO tool_traces (id, created_at, tool_name, input, output, duration_ms, user_id, chat_id) \
+ \VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
+ trWithId
+ pure tid
+
+getTrace :: SQL.Connection -> Text -> IO (Maybe TraceRecord)
+getTrace conn tid = do
+ results <-
+ SQL.query
+ conn
+ "SELECT id, created_at, tool_name, input, output, duration_ms, user_id, chat_id \
+ \FROM tool_traces WHERE id = ?"
+ (SQL.Only tid)
+ pure (listToMaybe results)
+
+cleanupOldTraces :: SQL.Connection -> IO Int
+cleanupOldTraces conn = do
+ SQL.execute_
+ conn
+ "DELETE FROM tool_traces WHERE created_at < datetime('now', '-7 days')"
+ SQL.changes conn
diff --git a/Omni/Ava/Web.hs b/Omni/Ava/Web.hs
new file mode 100644
index 0000000..86a8280
--- /dev/null
+++ b/Omni/Ava/Web.hs
@@ -0,0 +1,84 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Web server for Ava trace viewer.
+--
+-- Serves the trace viewer UI for debugging tool executions.
+--
+-- : out omni-ava-web
+-- : dep warp
+-- : dep wai
+-- : dep http-types
+-- : dep aeson
+-- : dep text
+-- : dep bytestring
+-- : dep sqlite-simple
+module Omni.Ava.Web
+ ( startWebServer,
+ app,
+ defaultPort,
+ )
+where
+
+import Alpha
+import qualified Data.Aeson as Aeson
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Encoding
+import qualified Data.Text.IO as TextIO
+import qualified Database.SQLite.Simple as SQL
+import qualified Network.HTTP.Types as HTTP
+import qualified Network.Wai as Wai
+import qualified Network.Wai.Handler.Warp as Warp
+import qualified Omni.Ava.Trace as Trace
+import qualified System.Environment as Environment
+
+defaultPort :: Int
+defaultPort = 8079
+
+startWebServer :: Int -> FilePath -> IO ()
+startWebServer port dbPath = do
+ putText <| "Starting Ava web server on port " <> tshow port
+ Warp.run port (app dbPath)
+
+app :: FilePath -> Wai.Application
+app dbPath request respond = do
+ case Wai.pathInfo request of
+ ["trace", tid] -> serveTracePage dbPath tid request respond
+ ["api", "trace", tid] -> serveTraceJson dbPath tid request respond
+ ["health"] -> respond <| Wai.responseLBS HTTP.status200 [(HTTP.hContentType, "text/plain")] "ok"
+ _ -> respond <| Wai.responseLBS HTTP.status404 [(HTTP.hContentType, "text/plain")] "Not found"
+
+serveTracePage :: FilePath -> Text -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived
+serveTracePage dbPath tid _req respond = do
+ SQL.withConnection dbPath <| \conn -> do
+ maybeRec <- Trace.getTrace conn tid
+ case maybeRec of
+ Nothing -> respond <| Wai.responseLBS HTTP.status404 [(HTTP.hContentType, "text/plain")] "Trace not found"
+ Just rec -> do
+ html <- renderTraceHtml rec
+ respond <| Wai.responseLBS HTTP.status200 [(HTTP.hContentType, "text/html; charset=utf-8")] (LBS.fromStrict <| Encoding.encodeUtf8 html)
+
+serveTraceJson :: FilePath -> Text -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived
+serveTraceJson dbPath tid _req respond = do
+ SQL.withConnection dbPath <| \conn -> do
+ maybeRec <- Trace.getTrace conn tid
+ case maybeRec of
+ Nothing -> respond <| Wai.responseLBS HTTP.status404 [(HTTP.hContentType, "application/json")] "{\"error\":\"not found\"}"
+ Just rec -> do
+ let json = Aeson.encode rec
+ respond <| Wai.responseLBS HTTP.status200 [(HTTP.hContentType, "application/json")] json
+
+renderTraceHtml :: Trace.TraceRecord -> IO Text
+renderTraceHtml rec = do
+ coderoot <- Environment.getEnv "CODEROOT"
+ let templatePath = coderoot <> "/Omni/Ava/Web/trace.html"
+ template <- TextIO.readFile templatePath
+ pure
+ <| Text.replace "{{trace_id}}" (Trace.trcId rec)
+ <| Text.replace "{{tool_name}}" (Trace.trcToolName rec)
+ <| Text.replace "{{created_at}}" (Trace.trcCreatedAt rec)
+ <| Text.replace "{{duration_ms}}" (tshow <| Trace.trcDurationMs rec)
+ <| Text.replace "{{input_json}}" (Trace.trcInput rec)
+ <| Text.replace "{{output_json}}" (Trace.trcOutput rec)
+ <| template
diff --git a/Omni/Ava/Web/trace.html b/Omni/Ava/Web/trace.html
new file mode 100644
index 0000000..ce990a4
--- /dev/null
+++ b/Omni/Ava/Web/trace.html
@@ -0,0 +1,71 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Trace: {{tool_name}}</title>
+ <style>
+ :root { --bg: #1a1a2e; --fg: #eee; --accent: #4a9eff; --code-bg: #0d0d1a; }
+ @media (prefers-color-scheme: light) {
+ :root { --bg: #fff; --fg: #222; --accent: #0066cc; --code-bg: #f5f5f5; }
+ }
+ * { box-sizing: border-box; }
+ body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); padding: 1rem; margin: 0; max-width: 100%; }
+ h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
+ .meta { font-size: 0.875rem; opacity: 0.7; margin-bottom: 1rem; }
+ .section { margin: 1rem 0; border: 1px solid var(--accent); border-radius: 8px; overflow: hidden; }
+ .section-header { padding: 0.75rem 1rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; background: var(--code-bg); }
+ .section-header::before { content: '▼'; margin-right: 0.5rem; transition: transform 0.2s; }
+ .section.collapsed .section-header::before { transform: rotate(-90deg); }
+ .section-content { padding: 1rem; display: block; overflow-x: auto; background: var(--code-bg); }
+ .section.collapsed .section-content { display: none; }
+ pre { margin: 0; white-space: pre-wrap; word-break: break-word; font-size: 0.875rem; font-family: ui-monospace, monospace; }
+ .copy-btn { background: var(--accent); color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }
+ .copy-btn:hover { opacity: 0.8; }
+ .footer { font-size: 0.75rem; opacity: 0.5; margin-top: 2rem; text-align: center; }
+ </style>
+</head>
+<body>
+ <h1>{{tool_name}}</h1>
+ <p class="meta">{{created_at}} · {{duration_ms}}ms</p>
+
+ <div class="section" id="input-section">
+ <div class="section-header">
+ Input
+ <button class="copy-btn" data-target="input-content">Copy</button>
+ </div>
+ <div class="section-content" id="input-content"><pre>{{input_json}}</pre></div>
+ </div>
+
+ <div class="section" id="output-section">
+ <div class="section-header">
+ Output
+ <button class="copy-btn" data-target="output-content">Copy</button>
+ </div>
+ <div class="section-content" id="output-content"><pre>{{output_json}}</pre></div>
+ </div>
+
+ <p class="footer">Trace ID: {{trace_id}}</p>
+
+ <script>
+ document.querySelectorAll('.section-header').forEach(header => {
+ header.addEventListener('click', (e) => {
+ if (e.target.classList.contains('copy-btn')) return;
+ header.parentElement.classList.toggle('collapsed');
+ });
+ });
+
+ document.querySelectorAll('.copy-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const targetId = btn.getAttribute('data-target');
+ const text = document.getElementById(targetId).querySelector('pre').textContent;
+ navigator.clipboard.writeText(text).then(() => {
+ const orig = btn.textContent;
+ btn.textContent = 'Copied!';
+ setTimeout(() => btn.textContent = orig, 1500);
+ });
+ });
+ });
+ </script>
+</body>
+</html>
diff --git a/Omni/Bild.nix b/Omni/Bild.nix
index cec1ea0..940de2d 100644
--- a/Omni/Bild.nix
+++ b/Omni/Bild.nix
@@ -111,6 +111,7 @@
inherit
alejandra
awscli2
+ bash
bat
bc
cmark
@@ -118,6 +119,7 @@
universal-ctags
datasette
deadnix
+ direnv
doctl
fd
figlet
diff --git a/Omni/Bild/Deps/Haskell.nix b/Omni/Bild/Deps/Haskell.nix
index 138a80e..e4830bb 100644
--- a/Omni/Bild/Deps/Haskell.nix
+++ b/Omni/Bild/Deps/Haskell.nix
@@ -32,6 +32,7 @@
"lucid"
"monad-logger"
"mtl"
+ "mustache"
"neat-interpolation"
"network-uri"
"niv"
diff --git a/Omni/Dev/Beryllium/Ava.nix b/Omni/Dev/Beryllium/Ava.nix
index becbf9e..7563b26 100644
--- a/Omni/Dev/Beryllium/Ava.nix
+++ b/Omni/Dev/Beryllium/Ava.nix
@@ -35,12 +35,14 @@
bild.pkgs.git
bild.pkgs.sqlite
bild.pkgs.ffmpeg
+ bild.pkgs.bash
pkgs.curl
pkgs.pandoc
pkgs.imagemagick
pkgs.csvkit
pkgs.openai-whisper-cpp
pkgs.direnv
+ pkgs.nix
]}
'';
};
@@ -61,6 +63,8 @@ in {
"AVA_DATA_ROOT=/home/ava"
"HOME=/home/ava"
"OLLAMA_URL=http://localhost:11434"
+ "AVA_WEB_PORT=8079"
+ "AVA_WEB_URL=https://beryllium.oryx-ide.ts.net"
];
EnvironmentFile = "/run/secrets/ava.env";
@@ -111,4 +115,9 @@ in {
chown ava:users /home/ava/.bashrc
fi
'';
+
+ # Note: Tailscale Funnel for Ava web server is configured via:
+ # tailscale funnel --bg 8079
+ # This persists in tailscaled config and doesn't need a systemd service.
+ # URL: https://beryllium.oryx-ide.ts.net/
}
diff --git a/Omni/Ide/hooks/pre-push b/Omni/Ide/hooks/pre-push
index adbf858..86e6e3c 100755
--- a/Omni/Ide/hooks/pre-push
+++ b/Omni/Ide/hooks/pre-push
@@ -22,7 +22,7 @@ do
else
range="$remote_sha..$local_sha"
fi
+ gitlint --commits "$range" lint
+ git test run --command ci "$range"
done
-gitlint --commits "$range" lint
-git test run --command ci "$range"
git push "$remote" refs/notes/ci --no-verify