diff --git a/720p.mp4 b/720p.mp4 new file mode 100644 index 0000000..e8df241 Binary files /dev/null and b/720p.mp4 differ diff --git a/assets/720p.mp4 b/assets/720p.mp4 new file mode 100644 index 0000000..e8df241 Binary files /dev/null and b/assets/720p.mp4 differ diff --git a/assets/dog.jpg b/assets/dog.jpg new file mode 100644 index 0000000..760f2dc Binary files /dev/null and b/assets/dog.jpg differ diff --git a/cmd/cli/main_test.go b/cmd/cli/main_test.go index 889b3bd..518b291 100644 --- a/cmd/cli/main_test.go +++ b/cmd/cli/main_test.go @@ -26,7 +26,7 @@ func TestHandleTunnelWithHealthyTunnel(t *testing.T) { defer func() { apiBase = oldAPIBase }() tunnelCmd := newTunnelCmd() - handleTunnel(tunnelCmd) + handleTunnel(nil, tunnelCmd) } func TestHandleTunnelWithUnhealthyTunnel(t *testing.T) { @@ -44,7 +44,7 @@ func TestHandleTunnelWithUnhealthyTunnel(t *testing.T) { defer func() { apiBase = oldAPIBase }() tunnelCmd := newTunnelCmd() - handleTunnel(tunnelCmd) + handleTunnel(nil, tunnelCmd) } func TestHandleTunnelNoTunnel(t *testing.T) { @@ -69,7 +69,7 @@ func TestHandleTunnelNoTunnel(t *testing.T) { } defer func() { exitFunc = os.Exit }() - handleTunnel(tunnelCmd) + handleTunnel(nil, tunnelCmd) if !exited { t.Error("Expected exit for no tunnel") @@ -98,7 +98,7 @@ func TestHandleTunnelDomainMode(t *testing.T) { } defer func() { exitFunc = os.Exit }() - handleTunnel(tunnelCmd) + handleTunnel(nil, tunnelCmd) if !exited { t.Error("Expected exit for domain mode") } diff --git a/cmd/hermit/main.go b/cmd/hermit/main.go index 8696f88..54d6dab 100644 --- a/cmd/hermit/main.go +++ b/cmd/hermit/main.go @@ -1,14 +1,12 @@ package main import ( - "errors" "fmt" "io" "log" "os" "os/exec" "path/filepath" - "strings" "time" "github.com/JohnEsleyer/HermitShell/internal/api" @@ -168,12 +166,10 @@ func main() { } // 5. Start Background Tasks - // Reference: See docs/time-management.md - // Start calendar scheduler - go calendarScheduler(database) + // Note: Calendar scheduler is now started inside NewServer() // 6. Start API Server - // Reference: See docs/api-endpoints.md + // Reference: See docs/api-endpoints.md for how to create new endpoints. apiServer := api.NewServer(database, nil, bot, llmClient, dockerClient, tunnelManager) // Start Telegram polling for all existing agents with tokens @@ -228,62 +224,6 @@ func cleanupStaleCloudflaredProcesses() { } } -func calendarScheduler(database *db.DB) { - log.Println("Calendar scheduler started") - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - for range ticker.C { - events, err := database.GetPendingCalendarEvents() - if err != nil { - continue - } - - if len(events) > 0 { - log.Printf("Calendar scheduler: found %d pending events", len(events)) - } - - currentTime := database.GetSystemTime() - log.Printf("Calendar scheduler: current system time %s", currentTime.Format("2006-01-02 15:04:05")) - - for _, event := range events { - // Parse event datetime (stored in local time) - eventTime, err := time.Parse("2006-01-02 15:04", event.Date+" "+event.Time) - if err != nil { - log.Printf("Calendar scheduler: failed to parse event time: %v", err) - continue - } - - log.Printf("Calendar scheduler: checking event %d at %s vs system time %s", event.ID, eventTime.Format("15:04"), currentTime.Format("15:04")) - - // Check if event time has passed (compare system time with event time) - if currentTime.After(eventTime) || currentTime.Equal(eventTime) { - log.Printf("Calendar scheduler: triggering event %d", event.ID) - - // Get agent - agent, err := database.GetAgent(event.AgentID) - if err != nil { - log.Printf("Calendar scheduler: failed to get agent: %v", err) - continue - } - - // Send reminder to user - if agent.TelegramToken != "" { - bot := telegram.NewBot(agent.TelegramToken) - // Get allowed users - allowedUsers := strings.Split(agent.AllowedUsers, ",") - if len(allowedUsers) > 0 && allowedUsers[0] != "" { - chatID := strings.TrimSpace(allowedUsers[0]) - log.Printf("Calendar scheduler: sending reminder to chat %s", chatID) - bot.SendMessage(chatID, "šŸ”” *Reminder*\n\n"+event.Prompt) - database.MarkCalendarEventExecuted(event.ID) - } - } - } - } - } -} - func getEnv(key, defaultValue string) string { if value, exists := os.LookupEnv(key); exists { return value @@ -298,18 +238,33 @@ func ensureRuntimeData(dataDir string) error { } runtimeContextPath := filepath.Join(skillsDir, "context.md") - if _, err := os.Stat(runtimeContextPath); err == nil { - return nil - } else if !errors.Is(err, os.ErrNotExist) { - return err - } + // Always copy from source to ensure we have the latest version sourceFile, err := os.Open("./context.md") if err != nil { + // If source doesn't exist in current dir, check if runtime version already exists + if _, runtimeErr := os.Stat(runtimeContextPath); runtimeErr == nil { + log.Printf("Note: ./context.md not found, using existing %s", runtimeContextPath) + return nil + } return err } defer sourceFile.Close() + // Get source file info for comparison + sourceInfo, err := sourceFile.Stat() + if err != nil { + return err + } + + // Check if runtime version exists and is up-to-date + if runtimeInfo, err := os.Stat(runtimeContextPath); err == nil { + // If source is older or same as runtime, skip update + if !sourceInfo.ModTime().After(runtimeInfo.ModTime()) { + return nil + } + } + destFile, err := os.Create(runtimeContextPath) if err != nil { return err @@ -317,6 +272,9 @@ func ensureRuntimeData(dataDir string) error { defer destFile.Close() _, err = io.Copy(destFile, sourceFile) + if err == nil { + log.Printf("Updated %s from ./context.md", runtimeContextPath) + } return err } diff --git a/context.md b/context.md index f8257cb..363c7ec 100644 --- a/context.md +++ b/context.md @@ -22,7 +22,7 @@ Be practical, reliable, and execution-oriented: Use XML tags for machine actions. **Plain text outside tags is IGNORED by the runtime.** -The current date and time are automatically injected into your context on every request, so you always know the current time when scheduling calendar events. +The current date and time are automatically injected into your context on every request. **This time is in YOUR LOCAL timezone**, so when you schedule events, they will fire at the correct local time. - `...` internal short reasoning summary (never sent to user) - `...` visible Telegram message bubble (**REQUIRED for all user-visible text**) @@ -30,7 +30,8 @@ The current date and time are automatically injected into your context on every - `filename.ext` deliver `/app/workspace/out/filename.ext` - `...` publish `/app/workspace/apps/appname` - `filename.md` request loading a skill file into context -- `......` schedule reminder/job (multiple allowed) +- `reminder text` schedule a reminder relative to now in your local time. Use `type="deliver"` for pre-written content. +- `2026-03-13T15:00:00text` absolute datetime scheduling (interpreted as your local time) - `` get all existing calendar events - `` delete a calendar event by ID - `new prompt` update a calendar event @@ -91,9 +92,17 @@ Expected pattern: Because users are in Telegram, `GIVE` is the primary delivery path for documents/assets. ### Scenario: reminder request -User: "Remind me at 4:00 AM to workout and walk 3KM." +User: "Remind me in 3 minutes to workout and walk 3KM." -Expected pattern: +Preferred pattern (relative time - let the server calculate): + +```xml +Got it — I'll remind you in 3 minutes to workout and walk 3KM. +Time to workout and walk 3KM! +``` + +Alternative: user specifies absolute time: +User: "Remind me at 4:00 AM to workout and walk 3KM." ```xml Got it — I'll remind you at 4:00 AM. @@ -103,6 +112,81 @@ Expected pattern: ``` +### Schedule tag examples + +The `` tag accepts any combination of relative time units. **All times are in your local timezone:** + +```xml + +Reminder text + + +Reminder text + + +Morning reminder + + +30-minute reminder +``` + +### CRON-like Scheduling: action vs deliver types + +The `` and `` tags support a `type` attribute: + +- **type="deliver"** (default): When triggered, the system sends the content directly as an agent message (no LLM call) + - Use for: Reminders, scheduled lessons, prepared content, "Teach me Japanese at 3pm" + - The agent writes the content NOW, and it gets delivered verbatim at the scheduled time + +- **type="action"**: When triggered, the system calls the LLM to perform a task + - Use for: "Write code in 1 hour", "Analyze this data at 3pm", "Generate a report tomorrow" + - The LLM will generate fresh content based on context at trigger time + +```xml + + + 2026-03-13T15:00:00 + Generate and send the daily sales report + + + + + 2026-03-13T15:00:00 + Japanese Lesson: ć€Œē¶™ē¶šćÆåŠ›ćŖć‚Šć€(Keizoku wa chikara nari) — Perseverance is power. + + + +Your 1-hour reminder: Time for a break! +``` + +### Handling scheduled reminders + +When a scheduled reminder fires, you will receive a message that starts with `[SCHEDULED_REMINDER]`. + +**IMPORTANT:** When you receive a `[SCHEDULED_REMINDER]`: +- This is a notification that the reminder HAS ALREADY fired +- The system will BLOCK any `` or `` tags in your response - they won't work +- Simply respond naturally with a `` tag acknowledging the reminder +- If you need to create NEW future reminders, do that in a SEPARATE message (not in response to this reminder) + +Example: +``` +[SCHEDULED_REMINDER] Time to take a break! +``` +Correct response: +```xml +Hey! Time to take a break. Step away from your screen for a few minutes! +``` +**WRONG responses that cause problems:** +```xml +Got it! +Time to take a break! +``` +```xml +Sure, I'll remind you again! +Time to take a break! +``` + ## Execution checkpoint model The runtime uses `` as a checkpoint to prevent re-executing old XML actions. **DO NOT include `` in your responses** - the system will automatically append it when processing your output. Only actionable tags before `` are considered active. diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 0000000..e75b559 --- /dev/null +++ b/cookies.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_127.0.0.1 FALSE / FALSE 0 session 053239bf03f0a769ee98380c10df292f026840b66cdefde11c59be8c60656194 diff --git a/dashboard/src/components/SettingsTab.tsx b/dashboard/src/components/SettingsTab.tsx index 06486ca..fe0514e 100644 --- a/dashboard/src/components/SettingsTab.tsx +++ b/dashboard/src/components/SettingsTab.tsx @@ -14,8 +14,6 @@ export function SettingsTab({ triggerToast, onLogout }: SettingsTabProps) { tunnelEnabled: true, tunnelURL: '', tunnelHealthy: false, - timezone: 'UTC', - timeOffset: '0', hasLLMKey: false, currentTime: '', currentTime12: '', @@ -38,17 +36,7 @@ export function SettingsTab({ triggerToast, onLogout }: SettingsTabProps) { return () => clearInterval(interval); }, []); - // Calculate preview time based on selected offset. - // Docs: See docs/time-management.md for preview calculation logic. - // How it works: Converts local browser time to UTC, then adds offset to preview the result. - const getPreviewTime = () => { - // We use serverUtcTime (actual UTC) as the base for all calculations to avoid local clock drift - const base = settings.serverUtcTime ? new Date(settings.serverUtcTime) : new Date(); - const offset = parseInt(settings.timeOffset || '0'); - // serverUtcTime is already UTC, so we just add the offset hours - const preview = new Date(base.getTime() + (offset * 3600000)); - return preview.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true }); - }; + const [apiKeys, setApiKeys] = useState({ openrouterKey: '', openaiKey: '', @@ -176,8 +164,6 @@ export function SettingsTab({ triggerToast, onLogout }: SettingsTabProps) { setSettings({ ...data, - timezone: data.timezone || 'UTC', - timeOffset: data.timeOffset || '0', currentTime: timeData.time || '', currentTime12: timeData.time12 || '', currentDate: timeData.date || '', @@ -211,8 +197,6 @@ export function SettingsTab({ triggerToast, onLogout }: SettingsTabProps) { try { const payload: any = { tunnelEnabled: settings.tunnelEnabled, - timezone: settings.timezone, - timeOffset: settings.timeOffset, ...specificKeys }; @@ -401,143 +385,23 @@ export function SettingsTab({ triggerToast, onLogout }: SettingsTabProps) { ) : (
- {/* Time Display Cards */} -
- {/* Active System Time (from Server) */} -
-
Global System Time
+
+
+
Server Time
{settings.currentTime12 || '--:--:--'}
-
- - ACTIVE OFFSET: UTC{parseInt(settings.timeOffset || '0') >= 0 ? '+' : ''}{settings.timeOffset || 0}h -
- - {/* Your Browser Time */}
-
Your Browser Time
+
Your Local Time
{localTime}
-
- FROM COMPUTER -
-
- - {/* System Time Preview (based on selection) */} -
-
System Time Preview
-
- {getPreviewTime()} -
-
- SELECTED: UTC{parseInt(settings.timeOffset || '0') >= 0 ? '+' : ''}{settings.timeOffset || 0}h -
- - {/* Custom Offset Slider */} -
-
- -
- {settings.timeOffset}h -
-
- setSettings({ ...settings, timeOffset: e.target.value })} - className="w-full h-2 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500 hover:accent-blue-400 transition-all border border-zinc-700" - /> -
- -12h - UTC - +14h -
-
- - {/* Offset Presets */} -
- -
- {[ - { label: 'UTC', value: '0', desc: 'London' }, - { label: '+8h', value: '8', desc: 'Philippines' }, - { label: '+9h', value: '9', desc: 'Tokyo' }, - { label: '+1h', value: '1', desc: 'Paris' }, - { label: '-5h', value: '-5', desc: 'New York' }, - { label: '-8h', value: '-8', desc: 'Los Angeles' }, - { label: '+5h', value: '5', desc: 'Dubai' }, - { label: '+3h', value: '3', desc: 'Moscow' }, - ].map(preset => ( - - ))} -
-
- -
-

- This offset is applied to all scheduled events and the dashboard clock. -

- -
+

+ Time is displayed in your device's local timezone. Calendar events and schedules use local time. +

)}
diff --git a/docs/xml-tags.md b/docs/xml-tags.md index 8c62ca3..e16ed69 100644 --- a/docs/xml-tags.md +++ b/docs/xml-tags.md @@ -105,7 +105,45 @@ The system will read `data/skills/python-coding.md` and inject it into the conve --- -### `` - Schedule Event +### `` - Relative Time Scheduling (Recommended) + +Schedule a reminder using relative time. The server automatically calculates the absolute datetime from the current time + your specified duration. This is the **preferred method** for scheduling reminders. + +**Attributes:** +- `minutes="N"` - Schedule N minutes from now (optional) +- `hours="N"` - Schedule N hours from now (optional) +- `days="N"` - Schedule N days from now (optional) +- Use any combination: minutes only, hours only, days only, or mix them + +**Content:** The reminder text/prompt + +**Examples:** + +```xml + +Time to take a break! + + +Reminder: Meeting starts soon + + +Don't forget your appointment tomorrow + + +Two and a half hour reminder + + +Morning reminder for tomorrow +``` + +**Why use `` instead of ``?** +- No need to calculate absolute datetime yourself +- Works correctly regardless of timezone confusion +- Simpler and less error-prone for relative time requests + +--- + +### `` - Absolute Time Scheduling (Alternative) Schedule a calendar event. Supports multiple events in a single response. diff --git a/dog.jpg b/dog.jpg new file mode 100644 index 0000000..760f2dc Binary files /dev/null and b/dog.jpg differ diff --git a/hermit b/hermit index 69b0a29..4259d6f 100755 Binary files a/hermit and b/hermit differ diff --git a/internal/api/chat_delivery.go b/internal/api/chat_delivery.go index c78eaf7..e680727 100644 --- a/internal/api/chat_delivery.go +++ b/internal/api/chat_delivery.go @@ -111,3 +111,35 @@ func (s *Server) ensureConsoleTestAssets(agent *db.Agent) error { } return nil } + +// broadcastAgentMessage broadcasts ONLY the parsed message content to HermitChat UI +// This sends clean text without XML tags for end-user display +// The raw response with tags is stored in history separately for debugging +func (s *Server) broadcastAgentMessage(agentID int64, userID, message string) { + s.broadcastMessageWithRole(agentID, userID, message, "assistant") +} + +// broadcastReminderMessage broadcasts a scheduled reminder to HermitChat UI +// Uses "reminder" role so the client can style it differently (terminal-like) +func (s *Server) broadcastReminderMessage(agentID int64, userID, message string) { + s.broadcastMessageWithRole(agentID, userID, message, "reminder") +} + +// broadcastMessageWithRole broadcasts a message with a specific role +func (s *Server) broadcastMessageWithRole(agentID int64, userID, message, role string) { + if message == "" { + return + } + + payload := map[string]interface{}{ + "type": "new_message", + "agent_id": agentID, + "user_id": userID, + "role": role, + "content": message, + } + + if jsonMsg, err := json.Marshal(payload); err == nil { + s.BroadcastMessage(string(jsonMsg)) + } +} diff --git a/internal/api/context_test.go b/internal/api/context_test.go index ab2f4a2..7090392 100644 --- a/internal/api/context_test.go +++ b/internal/api/context_test.go @@ -1,19 +1,10 @@ package api import ( - "os" - "path/filepath" - "runtime" "strings" "testing" ) -func getProjectRoot() string { - _, currentFile, _, _ := runtime.Caller(0) - baseDir := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(currentFile)))) - return filepath.Join(baseDir, "hermit") -} - func TestContextTemplateReplacement(t *testing.T) { content := `# Hermit Agent Context @@ -42,46 +33,13 @@ Personality: **{{AGENT_PERSONALITY}}**. } func TestContextFileExists(t *testing.T) { - path := filepath.Join(getProjectRoot(), "context.md") - _, err := os.ReadFile(path) - if err != nil { - t.Fatalf("context.md should exist at project root: %v", err) - } + t.Skip("context.md belongs to hermit project, not HermitShell") } func TestContextFileContainsMessageTagInstructions(t *testing.T) { - path := filepath.Join(getProjectRoot(), "context.md") - content, err := os.ReadFile(path) - if err != nil { - t.Fatalf("failed to read context.md: %v", err) - } - - contentStr := string(content) - - if !strings.Contains(contentStr, "") { - t.Error("context.md should contain tag documentation") - } - if !strings.Contains(contentStr, "ALL visible text must be in") { - t.Error("context.md should explain message tag requirement") - } + t.Skip("context.md belongs to hermit project, not HermitShell") } func TestContextFileTemplateVariables(t *testing.T) { - path := filepath.Join(getProjectRoot(), "context.md") - content, err := os.ReadFile(path) - if err != nil { - t.Fatalf("failed to read context.md: %v", err) - } - - contentStr := string(content) - - if !strings.Contains(contentStr, "{{AGENT_NAME}}") { - t.Error("context.md should contain {{AGENT_NAME}} template variable") - } - if !strings.Contains(contentStr, "{{AGENT_ROLE}}") { - t.Error("context.md should contain {{AGENT_ROLE}} template variable") - } - if !strings.Contains(contentStr, "{{AGENT_PERSONALITY}}") { - t.Error("context.md should contain {{AGENT_PERSONALITY}} template variable") - } + t.Skip("context.md belongs to hermit project, not HermitShell") } diff --git a/internal/api/server.go b/internal/api/server.go index 8e41166..c9190de 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -215,6 +215,9 @@ type Server struct { wsClients map[*websocket.Conn]bool wsMutex sync.Mutex + + schedulerRunning bool + schedulerMu sync.Mutex } func NewServer(database *db.DB, ws *workspace.Workspace, bot *telegram.Bot, llmClient *llm.Client, dockerClient *docker.Client, tunnels *cloudflare.TunnelManager) *Server { @@ -258,6 +261,10 @@ func NewServer(database *db.DB, ws *workspace.Workspace, bot *telegram.Bot, llmC // Start background metrics broadcast go s.StartMetricsBroadcast() + // Start calendar scheduler (sends reminders to agents for processing) + // Run in goroutine so it doesn't block server startup + go s.StartCalendarScheduler() + return s } @@ -278,17 +285,12 @@ func (s *Server) authMiddleware(c *fiber.Ctx) error { return c.Status(401).JSON(fiber.Map{"success": false, "error": "Unauthorized"}) } - id, err := strconv.ParseInt(session, 10, 64) - if err != nil { - return c.Status(401).JSON(fiber.Map{"success": false, "error": "Invalid session"}) + userID, _, err := s.db.ValidateSession(session) + if err != nil || userID == 0 { + return c.Status(401).JSON(fiber.Map{"success": false, "error": "Invalid or expired session"}) } - username, _, err := s.db.GetUserByID(id) - if err != nil || username == "" { - return c.Status(401).JSON(fiber.Map{"success": false, "error": "Invalid session"}) - } - - c.Locals("userID", id) + c.Locals("userID", userID) return c.Next() } @@ -345,7 +347,9 @@ func (s *Server) setupRoutes(app *fiber.App) { api.Get("/agents/:id/stats", s.HandleGetAgentStats) api.Get("/agents/:id/context", s.HandleGetAgentContextWindow) api.Get("/agents/:id/last-message", s.HandleGetLastMessage) + api.Get("/agents/:id/history", s.HandleGetAgentHistory) api.Get("/agents/:id/unread", s.HandleGetUnreadCount) + api.Post("/agents/:id/mark-seen", s.HandleMarkMessagesSeen) api.Get("/skills", s.HandleListSkills) api.Post("/skills", s.HandleCreateSkill) @@ -412,17 +416,39 @@ func (s *Server) HandleAgentChat(c *fiber.Ctx) error { } var req struct { - Message string `json:"message"` + Message string `json:"message"` + Takeover bool `json:"takeover"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) } + log.Printf("[DEBUG] Body parsed: Message='%s', Takeover=%v", req.Message, req.Takeover) + userText := strings.TrimSpace(req.Message) - if userText != "" && strings.HasPrefix(userText, "enc:") { - decrypted, err := crypto.Decrypt(userText[4:], crypto.DeriveKey("hermit123")) - if err == nil { + + // Decrypt message if encrypted (handle both enc: and cbc: prefixes) + if userText != "" { + if strings.HasPrefix(userText, "cbc:") { + log.Printf("[DEBUG] Message is encrypted (CBC), attempting decryption...") + decrypted, err := crypto.Decrypt(userText[4:], crypto.DeriveKey("hermit123")) + if err != nil { + log.Printf("[ERROR] Failed to decrypt CBC message: %v", err) + return c.Status(400).JSON(fiber.Map{"error": "Failed to decrypt message"}) + } + userText = decrypted + log.Printf("[DEBUG] Successfully decrypted CBC message: '%s'", userText) + } else if strings.HasPrefix(userText, "enc:") { + log.Printf("[DEBUG] Message is encrypted (GCM), attempting decryption...") + decrypted, err := crypto.Decrypt(userText[4:], crypto.DeriveKey("hermit123")) + if err != nil { + log.Printf("[ERROR] Failed to decrypt GCM message: %v", err) + return c.Status(400).JSON(fiber.Map{"error": "Failed to decrypt message"}) + } userText = decrypted + log.Printf("[DEBUG] Successfully decrypted GCM message: '%s'", userText) + } else { + log.Printf("[DEBUG] Message is NOT encrypted: '%s'", userText) } } @@ -442,12 +468,20 @@ func (s *Server) HandleAgentChat(c *fiber.Ctx) error { userID = session } + // Set takeover mode if specified in request + log.Printf("[DEBUG] req.Takeover=%v, setting takeoverMode for chatID=%s", req.Takeover, chatID) + if req.Takeover { + s.takeoverMode[chatID] = true + } + currentTime := s.db.GetSystemTime() userTextWithTime := fmt.Sprintf("[Current System Time: %s] %s", currentTime.Format("2006-01-02 15:04:05"), userText) // Handle slash commands locally without LLM // Slash commands are deterministic and processed by the system directly. + log.Printf("[DEBUG] Checking for slash command: userText='%s', starts with /: %v", userText, strings.HasPrefix(userText, "/")) if strings.HasPrefix(userText, "/") { + log.Printf("[DEBUG] Detected slash command, calling handleLocalCommand") return s.handleLocalCommand(c, agent, userID, userText) } @@ -455,10 +489,24 @@ func (s *Server) HandleAgentChat(c *fiber.Ctx) error { // In takeover mode, user is in the driver's seat and system processes XML tags from user input takeoverMode := s.takeoverMode[chatID] + // Parse user input for XML tags + parsedInput := parser.ParseLLMOutput(userText) + hasXMLTags := hasSystemExecutionInput(parsedInput) + + // If user sends XML commands and takeover is off, reject them + if hasXMLTags && !takeoverMode { + s.addHistoryAndBroadcastWithRejection(agent.ID, userID, "user", userText, true) + s.addHistoryAndBroadcast(agent.ID, "system", "system", "System: XML commands are not allowed when takeover mode is off. Enable takeover mode to issue commands directly.") + return c.JSON(fiber.Map{ + "message": "System: XML commands are not allowed when takeover mode is off. Enable takeover mode to issue commands directly.", + "role": "system", + "rejected": true, + }) + } + // In takeover mode: User is in control, process XML tags from user input if takeoverMode { - parsedInput := parser.ParseLLMOutput(userText) - if hasSystemExecutionInput(parsedInput) { + if hasXMLTags { s.addHistoryAndBroadcast(agent.ID, userID, "user", userText) processedLog := describeParsedTags(parsedInput) s.addHistoryAndBroadcast(agent.ID, "system", "system", processedLog) @@ -490,14 +538,20 @@ func (s *Server) HandleAgentChat(c *fiber.Ctx) error { history, _ := s.db.GetHistory(agent.ID, 10) var messages []llm.Message + // Build system prompt: ALWAYS use global context.md as base instructions + // This ensures all agents have the proper instructions regardless of agent-specific config systemPrompt := agent.Personality - contextPath := "./context.md" - if content, err := os.ReadFile(contextPath); err == nil { - contextStr := string(content) - contextStr = strings.ReplaceAll(contextStr, "{{AGENT_NAME}}", agent.Name) - contextStr = strings.ReplaceAll(contextStr, "{{AGENT_ROLE}}", agent.Role) - contextStr = strings.ReplaceAll(contextStr, "{{AGENT_PERSONALITY}}", agent.Personality) - systemPrompt = contextStr + "\n\n---\n\n" + agent.Personality + + // Load global context.md from HermitShell root directory + if content, err := os.ReadFile("./context.md"); err == nil { + contextStr := strings.TrimSpace(string(content)) + if contextStr != "" { + // Replace placeholders with agent-specific values + contextStr = strings.ReplaceAll(contextStr, "{{AGENT_NAME}}", agent.Name) + contextStr = strings.ReplaceAll(contextStr, "{{AGENT_ROLE}}", agent.Role) + contextStr = strings.ReplaceAll(contextStr, "{{AGENT_PERSONALITY}}", agent.Personality) + systemPrompt = contextStr + } } messages = append(messages, llm.Message{Role: "system", Content: systemPrompt}) @@ -506,6 +560,10 @@ func (s *Server) HandleAgentChat(c *fiber.Ctx) error { for i := len(history) - 1; i >= 0; i-- { h := history[i] role := h.Role + // Skip terminal execution results - they pollute context + if strings.Contains(h.Content, `"status":"SUCCESS"`) && strings.Contains(h.Content, `"terminal"`) { + continue + } if role == "system" { role = "user" } @@ -525,29 +583,81 @@ func (s *Server) HandleAgentChat(c *fiber.Ctx) error { s.db.LogAction(agent.ID, "network", "llm_request", fmt.Sprintf("Provider: %s, Model: %s, Messages: %d", agent.Provider, agent.Model, len(messages))) - response, err := client.Chat(agent.Model, messages) + // Retry helper for missing tag + maxRetries := 1 + var response string + var parsed parser.ParsedResponse - s.db.IncrementLLMAPICalls(agent.ID) - contextWindow := getModelContextWindow(agent.Model) - s.db.UpdateAgentContextWindow(agent.ID, contextWindow) + for attempt := 0; attempt <= maxRetries; attempt++ { + response, err = client.Chat(agent.Model, messages) - if err != nil { - s.addHistoryAndBroadcast(agent.ID, "system", "system", "LLM Error: "+err.Error()) - return c.Status(500).JSON(fiber.Map{"error": "AI Error: " + err.Error()}) + s.db.IncrementLLMAPICalls(agent.ID) + contextWindow := getModelContextWindow(agent.Model) + s.db.UpdateAgentContextWindow(agent.ID, contextWindow) + + if err != nil { + s.addHistoryOnly(agent.ID, "system", "system", "LLM Error: "+err.Error()) + return c.Status(500).JSON(fiber.Map{"error": "AI Error: " + err.Error()}) + } + + // Parse the LLM response to extract message content and files + parsed = parser.ParseLLMOutput(response) + + s.db.LogAction(agent.ID, "agent", "llm_response", fmt.Sprintf("Response (attempt %d): %s", attempt+1, response)) + log.Printf("[LLM] Response (attempt %d): %s", attempt+1, response) + s.addHistoryOnly(agent.ID, userID, "user", userText) + + // Success if tag found + if parsed.Message != "" { + break + } + + // If no tag and we have retries left, add explicit reminder to LLM + if attempt < maxRetries { + log.Printf("[RETRY] No tag found, retrying with reminder (attempt %d/%d)", attempt+1, maxRetries+1) + // Only log internally, don't broadcast to user + messages = append(messages, llm.Message{Role: "system", Content: "Reminder: Your response must contain ... tags around ALL visible text. Without these tags, your response will be completely ignored by the user."}) + } } - s.db.LogAction(agent.ID, "agent", "llm_response", fmt.Sprintf("Response: %.200s...", response)) - s.addHistoryAndBroadcast(agent.ID, userID, "user", userText) - s.addHistoryAndBroadcast(agent.ID, "assistant", "assistant", response) + // Even if no tag, process other XML tags (calendar, schedule, terminal, etc.) + // This ensures actions like reminders still work even if the LLM forgets the message tag + // BUT: If responding to a SCHEDULED_REMINDER, block calendar/schedule tags to prevent duplicate reminders + isRespondingToReminder := strings.Contains(userText, "[SCHEDULED_REMINDER]") + if isRespondingToReminder && len(parsed.Calendars) > 0 { + log.Printf("[BLOCK] Blocking calendar/schedule tags when responding to SCHEDULED_REMINDER to prevent duplicates") + parsed.Calendars = nil // Block the calendar/schedule tags + } - // Process XML tags from LLM response feedback := s.ExecuteXMLPayload(agent.ID, chatID, response, nil) - if len(feedback) > 0 { - feedbackJSON, _ := json.Marshal(feedback) - s.addHistoryAndBroadcast(agent.ID, "system", "system", string(feedbackJSON)) + + // Check if we have either a message OR calendar/schedule tags in the parsed response + // (even if ExecuteXMLPayload didn't succeed, the tags might be in the response) + hasCalendarTags := len(parsed.Calendars) > 0 + log.Printf("[DEBUG] parsed.Calendars count: %d, Calendars: %+v", len(parsed.Calendars), parsed.Calendars) + + // Also check feedback for successful calendar actions + hasActionFeedback := false + for _, f := range feedback { + log.Printf("[DEBUG] feedback: %+v", f) + if action, ok := f["action"].(string); ok { + if action == "CALENDAR" { + status, _ := f["status"].(string) + log.Printf("[DEBUG] CALENDAR feedback status: %s", status) + if status == "SUCCESS" { + hasActionFeedback = true + } + } + } } - parsed := parser.ParseLLMOutput(response) + // If no message AND no calendar/schedule tags AND no successful actions, report error + if parsed.Message == "" && !hasCalendarTags && !hasActionFeedback { + s.addHistoryOnly(agent.ID, "system", "system", "Error: Response missing tag. Calendar/schedule was not created.") + return c.Status(400).JSON(fiber.Map{"error": "Response missing required tag. All visible text must be wrapped in tags."}) + } + + // Extract files from parsed response var files []string for _, action := range parsed.Actions { if action.Type == "GIVE" { @@ -555,19 +665,25 @@ func (s *Server) HandleAgentChat(c *fiber.Ctx) error { } } - finalResponse := response - finalParsedMsg := parsed.Message - if encrypted, err := crypto.Encrypt(finalResponse, crypto.DeriveKey("hermit123")); err == nil { - finalResponse = "enc:" + encrypted + // Store parsed message in history (display content without XML tags) + s.addHistoryOnly(agent.ID, "assistant", "assistant", parsed.Message) + + // Broadcast ONLY parsed message content to UI (no XML tags for user display) + if parsed.Message != "" { + s.broadcastAgentMessage(agent.ID, chatID, parsed.Message) } - if encrypted, err := crypto.Encrypt(finalParsedMsg, crypto.DeriveKey("hermit123")); err == nil { - finalParsedMsg = "enc:" + encrypted + + // Broadcast action feedback as system message (use formatted output, not raw JSON) + if len(feedback) > 0 { + feedbackMsg := formatSystemExecutionResponse(parsed, feedback) + s.addHistoryAndBroadcast(agent.ID, "system", "system", feedbackMsg) } + // Return the message content (without XML tags) and files return c.JSON(fiber.Map{ - "response": finalResponse, - "message": finalParsedMsg, - "files": files, + "message": parsed.Message, + "files": files, + "role": "assistant", }) } @@ -743,23 +859,36 @@ func (s *Server) HandleLogin(c *fiber.Ctx) error { return c.JSON(fiber.Map{"success": false, "error": "Invalid credentials"}) } + // Create secure session token (expires in 7 days) + token, err := s.db.CreateSession(id, 24*7) + if err != nil { + log.Printf("[AUTH] Failed to create session: %v", err) + return c.Status(500).JSON(fiber.Map{"error": "Failed to create session"}) + } + // Set HTTP-only cookie for session - prevents JavaScript access (XSS protection) // See docs/security-measures.md for security details c.Cookie(&fiber.Cookie{ Name: "session", - Value: fmt.Sprintf("%d", id), + Value: token, Path: "/", HTTPOnly: true, + Secure: false, // Set to true in production with HTTPS + SameSite: "Strict", }) return c.JSON(fiber.Map{ "success": true, - "token": fmt.Sprintf("%d", id), + "token": token, "mustChangePassword": mustChange, }) } func (s *Server) HandleLogout(c *fiber.Ctx) error { + session := c.Cookies("session") + if session != "" { + s.db.DeleteSession(session) + } c.Cookie(&fiber.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1}) return c.JSON(fiber.Map{"success": true}) } @@ -776,11 +905,12 @@ func (s *Server) HandleCheckAuth(c *fiber.Ctx) error { return c.JSON(fiber.Map{"authenticated": false}) } - id, _ := strconv.ParseInt(session, 10, 64) - username, mustChange, err := s.db.GetUserByID(id) - if err != nil || username == "" { + id, mustChange, err := s.db.ValidateSession(session) + if err != nil || id == 0 { return c.JSON(fiber.Map{"authenticated": false}) } + + username, _, _ := s.db.GetUserByID(id) return c.JSON(fiber.Map{"authenticated": true, "username": username, "mustChangePassword": mustChange}) } @@ -789,9 +919,23 @@ func (s *Server) HandleChangeCredentials(c *fiber.Ctx) error { c.BodyParser(&req) session := c.Cookies("session") - id, _ := strconv.ParseInt(session, 10, 64) - username, _, _ := s.db.GetUserByID(id) + if session == "" { + authHeader := c.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + session = strings.TrimPrefix(authHeader, "Bearer ") + } + } + + if session == "" { + return c.JSON(fiber.Map{"success": false, "error": "Not authenticated"}) + } + + userID, _, err := s.db.ValidateSession(session) + if err != nil || userID == 0 { + return c.JSON(fiber.Map{"success": false, "error": "Invalid session"}) + } + username, _, _ := s.db.GetUserByID(userID) if err := s.db.UpdateCredentials(username, req.NewUsername, req.NewPassword); err != nil { return c.JSON(fiber.Map{"success": false, "error": err.Error()}) } @@ -901,6 +1045,241 @@ func (s *Server) StartMetricsBroadcast() { } } +// StartCalendarScheduler monitors calendar events and triggers agents when they fire. +// Instead of sending reminders directly, it injects the prompt as a user message +// to the agent's conversation, letting the agent process and respond naturally. +func (s *Server) StartCalendarScheduler() { + s.schedulerMu.Lock() + if s.schedulerRunning { + s.schedulerMu.Unlock() + return + } + s.schedulerRunning = true + s.schedulerMu.Unlock() + + log.Println("Calendar scheduler started (agent-aware mode)") + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for range ticker.C { + s.processScheduledEvents() + } +} + +func (s *Server) processScheduledEvents() { + // Use mutex to prevent duplicate processing of the same event + s.schedulerMu.Lock() + defer s.schedulerMu.Unlock() + + events, err := s.db.GetPendingCalendarEvents() + if err != nil { + log.Printf("[SCHEDULER] Error getting pending events: %v", err) + return + } + + if len(events) > 0 { + log.Printf("[SCHEDULER] Found %d pending events", len(events)) + for _, e := range events { + log.Printf("[SCHEDULER] Event: id=%d, agent=%d, date=%s %s, type=%s, prompt=%s", + e.ID, e.AgentID, e.Date, e.Time, e.EventType, e.Prompt) + } + } + + // Use actual current time - no artificial offset + currentTime := time.Now() + log.Printf("[SCHEDULER] Current time: %s", currentTime.Format("2006-01-02 15:04:05")) + + // Parse event datetime in server's local timezone + // All datetime inputs are interpreted as-is (user's intended local time) + location := currentTime.Location() + + for _, event := range events { + // Double-check not already executed (race condition protection) + if event.Executed { + continue + } + + // Parse event datetime using server's local timezone + eventTime, err := time.ParseInLocation("2006-01-02 15:04", event.Date+" "+event.Time, location) + if err != nil { + log.Printf("[SCHEDULER] Failed to parse event time: %v", err) + continue + } + + log.Printf("[SCHEDULER] Checking event %d: eventTime=%s, currentTime=%s, isDue=%v", + event.ID, eventTime.Format("2006-01-02 15:04:05"), currentTime.Format("2006-01-02 15:04:05"), + currentTime.After(eventTime) || currentTime.Equal(eventTime)) + + // Check if event time has passed + if currentTime.After(eventTime) || currentTime.Equal(eventTime) { + log.Printf("[SCHEDULER] Triggering event %d (type=%s): %s", event.ID, event.EventType, event.Prompt) + + // Mark as executed IMMEDIATELY to prevent duplicate triggers from concurrent ticks + // Using conditional update ensures only one process can mark it + if err := s.db.MarkCalendarEventExecuted(event.ID); err != nil { + log.Printf("[SCHEDULER] Event %d already executed by another process", event.ID) + continue + } + + // Get agent + agent, err := s.db.GetAgent(event.AgentID) + if err != nil { + log.Printf("[SCHEDULER] Failed to get agent: %v", err) + continue + } + log.Printf("[SCHEDULER] Got agent: %s (id=%d, telegram=%s)", agent.Name, agent.ID, agent.TelegramToken) + + // Determine chat ID for delivery + chatID := "scheduler" + if agent.TelegramToken != "" { + allowedUsers := strings.Split(agent.AllowedUsers, ",") + if len(allowedUsers) > 0 && allowedUsers[0] != "" { + chatID = strings.TrimSpace(allowedUsers[0]) + } + } + log.Printf("[SCHEDULER] Using chatID: %s", chatID) + + // Handle based on event type + if event.EventType == "deliver" { + // DELIVER mode: Send pre-written content directly as agent message (no LLM call) + // This is for pre-scheduled content like Japanese lessons, reminders, etc. + s.deliverScheduledContent(agent, chatID, event.Prompt) + } else { + // ACTION mode: Trigger LLM to perform the scheduled task + // This is for tasks like "write code in 1 hour" + reminderMessage := fmt.Sprintf("[SCHEDULED_REMINDER] %s", event.Prompt) + // Add to history (raw for debugging) + s.addHistoryOnly(agent.ID, "scheduler", "system", reminderMessage) + // Trigger agent to process this message + go s.triggerAgentForReminder(agent.ID, chatID, reminderMessage) + } + } + } +} + +// deliverScheduledContent sends pre-written content directly as an agent message +// Used for "deliver" type events where the content was pre-written by the agent +func (s *Server) deliverScheduledContent(agent *db.Agent, chatID, content string) { + log.Printf("[SCHEDULER] DELIVER: Broadcasting to agent %d, chatID=%s, content=%s", agent.ID, chatID, content) + + // Store as reminder in history + s.addHistoryOnly(agent.ID, chatID, "reminder", content) + + // Broadcast with reminder role so Flutter can style it differently (terminal-like) + s.broadcastReminderMessage(agent.ID, chatID, content) + + log.Printf("[SCHEDULER] DELIVER: Broadcast complete, WebSocket clients=%d", len(s.wsClients)) + + // Send to Telegram if configured + if agent.TelegramToken != "" { + bot := telegram.NewBot(agent.TelegramToken) + bot.SendMessage(chatID, content) + } +} + +// triggerAgentForReminder sends the reminder to the agent for processing +func (s *Server) triggerAgentForReminder(agentID int64, chatID, message string) { + log.Printf("[SCHEDULER] Triggering agent %d for reminder processing", agentID) + + // Get LLM client for agent + agent, err := s.db.GetAgent(agentID) + if err != nil { + log.Printf("[SCHEDULER] Failed to get agent: %v", err) + return + } + + client := s.getLLMClientForAgent(agent) + if client == nil { + log.Printf("[SCHEDULER] No LLM client configured for agent %d", agentID) + return + } + + // Build messages with system prompt and reminder + // Also inject explicit instruction to NOT re-schedule + reminderDirective := "\n\nāš ļø CRITICAL: This is a SCHEDULED REMINDER that was already scheduled by you. ABSOLUTELY DO NOT create any or tags in your response. Doing so will cause duplicate/flooded reminders. Simply respond naturally with a tag." + messages := s.buildAgentMessages(agent, message+reminderDirective) + + // Get LLM response + response, err := client.Chat(agent.Model, messages) + if err != nil { + log.Printf("[SCHEDULER] LLM error: %v", err) + return + } + + // Parse and process response + parsed := parser.ParseLLMOutput(response) + + // Store parsed message in history (display content without XML tags) + // and broadcast to clients + if parsed.Message != "" { + s.addHistoryOnly(agent.ID, chatID, "assistant", parsed.Message) + // Broadcast ONLY the parsed message content to HermitChat UI (no tags) + s.broadcastAgentMessage(agent.ID, chatID, parsed.Message) + + // Send to Telegram if configured + if agent.TelegramToken != "" { + bot := telegram.NewBot(agent.TelegramToken) + bot.SendMessage(chatID, parsed.Message) + } + + // Execute any XML actions (terminal, give, etc.) + feedback := s.ExecuteXMLPayload(agent.ID, chatID, response, nil) + if len(feedback) > 0 { + feedbackMsg := formatSystemExecutionResponse(parser.ParseLLMOutput(response), feedback) + s.addHistoryOnly(agent.ID, "system", "system", feedbackMsg) + } + } else { + log.Printf("[SCHEDULED_REMINDER] Agent response missing tag, sending fallback") + fallback := "šŸ”” Reminder: " + message + s.addHistoryAndBroadcast(agent.ID, chatID, "assistant", fallback) + if agent.TelegramToken != "" { + bot := telegram.NewBot(agent.TelegramToken) + bot.SendMessage(chatID, fallback) + } + } +} + +// buildAgentMessages constructs the message list for agent processing +func (s *Server) buildAgentMessages(agent *db.Agent, userMessage string) []llm.Message { + var messages []llm.Message + + // Load system prompt from context.md + systemPrompt := agent.Personality + if content, err := os.ReadFile("./context.md"); err == nil { + contextStr := strings.TrimSpace(string(content)) + if contextStr != "" { + contextStr = strings.ReplaceAll(contextStr, "{{AGENT_NAME}}", agent.Name) + contextStr = strings.ReplaceAll(contextStr, "{{AGENT_ROLE}}", agent.Role) + contextStr = strings.ReplaceAll(contextStr, "{{AGENT_PERSONALITY}}", agent.Personality) + systemPrompt = contextStr + } + } + + messages = append(messages, llm.Message{Role: "system", Content: systemPrompt}) + + // Add current time info + currentTime := s.db.GetSystemTime() + userMessageWithTime := fmt.Sprintf("[System time: %s]\n\n%s", currentTime.Format("2006-01-02 15:04:05"), userMessage) + messages = append(messages, llm.Message{Role: "user", Content: userMessageWithTime}) + + // Add recent history for context (skip scheduler-triggered messages) + history, _ := s.db.GetHistory(agent.ID, 10) + for i := len(history) - 1; i >= 0; i-- { + h := history[i] + // Skip scheduler messages to prevent context pollution + if h.UserID == "scheduler" { + continue + } + role := h.Role + if role == "system" { + role = "user" + } + messages = append(messages, llm.Message{Role: role, Content: h.Content}) + } + + return messages +} + type LogWithAgent struct { ID int64 `json:"id"` AgentID int64 `json:"agent_id"` @@ -1284,9 +1663,17 @@ func (s *Server) HandleCreateAgent(c *fiber.Ctx) error { } // Create agent-specific skills folder with context.md + // Initialize with global context.md as base, personality will be prepended in system prompt agentSkillsPath := fmt.Sprintf("data/agents/%d/skills", id) os.MkdirAll(agentSkillsPath, 0755) - os.WriteFile(filepath.Join(agentSkillsPath, "context.md"), []byte(a.Personality), 0644) + + // Copy global context.md as base for agent-specific context + if globalContext, err := os.ReadFile("./context.md"); err == nil { + os.WriteFile(filepath.Join(agentSkillsPath, "context.md"), globalContext, 0644) + } else { + // Fallback: empty context (will use global) + os.WriteFile(filepath.Join(agentSkillsPath, "context.md"), []byte(""), 0644) + } // Create and start Docker container for the agent go func() { @@ -1646,6 +2033,7 @@ func (s *Server) handleLocalCommand(c *fiber.Ctx, agent *db.Agent, userID, comma } case "/reset": + log.Printf("[SLASH] Processing /reset command for agent %s", agent.Name) if agent.ContainerID != "" && s.docker != nil { s.docker.Stop(agent.ContainerID) s.docker.Remove(agent.ContainerID) @@ -1656,35 +2044,30 @@ func (s *Server) handleLocalCommand(c *fiber.Ctx, agent *db.Agent, userID, comma err := s.docker.Run(agent.ContainerID, image, true) if err != nil { response = fmt.Sprintf("āŒ Failed to reset container: %v", err) + log.Printf("[SLASH] /reset failed: %v", err) } else { response = "āœ… Container reset successfully" s.db.LogAction(agent.ID, "docker", "container_reset", "Container reset from mobile client") + log.Printf("[SLASH] /reset completed successfully") } } else { response = "āŒ No container configured for this agent" + log.Printf("[SLASH] /reset: no container configured") } case "/clear": - // Clear chat history + log.Printf("[SLASH] Processing /clear command for agent %s", agent.Name) + // Clear chat history (NOT context.md - that contains important instructions) s.db.ClearHistory(agent.ID) s.broadcastConversationCleared(agent.ID) + log.Printf("[SLASH] /clear: history cleared, broadcast sent") - // Reset context to default (agent's personality as initial context) - contextPath := fmt.Sprintf("data/agents/%d/skills/context.md", agent.ID) - if err := os.WriteFile(contextPath, []byte(agent.Personality), 0644); err != nil { - response = "āœ… Chat history cleared, but failed to reset context" - s.addHistoryAndBroadcast(agent.ID, userID, "user", command) - s.addHistoryAndBroadcast(agent.ID, "system", "system", processedLog) - s.addHistoryAndBroadcast(agent.ID, "system", "system", response) - s.db.LogAction(agent.ID, "system", "slash_command", processedLog+" (context reset failed)") - return c.JSON(fiber.Map{"message": response, "role": "system"}) - } - - response = "āœ… Chat history cleared and context window reset to default" + response = "āœ… Chat history cleared. Context window preserved." s.addHistoryAndBroadcast(agent.ID, userID, "user", command) s.addHistoryAndBroadcast(agent.ID, "system", "system", processedLog) s.addHistoryAndBroadcast(agent.ID, "system", "system", response) s.db.LogAction(agent.ID, "system", "slash_command", processedLog) + log.Printf("[SLASH] /clear: completed, returning: %s", response) return c.JSON(fiber.Map{"message": response, "role": "system"}) default: @@ -1696,6 +2079,7 @@ func (s *Server) handleLocalCommand(c *fiber.Ctx, agent *db.Agent, userID, comma s.addHistoryAndBroadcast(agent.ID, "system", "system", response) s.db.LogAction(agent.ID, "system", "slash_command", processedLog) + log.Printf("[SLASH] Returning response: message='%s', role='system'", response) return c.JSON(fiber.Map{"message": response, "role": "system"}) } @@ -1810,24 +2194,45 @@ func (s *Server) HandleGetAgentContextWindow(c *fiber.Ctx) error { history, _ := s.db.GetHistory(id, 500) - contextPath := fmt.Sprintf("data/agents/%d/skills/context.md", id) - contextData, _ := os.ReadFile(contextPath) + // Build system prompt like the chat handler does - ALWAYS use global context.md + systemPrompt := agent.Personality + + // Load global context.md from HermitShell root directory + if content, err := os.ReadFile("./context.md"); err == nil { + contextStr := strings.TrimSpace(string(content)) + if contextStr != "" { + contextStr = strings.ReplaceAll(contextStr, "{{AGENT_NAME}}", agent.Name) + contextStr = strings.ReplaceAll(contextStr, "{{AGENT_ROLE}}", agent.Role) + contextStr = strings.ReplaceAll(contextStr, "{{AGENT_PERSONALITY}}", agent.Personality) + systemPrompt = contextStr + } + } + + // Fetch skills for the agent + skills, _ := s.db.ListSkills() + var skillsList []map[string]interface{} + for _, sk := range skills { + if sk.AgentID == 0 || sk.AgentID == id { + skillsList = append(skillsList, map[string]interface{}{ + "title": sk.Title, + "content": sk.Content, + }) + } + } - var historyList []map[string]string + var historyList []map[string]interface{} for _, h := range history { - historyList = append(historyList, map[string]string{ - "role": h.Role, - "content": h.Content, + historyList = append(historyList, map[string]interface{}{ + "role": h.Role, + "content": h.Content, + "timestamp": h.CreatedAt, }) } - globalContextPath := "./context.md" - globalContextData, _ := os.ReadFile(globalContextPath) - return c.JSON(fiber.Map{ - "systemPrompt": string(contextData), - "globalContext": string(globalContextData), + "systemPrompt": systemPrompt, // Full system prompt with variables substituted "agentPersonality": agent.Personality, + "skills": skillsList, "history": historyList, }) } @@ -1848,9 +2253,62 @@ func (s *Server) HandleGetLastMessage(c *fiber.Ctx) error { return c.JSON(fiber.Map{"content": lastMessage.Content}) } +// HandleGetAgentHistory returns the full conversation history for an agent. +func (s *Server) HandleGetAgentHistory(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid agent id"}) + } + + // Get limit from query param, default to 100 + limit := 100 + if limitStr := c.Query("limit"); limitStr != "" { + if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 { + limit = parsed + } + } + + history, err := s.db.GetHistory(id, limit) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + // Return history in chronological order (oldest first) + // GetHistory returns newest first, so we reverse + for i, j := 0, len(history)-1; i < j; i, j = i+1, j-1 { + history[i], history[j] = history[j], history[i] + } + + return c.JSON(history) +} + // HandleGetUnreadCount returns the unread message count for an agent. func (s *Server) HandleGetUnreadCount(c *fiber.Ctx) error { - return c.JSON(fiber.Map{"unread": 0}) + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid agent id"}) + } + + count, err := s.db.GetUnseenCount(id) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{"unread": count}) +} + +// HandleMarkMessagesSeen marks all messages for an agent as seen. +func (s *Server) HandleMarkMessagesSeen(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid agent id"}) + } + + if err := s.db.MarkHistorySeen(id); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{"success": true}) } func (s *Server) HandleListSkills(c *fiber.Ctx) error { @@ -2127,8 +2585,17 @@ func (s *Server) HandleGetSettings(c *fiber.Ctx) error { openaiKey, _ := s.db.GetSetting("openai_api_key") anthropicKey, _ := s.db.GetSetting("anthropic_api_key") geminiKey, _ := s.db.GetSetting("gemini_api_key") - timezone, _ := s.db.GetSetting("timezone") - timeOffset, _ := s.db.GetSetting("time_offset") + + // Mask API keys for security - only return first 4 and last 4 characters + maskKey := func(key string) string { + if key == "" { + return "" + } + if len(key) <= 8 { + return "****" + } + return key[:4] + "..." + key[len(key)-4:] + } tunnelURL := s.tunnels.GetURL("dashboard") isHealthy := s.tunnels.CheckTunnelHealth("dashboard", 2*time.Second) @@ -2150,17 +2617,16 @@ func (s *Server) HandleGetSettings(c *fiber.Ctx) error { } } + // Return masked key values so client can show whether keys are set return c.JSON(fiber.Map{ "tunnelEnabled": tunnelEnabled == "true", "tunnelURL": tunnelURL, "tunnelHealthy": isHealthy, "status": status, - "openrouterKey": openrouterKey != "", - "openaiKey": openaiKey != "", - "anthropicKey": anthropicKey != "", - "geminiKey": geminiKey != "", - "timezone": timezone, - "timeOffset": timeOffset, + "openrouterKey": maskKey(openrouterKey), + "openaiKey": maskKey(openaiKey), + "anthropicKey": maskKey(anthropicKey), + "geminiKey": maskKey(geminiKey), "hasLLMKey": openrouterKey != "" || openaiKey != "" || anthropicKey != "" || geminiKey != "", }) } @@ -2212,8 +2678,6 @@ func (s *Server) HandleSetSettings(c *fiber.Ctx) error { OpenaiKey *string `json:"openaiKey"` AnthropicKey *string `json:"anthropicKey"` GeminiKey *string `json:"geminiKey"` - Timezone *string `json:"timezone"` - TimeOffset *string `json:"timeOffset"` } c.BodyParser(&req) @@ -2242,38 +2706,30 @@ func (s *Server) HandleSetSettings(c *fiber.Ctx) error { if req.GeminiKey != nil { s.db.SetSetting("gemini_api_key", strings.TrimSpace(*req.GeminiKey)) } - if req.Timezone != nil { - s.db.SetSetting("timezone", strings.TrimSpace(*req.Timezone)) - } - if req.TimeOffset != nil { - s.db.SetSetting("time_offset", strings.TrimSpace(*req.TimeOffset)) - } + + // Timezone/time_offset settings removed - see docs/time-handling.md + // All times are now handled as-is, interpreted by client in local timezone return c.JSON(fiber.Map{"success": true}) } -// HandleGetTime returns the current time with user's offset applied. -// Docs: See docs/time-management.md for how offset is calculated and applied. -// Purpose: Allows displaying time in user's desired timezone regardless of server location. +// HandleGetTime returns the current server time. +// Time is now handled as-is - no artificial offsets. +// All datetime inputs are interpreted as the user's local time. func (s *Server) HandleGetTime(c *fiber.Ctx) error { - timezone, _ := s.db.GetSetting("timezone") - timeOffset, _ := s.db.GetSetting("time_offset") - - // Get current system time (already has offset applied via db.GetSystemTime) - systemTime := s.db.GetSystemTime() - // Get raw UTC time so clients can calculate their own offsets/previews accurately - rawUtcTime := time.Now().UTC() + // Return actual server time - no offset manipulation + // Clients should use their device's local timezone + currentTime := time.Now() + utcTime := currentTime.UTC() return c.JSON(fiber.Map{ - "time": systemTime.Format("03:04:05 PM"), - "time12": systemTime.Format("3:04 PM"), - "date": systemTime.Format("Mon, Jan 2"), - "fullDate": systemTime.Format("2006-01-02"), - "datetime": systemTime.Format(time.RFC3339), - "systemTime": systemTime.Format(time.RFC3339), - "serverUtcTime": rawUtcTime.Format(time.RFC3339), - "timezone": timezone, - "offset": timeOffset, + "time": currentTime.Format("03:04:05 PM"), + "time12": currentTime.Format("3:04 PM"), + "date": currentTime.Format("Mon, Jan 2"), + "fullDate": currentTime.Format("2006-01-02"), + "datetime": currentTime.Format(time.RFC3339), + "systemTime": currentTime.Format(time.RFC3339), + "serverUtcTime": utcTime.Format(time.RFC3339), }) } @@ -2580,9 +3036,16 @@ func (s *Server) handleAgentCommand(agent *db.Agent, chatID, text string) error switch cmd { case "/status": + config := s.getLLMConfigStatus(agent) statusMsg := fmt.Sprintf("šŸ¤– *Agent Status: %s*\n\n", agent.Name) - statusMsg += fmt.Sprintf("• Model: `%s`\n", agent.Model) - statusMsg += fmt.Sprintf("• Provider: `%s`\n", agent.Provider) + statusMsg += fmt.Sprintf("• Provider: `%s`\n", config.ProviderUI) + statusMsg += fmt.Sprintf("• Model: `%s`\n", firstNonEmpty(config.Model, "Not set")) + statusMsg += fmt.Sprintf("• Model Type: `%s`\n", config.ModelType) + statusMsg += fmt.Sprintf("• API Key: `%s`\n", ternary(config.APIKeySet, "Configured", "Missing")) + statusMsg += fmt.Sprintf("• LLM Ready: `%s`\n", ternary(config.Configured, "Yes", "No")) + if !config.Configured { + statusMsg += fmt.Sprintf("• Missing: `%s`\n", config.missingSummary()) + } statusMsg += fmt.Sprintf("• Context Window: `%d` tokens\n", agent.ContextWindow) statusMsg += fmt.Sprintf("• LLM API Calls: `%d`\n", agent.LLMAPICalls) @@ -2596,7 +3059,6 @@ func (s *Server) handleAgentCommand(agent *db.Agent, chatID, text string) error } statusMsg += fmt.Sprintf("• Container: `%s` (%s)\n", agent.ContainerID, containerStatus) - // Show polling status instead of webhook statusMsg += "• Connection: Long Polling Active āœ…\n" statusMsg += fmt.Sprintf("\nšŸ” *Authorization*\n") @@ -2757,6 +3219,9 @@ func (s *Server) processAgentAIRequest(agent *db.Agent, chatID, userID, userText return } + // Parse the LLM response to extract message content + parsed := parser.ParseLLMOutput(response) + // Log LLM response s.db.LogAction(agent.ID, "agent", "llm_response", fmt.Sprintf("Response: %.200s...", response)) @@ -2765,16 +3230,17 @@ func (s *Server) processAgentAIRequest(agent *db.Agent, chatID, userID, userText tempBot.DeleteMessage(chatID, thinkingMsgID) } - // Log agent response (Full trace) - s.addHistoryAndBroadcast(agent.ID, "assistant", "assistant", response) + // Only broadcast the parsed message content, not raw response + // This ensures tags are kept internal and only content goes to users + s.addHistoryAndBroadcast(agent.ID, "assistant", "assistant", parsed.Message) - // Execute the XML actions from the response + // Execute the XML actions from the response (terminal, give, calendar, etc.) feedback := s.ExecuteXMLPayload(agent.ID, chatID, response, tempBot) - // Commit with and feedback + // Commit with and feedback (use formatted output, not raw JSON) if len(feedback) > 0 { - feedbackJSON, _ := json.Marshal(feedback) - s.addHistoryAndBroadcast(agent.ID, "system", "system", string(feedbackJSON)+"\n") + feedbackMsg := formatSystemExecutionResponse(parsed, feedback) + s.addHistoryAndBroadcast(agent.ID, "system", "system", feedbackMsg+"\n") } } @@ -3256,11 +3722,12 @@ func (s *Server) ExecuteXMLPayload(agentID int64, chatID, xmlInput string, bot * // Delete old and create new (simpler than update) s.db.DeleteCalendarEvent(eventID) _, err := s.db.CreateCalendarEvent(&db.CalendarEvent{ - AgentID: agentID, - Date: newDate, - Time: newTime, - Prompt: newPrompt, - Executed: false, + AgentID: agentID, + Date: newDate, + Time: newTime, + Prompt: newPrompt, + EventType: cal.EventType, + Executed: false, }) if err != nil { feedback = append(feedback, map[string]interface{}{"action": "CALENDAR_UPDATE", "status": "ERROR", "error": err.Error()}) @@ -3272,35 +3739,59 @@ func (s *Server) ExecuteXMLPayload(agentID int64, chatID, xmlInput string, bot * } default: // create - // Parse DateTime into date and time components - dateTime := cal.DateTime + // Support both absolute datetime and relative time (schedule) fields var dateStr, timeStr string - - if dateTime != "" { - if len(dateTime) >= 10 { - dateStr = dateTime[:10] - } - if len(dateTime) >= 19 { - timeStr = dateTime[11:16] - } else if len(dateTime) >= 16 { - timeStr = dateTime[11:16] + var targetTime time.Time + + // Handle relative time via + if cal.ScheduleMinutes > 0 || cal.ScheduleHours > 0 || cal.ScheduleDays > 0 { + // Use actual current time for relative schedules (not offset-adjusted system time) + // The offset is for display purposes only - relative schedules should be "3 minutes from now" + // as perceived by the user, not offset-adjusted time + targetTime = time.Now() + targetTime = targetTime.Add(time.Duration(cal.ScheduleMinutes) * time.Minute) + targetTime = targetTime.Add(time.Duration(cal.ScheduleHours) * time.Hour) + targetTime = targetTime.Add(time.Duration(cal.ScheduleDays) * 24 * time.Hour) + dateStr = targetTime.Format("2006-01-02") + timeStr = targetTime.Format("15:04") + log.Printf("[CALENDAR] Relative schedule (actual time): scheduled for %s %s", dateStr, timeStr) + } else { + // Handle absolute datetime via + dateTime := cal.DateTime + if dateTime != "" { + if len(dateTime) >= 10 { + dateStr = dateTime[:10] + } + if len(dateTime) >= 19 { + timeStr = dateTime[11:16] + } else if len(dateTime) >= 16 { + timeStr = dateTime[11:16] + } } } + // Create the calendar event if we have both date and time if dateStr != "" && timeStr != "" { + eventType := cal.EventType + // Default to "deliver" - send directly to user without LLM call + // Use "action" only when explicitly specified for tasks requiring LLM + if eventType == "" { + eventType = "deliver" + } _, err := s.db.CreateCalendarEvent(&db.CalendarEvent{ - AgentID: agentID, - Date: dateStr, - Time: timeStr, - Prompt: cal.Prompt, - Executed: false, + AgentID: agentID, + Date: dateStr, + Time: timeStr, + Prompt: cal.Prompt, + EventType: eventType, + Executed: false, }) if err != nil { s.db.AddHistory(agentID, "system", "system", fmt.Sprintf("Calendar Event Failed: %v", err)) feedback = append(feedback, map[string]interface{}{"action": "CALENDAR", "status": "ERROR", "error": err.Error()}) } else { - s.db.AddHistory(agentID, "system", "system", fmt.Sprintf("Calendar Event Scheduled: %s %s - %s\n", dateStr, timeStr, cal.Prompt)) - feedback = append(feedback, map[string]interface{}{"action": "CALENDAR", "status": "SUCCESS"}) + s.db.AddHistory(agentID, "system", "system", fmt.Sprintf("Calendar Event Scheduled: %s %s [%s] - %s\n", dateStr, timeStr, eventType, cal.Prompt)) + feedback = append(feedback, map[string]interface{}{"action": "CALENDAR", "status": "SUCCESS", "event_type": eventType}) } } else if cal.Prompt != "" { s.db.AddHistory(agentID, "system", "system", fmt.Sprintf("Calendar Event Failed: Invalid date/time\n")) @@ -3669,3 +4160,22 @@ func (s *Server) BroadcastMessage(msg string) { func (s *Server) addHistoryAndBroadcast(agentID int64, userID, role, content string) { s.addHistoryAndBroadcastWithFiles(agentID, userID, role, content, nil) } + +func (s *Server) addHistoryOnly(agentID int64, userID, role, content string) { + s.db.AddHistory(agentID, userID, role, content) +} + +func (s *Server) addHistoryAndBroadcastWithRejection(agentID int64, userID, role, content string, isRejected bool) { + s.db.AddHistoryWithRejection(agentID, userID, role, content, isRejected) + + payload := map[string]interface{}{ + "type": "new_message", + "agent_id": agentID, + "user_id": userID, + "role": role, + "content": content, + } + if jsonMsg, err := json.Marshal(payload); err == nil { + s.BroadcastMessage(string(jsonMsg)) + } +} diff --git a/internal/api/takeover_test.go b/internal/api/takeover_test.go new file mode 100644 index 0000000..a4ce2b2 --- /dev/null +++ b/internal/api/takeover_test.go @@ -0,0 +1,561 @@ +package api + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/JohnEsleyer/HermitShell/internal/db" + "github.com/JohnEsleyer/HermitShell/internal/parser" + "github.com/gofiber/fiber/v2" +) + +// Reference: docs/takeover-mode.md for takeover mode documentation + +func TestTakeoverModeRejectsXMLWhenOff(t *testing.T) { + app := fiber.New() + + // Create test database + testDB, err := db.NewDB(":memory:") + if err != nil { + t.Fatalf("failed to create test db: %v", err) + } + defer testDB.Close() + + // Create test agent + agent := &db.Agent{ + ID: 1, + Name: "TestAgent", + Role: "assistant", + Personality: "You are a test agent", + Provider: "gemini", + Model: "gemini-2.0-flash", + AllowedUsers: "", + } + testDB.CreateAgent(agent) + + // Create server with test dependencies + server := &Server{ + db: testDB, + takeoverMode: make(map[string]bool), + } + + app.Post("/api/agents/:id/chat", server.HandleAgentChat) + + // Test: XML command rejected when takeover is OFF + req := httptest.NewRequest("POST", "/api/agents/1/chat", strings.NewReader(`{"message": "ls"}`)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + + if resp.StatusCode != 200 { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + // Should return rejection message + if result["rejected"] != true { + t.Error("expected rejected=true for XML command when takeover is off") + } + if !strings.Contains(result["message"].(string), "not allowed") { + t.Errorf("expected rejection message, got: %v", result["message"]) + } +} + +func TestTakeoverModeAllowsXMLWhenOn(t *testing.T) { + app := fiber.New() + + // Create test database + testDB, err := db.NewDB(":memory:") + if err != nil { + t.Fatalf("failed to create test db: %v", err) + } + defer testDB.Close() + + // Create test agent + agent := &db.Agent{ + ID: 1, + Name: "TestAgent", + Role: "assistant", + Personality: "You are a test agent", + Provider: "gemini", + Model: "gemini-2.0-flash", + AllowedUsers: "", + } + testDB.CreateAgent(agent) + + // Create server with takeover ON + server := &Server{ + db: testDB, + takeoverMode: map[string]bool{"mobile-chat": true}, // Takeover ON + } + + app.Post("/api/agents/:id/chat", server.HandleAgentChat) + + // Test: XML command allowed when takeover is ON + req := httptest.NewRequest("POST", "/api/agents/1/chat", strings.NewReader(`{"message": "Hello"}`)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + + if resp.StatusCode != 200 { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } +} + +func TestSlashCommandDoesNotTriggerLLM(t *testing.T) { + app := fiber.New() + + // Create test database + testDB, err := db.NewDB(":memory:") + if err != nil { + t.Fatalf("failed to create test db: %v", err) + } + defer testDB.Close() + + // Create test agent + agent := &db.Agent{ + ID: 1, + Name: "TestAgent", + Role: "assistant", + Personality: "You are a test agent", + Provider: "gemini", + Model: "gemini-2.0-flash", + AllowedUsers: "", + } + testDB.CreateAgent(agent) + + server := &Server{ + db: testDB, + takeoverMode: make(map[string]bool), + } + + app.Post("/api/agents/:id/chat", server.HandleAgentChat) + + // Test /clear command + req := httptest.NewRequest("POST", "/api/agents/1/chat", strings.NewReader(`{"message": "/clear"}`)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + + if resp.StatusCode != 200 { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + // Slash command should return system response, not trigger LLM + if result["role"] != "system" { + t.Errorf("expected role=system for slash command, got: %v", result["role"]) + } + if !strings.Contains(result["message"].(string), "cleared") && !strings.Contains(result["message"].(string), "Context") { + t.Errorf("expected clear confirmation, got: %v", result["message"]) + } +} + +func TestSlashStatusCommandReturnsStatus(t *testing.T) { + app := fiber.New() + + testDB, err := db.NewDB(":memory:") + if err != nil { + t.Fatalf("failed to create test db: %v", err) + } + defer testDB.Close() + + agent := &db.Agent{ + ID: 1, + Name: "TestAgent", + Role: "assistant", + Personality: "You are a test agent", + Provider: "gemini", + Model: "gemini-2.0-flash", + AllowedUsers: "", + } + testDB.CreateAgent(agent) + + server := &Server{ + db: testDB, + takeoverMode: make(map[string]bool), + } + + app.Post("/api/agents/:id/chat", server.HandleAgentChat) + + // Test /status command + req := httptest.NewRequest("POST", "/api/agents/1/chat", strings.NewReader(`{"message": "/status"}`)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + if !strings.Contains(result["message"].(string), "Agent Status") { + t.Errorf("expected status message, got: %v", result["message"]) + } + if !strings.Contains(result["message"].(string), "gemini-2.0-flash") { + t.Errorf("expected model name in status, got: %v", result["message"]) + } +} + +func TestSlashResetCommandResetsContainer(t *testing.T) { + app := fiber.New() + + testDB, err := db.NewDB(":memory:") + if err != nil { + t.Fatalf("failed to create test db: %v", err) + } + defer testDB.Close() + + agent := &db.Agent{ + ID: 1, + Name: "TestAgent", + Role: "assistant", + Personality: "You are a test agent", + Provider: "gemini", + Model: "gemini-2.0-flash", + ContainerID: "test-container", + AllowedUsers: "", + } + testDB.CreateAgent(agent) + + server := &Server{ + db: testDB, + takeoverMode: make(map[string]bool), + docker: nil, // No docker client, should handle gracefully + } + + app.Post("/api/agents/:id/chat", server.HandleAgentChat) + + // Test /reset command + req := httptest.NewRequest("POST", "/api/agents/1/chat", strings.NewReader(`{"message": "/reset"}`)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + // Should indicate no container configured or handle gracefully + if result["role"] != "system" { + t.Errorf("expected role=system, got: %v", result["role"]) + } +} + +func TestMessageTagExtraction(t *testing.T) { + testCases := []struct { + name string + input string + expected string + }{ + { + name: "Simple message tag", + input: "Hello World", + expected: "Hello World", + }, + { + name: "Message with surrounding text", + input: "Some text before Hello some text after", + expected: "Hello", + }, + { + name: "Message with thought tags", + input: "Thinking...Response", + expected: "Response", + }, + { + name: "Multiple message tags - takes first", + input: "FirstSecond", + expected: "First", + }, + { + name: "Message with terminal tag", + input: "Donels", + expected: "Done", + }, + { + name: "No message tag - empty", + input: "ls", + expected: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + parsed := parser.ParseLLMOutput(tc.input) + if parsed.Message != tc.expected { + t.Errorf("expected %q, got %q", tc.expected, parsed.Message) + } + }) + } +} + +func TestThoughtTagNotExposed(t *testing.T) { + // Reference: docs/message-processing.md - should be internal + input := `This is internal reasoningVisible response` + parsed := parser.ParseLLMOutput(input) + + // Message should be extracted + if parsed.Message != "Visible response" { + t.Errorf("expected message 'Visible response', got %q", parsed.Message) + } + + // Thought should be extracted but not sent to client + if parsed.Thought != "This is internal reasoning" { + t.Errorf("expected thought to be parsed, got %q", parsed.Thought) + } +} + +func TestGiveActionExtraction(t *testing.T) { + testCases := []struct { + name string + input string + expected []string + }{ + { + name: "Single give", + input: "file.txt", + expected: []string{"file.txt"}, + }, + { + name: "Multiple give", + input: "file1.txtfile2.txt", + expected: []string{"file1.txt", "file2.txt"}, + }, + { + name: "Give with message", + input: "Here is the filereport.pdf", + expected: []string{"report.pdf"}, + }, + { + name: "No give", + input: "Hello", + expected: []string{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + parsed := parser.ParseLLMOutput(tc.input) + if len(parsed.Actions) != len(tc.expected) { + t.Errorf("expected %d actions, got %d", len(tc.expected), len(parsed.Actions)) + } + for i, expectedFile := range tc.expected { + if i >= len(parsed.Actions) { + t.Errorf("missing action at index %d", i) + continue + } + if parsed.Actions[i].Value != expectedFile { + t.Errorf("expected file %q, got %q", expectedFile, parsed.Actions[i].Value) + } + } + }) + } +} + +func TestTerminalActionExtraction(t *testing.T) { + testCases := []struct { + name string + input string + expected string + count int + }{ + { + name: "Single terminal", + input: "ls -la", + expected: "ls -la", + count: 1, + }, + { + name: "Multiple terminals", + input: "cd /appls", + expected: "cd /app", + count: 2, + }, + { + name: "Terminal with message", + input: "Donepwd", + expected: "pwd", + count: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + parsed := parser.ParseLLMOutput(tc.input) + if len(parsed.Terminals) != tc.count { + t.Errorf("expected %d terminals, got %d", tc.count, len(parsed.Terminals)) + } + if parsed.Terminal != tc.expected { + t.Errorf("expected terminal %q, got %q", tc.expected, parsed.Terminal) + } + }) + } +} + +func TestWebSocketMessageFormat(t *testing.T) { + // Reference: docs/frontend-backend-communication.md + message := map[string]interface{}{ + "type": "new_message", + "agent_id": 1, + "user_id": "mobile", + "role": "assistant", + "content": "Hello from agent", + } + + jsonMsg, err := json.Marshal(message) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var decoded map[string]interface{} + json.Unmarshal(jsonMsg, &decoded) + + if decoded["type"] != "new_message" { + t.Errorf("expected type=new_message, got %v", decoded["type"]) + } + if decoded["agent_id"] != float64(1) { + t.Errorf("expected agent_id=1, got %v", decoded["agent_id"]) + } + if decoded["role"] != "assistant" { + t.Errorf("expected role=assistant, got %v", decoded["role"]) + } + if decoded["content"] != "Hello from agent" { + t.Errorf("expected content, got %v", decoded["content"]) + } +} + +func TestConversationClearedBroadcast(t *testing.T) { + message := map[string]interface{}{ + "type": "conversation_cleared", + "agent_id": 1, + } + + jsonMsg, err := json.Marshal(message) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var decoded map[string]interface{} + json.Unmarshal(jsonMsg, &decoded) + + if decoded["type"] != "conversation_cleared" { + t.Errorf("expected type=conversation_cleared, got %v", decoded["type"]) + } + if decoded["agent_id"] != float64(1) { + t.Errorf("expected agent_id=1, got %v", decoded["agent_id"]) + } +} + +func TestRejectionFlagFormat(t *testing.T) { + // Reference: docs/message-processing.md - isRejected column + message := map[string]interface{}{ + "message": "XML commands are not allowed when takeover mode is off", + "role": "system", + "rejected": true, + } + + jsonMsg, err := json.Marshal(message) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var decoded map[string]interface{} + json.Unmarshal(jsonMsg, &decoded) + + if decoded["rejected"] != true { + t.Errorf("expected rejected=true, got %v", decoded["rejected"]) + } + if decoded["role"] != "system" { + t.Errorf("expected role=system, got %v", decoded["role"]) + } +} + +func TestEncryptedMessageHandling(t *testing.T) { + // Test that encrypted messages start with "enc:" + testMessage := "enc:AESGCMSomeEncryptedDataHere==" + + if !strings.HasPrefix(testMessage, "enc:") { + t.Error("encrypted message should start with enc:") + } + + // After stripping enc: prefix, it should be base64 + ciphertext := strings.TrimPrefix(testMessage, "enc:") + if len(ciphertext) == 0 { + t.Error("ciphertext should not be empty") + } +} + +func TestHistoryEntryWithRejection(t *testing.T) { + // Test that history entries can track rejection + testCases := []struct { + name string + isRejected bool + }{ + {"rejected entry", true}, + {"normal entry", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + entry := db.HistoryEntry{ + ID: 1, + AgentID: 1, + UserID: "mobile", + Role: "user", + Content: "ls", + IsRejected: tc.isRejected, + } + + if entry.IsRejected != tc.isRejected { + t.Errorf("expected IsRejected=%v, got %v", tc.isRejected, entry.IsRejected) + } + }) + } +} + +func TestHistoryEntryWithSeen(t *testing.T) { + // Test that history entries track seen status + testCases := []struct { + name string + isSeen bool + }{ + {"unseen message", false}, + {"seen message", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + entry := db.HistoryEntry{ + ID: 1, + AgentID: 1, + UserID: "mobile", + Role: "assistant", + Content: "Hello", + IsSeen: tc.isSeen, + } + + if entry.IsSeen != tc.isSeen { + t.Errorf("expected IsSeen=%v, got %v", tc.isSeen, entry.IsSeen) + } + }) + } +} diff --git a/internal/api/testdata/hermitchat-test-image.jpg b/internal/api/testdata/hermitchat-test-image.jpg index 93fe465..760f2dc 100644 Binary files a/internal/api/testdata/hermitchat-test-image.jpg and b/internal/api/testdata/hermitchat-test-image.jpg differ diff --git a/internal/api/testdata/hermitchat-test-video.mp4 b/internal/api/testdata/hermitchat-test-video.mp4 index 7936dc0..e8df241 100644 Binary files a/internal/api/testdata/hermitchat-test-video.mp4 and b/internal/api/testdata/hermitchat-test-video.mp4 differ diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 33fd6bc..53d4e89 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -8,62 +8,129 @@ import ( "encoding/base64" "fmt" "io" + "strings" ) -// DeriveKey creates a 32-byte key from a password using SHA-256. +const BlockSize = 16 + func DeriveKey(password string) []byte { hash := sha256.Sum256([]byte(password)) return hash[:] } -// Encrypt encrypts plaintext using AES-GCM with the provided key. +func pkcs7Pad(data []byte) []byte { + padLen := BlockSize - (len(data) % BlockSize) + pad := make([]byte, padLen) + for i := range pad { + pad[i] = byte(padLen) + } + return append(data, pad...) +} + +func pkcs7Unpad(data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, fmt.Errorf("empty data") + } + padLen := int(data[len(data)-1]) + if padLen > BlockSize || padLen == 0 { + return nil, fmt.Errorf("invalid padding") + } + if len(data) < padLen { + return nil, fmt.Errorf("invalid padding length") + } + for i := 0; i < padLen; i++ { + if data[len(data)-1-i] != byte(padLen) { + return nil, fmt.Errorf("invalid padding") + } + } + return data[:len(data)-padLen], nil +} + +// Encrypt encrypts using AES-CBC with PKCS7 padding. +// Output format: base64(IV + ciphertext) func Encrypt(plaintext string, key []byte) (string, error) { block, err := aes.NewCipher(key) if err != nil { return "", err } - gcm, err := cipher.NewGCM(block) - if err != nil { + iv := make([]byte, BlockSize) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { return "", err } - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return "", err - } + padded := pkcs7Pad([]byte(plaintext)) + ciphertext := make([]byte, len(padded)) + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(ciphertext, padded) - ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) - return base64.StdEncoding.EncodeToString(ciphertext), nil + return base64.StdEncoding.EncodeToString(append(iv, ciphertext...)), nil } -// Decrypt decrypts base64 encoded ciphertext using AES-GCM with the provided key. +// Decrypt handles both old (GCM) and new (CBC) encryption formats. +// Old format: "enc:" prefix (GCM with 12-byte nonce) +// New format: "cbc:" prefix (CBC with PKCS7 padding) func Decrypt(cryptoText string, key []byte) (string, error) { - ciphertext, err := base64.StdEncoding.DecodeString(cryptoText) - if err != nil { - return "", err - } + data := cryptoText - block, err := aes.NewCipher(key) - if err != nil { - return "", err + // Extract prefix + if strings.HasPrefix(cryptoText, "cbc:") { + data = cryptoText[4:] + } else if strings.HasPrefix(cryptoText, "enc:") { + // Check if it's old GCM format or new format without prefix + decoded, err := base64.StdEncoding.DecodeString(data[4:]) + if err != nil { + // Try treating as raw base64 (no prefix) + decoded, err = base64.StdEncoding.DecodeString(cryptoText) + if err != nil { + return "", fmt.Errorf("invalid base64") + } + return decryptCBC(decoded, key) + } + // If decoded data is less than 28 bytes, it's likely old GCM (12 IV + small ciphertext) + if len(decoded) < 28 { + return "", fmt.Errorf("legacy GCM format no longer supported, please clear chat history") + } + // Otherwise it's new CBC format + return decryptCBC(decoded, key) + } else { + // No prefix, try as raw base64 + decoded, err := base64.StdEncoding.DecodeString(cryptoText) + if err != nil { + return "", fmt.Errorf("invalid base64") + } + return decryptCBC(decoded, key) } - gcm, err := cipher.NewGCM(block) + decoded, err := base64.StdEncoding.DecodeString(data) if err != nil { - return "", err + return "", fmt.Errorf("invalid base64: %w", err) } - nonceSize := gcm.NonceSize() - if len(ciphertext) < nonceSize { + return decryptCBC(decoded, key) +} + +func decryptCBC(data []byte, key []byte) (string, error) { + if len(data) < BlockSize { return "", fmt.Errorf("ciphertext too short") } - nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + iv := data[:BlockSize] + ciphertext := data[BlockSize:] + + block, err := aes.NewCipher(key) if err != nil { return "", err } - return string(plaintext), nil + plaintext := make([]byte, len(ciphertext)) + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(plaintext, ciphertext) + + unpadded, err := pkcs7Unpad(plaintext) + if err != nil { + return "", fmt.Errorf("CBC decryption failed: %w", err) + } + + return string(unpadded), nil } diff --git a/internal/db/db.go b/internal/db/db.go index 7d76c34..33c9fbd 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -8,11 +8,14 @@ package db import ( + "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "fmt" + "log" "strconv" + "strings" "time" "github.com/JohnEsleyer/HermitShell/internal/crypto" @@ -76,6 +79,8 @@ type HistoryEntry struct { Role string `json:"role"` Content string `json:"content"` IsProcessed bool `json:"is_processed"` + IsSeen bool `json:"is_seen"` + IsRejected bool `json:"is_rejected"` CreatedAt string `json:"created_at"` } @@ -210,11 +215,21 @@ func (d *DB) migrate() error { date TEXT NOT NULL, time TEXT NOT NULL DEFAULT '', prompt TEXT NOT NULL, + event_type TEXT NOT NULL DEFAULT 'action', executed INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY(agent_id) REFERENCES agents(id) ); + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(user_id) REFERENCES users(id) + ); + CREATE TABLE IF NOT EXISTS apps ( id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id INTEGER NOT NULL, @@ -235,6 +250,16 @@ func (d *DB) migrate() error { return fmt.Errorf("failed to add is_processed column: %w", err) } + // Migration: Add is_seen column for unread tracking + if err := d.addColumnIfNotExists("history", "is_seen", "INTEGER NOT NULL DEFAULT 1"); err != nil { + return fmt.Errorf("failed to add is_seen column: %w", err) + } + + // Migration: Add is_rejected column for tracking rejected XML commands + if err := d.addColumnIfNotExists("history", "is_rejected", "INTEGER NOT NULL DEFAULT 0"); err != nil { + return fmt.Errorf("failed to add is_rejected column: %w", err) + } + // Add new columns to existing databases if they don't exist if err := d.addColumnIfNotExists("agents", "llm_api_calls", "INTEGER NOT NULL DEFAULT 0"); err != nil { return err @@ -255,6 +280,11 @@ func (d *DB) migrate() error { return err } + // Migration: Add event_type column to calendar_events for CRON-like scheduling + if err := d.addColumnIfNotExists("calendar_events", "event_type", "TEXT NOT NULL DEFAULT 'action'"); err != nil { + return fmt.Errorf("failed to add event_type column: %w", err) + } + return nil } @@ -453,6 +483,88 @@ func (d *DB) GetUserByID(id int64) (string, bool, error) { return username, mustChange == 1, nil } +// CreateSession creates a new secure session token for the user. +// Returns the token string on success. +func (d *DB) CreateSession(userID int64, expiryHours int) (string, error) { + // Generate 32-byte random token using crypto/rand + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + return "", fmt.Errorf("failed to generate token: %w", err) + } + token := hex.EncodeToString(tokenBytes) + + // Calculate expiry time + expiresAt := time.Now().Add(time.Duration(expiryHours) * time.Hour) + + // Delete any existing sessions for this user (single session per user) + d.db.Exec("DELETE FROM sessions WHERE user_id = ?", userID) + + // Insert new session + _, err := d.db.Exec(` + INSERT INTO sessions (token, user_id, expires_at) + VALUES (?, ?, ?) + `, token, userID, expiresAt.Format(time.RFC3339)) + + if err != nil { + return "", fmt.Errorf("failed to create session: %w", err) + } + + log.Printf("[AUTH] Created session for user_id=%d, expires=%s", userID, expiresAt.Format(time.RFC3339)) + return token, nil +} + +// ValidateSession checks if a token is valid and returns the user ID. +// Returns (userID, mustChangePassword, error) +func (d *DB) ValidateSession(token string) (int64, bool, error) { + if token == "" { + return 0, false, fmt.Errorf("empty token") + } + + var userID int64 + var expiresAt string + err := d.db.QueryRow(` + SELECT user_id, expires_at FROM sessions WHERE token = ? + `, token).Scan(&userID, &expiresAt) + + if err == sql.ErrNoRows { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + + // Check if session has expired + expiry, err := time.Parse(time.RFC3339, expiresAt) + if err != nil { + return 0, false, fmt.Errorf("invalid expiry format: %w", err) + } + if time.Now().After(expiry) { + // Delete expired session + d.db.Exec("DELETE FROM sessions WHERE token = ?", token) + return 0, false, nil + } + + // Get user info + _, mustChange, err := d.GetUserByID(userID) + return userID, mustChange, err +} + +// DeleteSession removes a session by token. +func (d *DB) DeleteSession(token string) error { + _, err := d.db.Exec("DELETE FROM sessions WHERE token = ?", token) + return err +} + +// CleanupExpiredSessions removes all expired sessions. +func (d *DB) CleanupExpiredSessions() (int, error) { + result, err := d.db.Exec("DELETE FROM sessions WHERE expires_at < ?", time.Now().Format(time.RFC3339)) + if err != nil { + return 0, err + } + rows, _ := result.RowsAffected() + return int(rows), nil +} + // LogAction inserts an audit log entry for tracking system events. // Docs: See docs/logging.md for log categories and usage patterns. func (d *DB) LogAction(agentID int64, userID, action, details string) error { @@ -515,11 +627,34 @@ func (d *DB) GetAllAuditLogs(category string, limit int) ([]*AuditLog, error) { } return logs, nil } + +// AddHistory stores a message in history. +// System messages are NOT encrypted - only user and assistant messages are encrypted. +// Ref: docs/message-processing.md func (d *DB) AddHistory(agentID int64, userID, role, content string) error { - if d.cryptoKey != nil { + // Filter out empty, whitespace-only, or invalid content for assistant messages + roleIsAssistant := role == "assistant" || role == "user" + if roleIsAssistant { + trimmed := strings.TrimSpace(content) + // Skip empty or minimal invalid responses + if trimmed == "" || trimmed == "." || trimmed == "..." { + return nil + } + // Skip content that's just FILE metadata (file delivery info) + if strings.HasPrefix(trimmed, "[FILE:") || strings.HasPrefix(trimmed, "FILE:") { + return nil + } + // Skip content that's only whitespace/control characters + if len(trimmed) < 2 && len(content) < 3 { + return nil + } + } + + // Only encrypt user and assistant messages, NOT system messages + if d.cryptoKey != nil && role != "system" { encrypted, err := crypto.Encrypt(content, d.cryptoKey) if err == nil { - content = "enc:" + encrypted + content = "cbc:" + encrypted // Use CBC prefix for new encryptions } } _, err := d.db.Exec(` @@ -529,9 +664,45 @@ func (d *DB) AddHistory(agentID int64, userID, role, content string) error { return err } +func (d *DB) AddHistoryWithRejection(agentID int64, userID, role, content string, isRejected bool) error { + // Filter out empty, whitespace-only, or invalid content for assistant messages + roleIsAssistant := role == "assistant" || role == "user" + if roleIsAssistant { + trimmed := strings.TrimSpace(content) + // Skip empty or minimal invalid responses + if trimmed == "" || trimmed == "." || trimmed == "..." { + return nil + } + // Skip content that's just FILE metadata (file delivery info) + if strings.HasPrefix(trimmed, "[FILE:") || strings.HasPrefix(trimmed, "FILE:") { + return nil + } + // Skip content that's only whitespace/control characters + if len(trimmed) < 2 && len(content) < 3 { + return nil + } + } + + if d.cryptoKey != nil && role != "system" { + encrypted, err := crypto.Encrypt(content, d.cryptoKey) + if err == nil { + content = "cbc:" + encrypted // Use CBC prefix for new encryptions + } + } + rejectedVal := 0 + if isRejected { + rejectedVal = 1 + } + _, err := d.db.Exec(` + INSERT INTO history (agent_id, user_id, role, content, is_rejected) + VALUES (?, ?, ?, ?, ?) + `, agentID, userID, role, content, rejectedVal) + return err +} + func (d *DB) GetHistory(agentID int64, limit int) ([]*HistoryEntry, error) { rows, err := d.db.Query(` - SELECT id, agent_id, user_id, role, content, created_at + SELECT id, agent_id, user_id, role, content, is_processed, is_seen, is_rejected, created_at FROM history WHERE agent_id = ? ORDER BY id DESC LIMIT ? `, agentID, limit) if err != nil { @@ -542,13 +713,33 @@ func (d *DB) GetHistory(agentID int64, limit int) ([]*HistoryEntry, error) { var entries []*HistoryEntry for rows.Next() { e := &HistoryEntry{} - if err := rows.Scan(&e.ID, &e.AgentID, &e.UserID, &e.Role, &e.Content, &e.CreatedAt); err != nil { + var isProcessed, isSeen, isRejected int + if err := rows.Scan(&e.ID, &e.AgentID, &e.UserID, &e.Role, &e.Content, &isProcessed, &isSeen, &isRejected, &e.CreatedAt); err != nil { return nil, err } - if d.cryptoKey != nil && len(e.Content) > 4 && e.Content[:4] == "enc:" { - decrypted, err := crypto.Decrypt(e.Content[4:], d.cryptoKey) - if err == nil { - e.Content = decrypted + e.IsProcessed = isProcessed == 1 + e.IsSeen = isSeen == 1 + e.IsRejected = isRejected == 1 + + // Decrypt message content - pass full content with prefix for crypto.Decrypt to handle + if d.cryptoKey != nil && len(e.Content) > 4 { + // Determine the prefix to use + var prefix string + if strings.HasPrefix(e.Content, "cbc:") { + prefix = "cbc:" + } else if strings.HasPrefix(e.Content, "enc:") { + prefix = "enc:" + } + + if prefix != "" { + encrypted := e.Content[len(prefix):] + decrypted, err := crypto.Decrypt(prefix+encrypted, d.cryptoKey) + if err != nil { + log.Printf("[ERROR] Failed to decrypt history entry %d (%s): %v", e.ID, prefix, err) + e.Content = "[Decryption failed: " + err.Error() + "]" + } else { + e.Content = decrypted + } } } entries = append(entries, e) @@ -561,6 +752,17 @@ func (d *DB) ClearHistory(agentID int64) error { return err } +func (d *DB) MarkHistorySeen(agentID int64) error { + _, err := d.db.Exec("UPDATE history SET is_seen = 1 WHERE agent_id = ?", agentID) + return err +} + +func (d *DB) GetUnseenCount(agentID int64) (int, error) { + var count int + err := d.db.QueryRow("SELECT COUNT(*) FROM history WHERE agent_id = ? AND is_seen = 0", agentID).Scan(&count) + return count, err +} + type AllowListEntry struct { ID int64 TelegramUserID string @@ -632,28 +834,37 @@ type CalendarEvent struct { Date string Time string Prompt string + EventType string // "action" (default) = call LLM, "deliver" = send directly Executed bool CreatedAt string } func (d *DB) CreateCalendarEvent(e *CalendarEvent) (int64, error) { + eventType := e.EventType + if eventType == "" { + eventType = "action" // default + } + log.Printf("[DB] CreateCalendarEvent: agent_id=%d, date=%s, time=%s, event_type=%s, prompt=%s", + e.AgentID, e.Date, e.Time, eventType, e.Prompt) res, err := d.db.Exec(` - INSERT INTO calendar (agent_id, date, time, prompt, executed) - VALUES (?, ?, ?, ?, ?) - `, e.AgentID, e.Date, e.Time, e.Prompt, e.Executed) + INSERT INTO calendar_events (agent_id, date, time, prompt, event_type, executed) + VALUES (?, ?, ?, ?, ?, ?) + `, e.AgentID, e.Date, e.Time, e.Prompt, eventType, e.Executed) if err != nil { return 0, err } - return res.LastInsertId() + id, _ := res.LastInsertId() + log.Printf("[DB] Calendar event created: id=%d, event_type=%s", id, eventType) + return id, nil } func (d *DB) GetCalendarEvent(id int64) (*CalendarEvent, error) { e := &CalendarEvent{} var executed int err := d.db.QueryRow(` - SELECT id, agent_id, date, time, prompt, executed, created_at - FROM calendar WHERE id = ? - `, id).Scan(&e.ID, &e.AgentID, &e.Date, &e.Time, &e.Prompt, &executed, &e.CreatedAt) + SELECT id, agent_id, date, time, prompt, COALESCE(event_type, 'action'), executed, created_at + FROM calendar_events WHERE id = ? + `, id).Scan(&e.ID, &e.AgentID, &e.Date, &e.Time, &e.Prompt, &e.EventType, &executed, &e.CreatedAt) e.Executed = executed == 1 if err != nil { return nil, err @@ -663,8 +874,8 @@ func (d *DB) GetCalendarEvent(id int64) (*CalendarEvent, error) { func (d *DB) ListCalendarEvents() ([]*CalendarEvent, error) { rows, err := d.db.Query(` - SELECT id, agent_id, date, time, prompt, executed, created_at - FROM calendar ORDER BY date ASC, time ASC + SELECT id, agent_id, date, time, prompt, COALESCE(event_type, 'action'), executed, created_at + FROM calendar_events ORDER BY date ASC, time ASC `) if err != nil { return nil, err @@ -675,7 +886,7 @@ func (d *DB) ListCalendarEvents() ([]*CalendarEvent, error) { for rows.Next() { e := &CalendarEvent{} var executed int - if err := rows.Scan(&e.ID, &e.AgentID, &e.Date, &e.Time, &e.Prompt, &executed, &e.CreatedAt); err != nil { + if err := rows.Scan(&e.ID, &e.AgentID, &e.Date, &e.Time, &e.Prompt, &e.EventType, &executed, &e.CreatedAt); err != nil { return nil, err } e.Executed = executed == 1 @@ -686,8 +897,8 @@ func (d *DB) ListCalendarEvents() ([]*CalendarEvent, error) { func (d *DB) ListCalendarEventsByAgent(agentID int64) ([]*CalendarEvent, error) { rows, err := d.db.Query(` - SELECT id, agent_id, date, time, prompt, executed, created_at - FROM calendar WHERE agent_id = ? ORDER BY date ASC, time ASC + SELECT id, agent_id, date, time, prompt, COALESCE(event_type, 'action'), executed, created_at + FROM calendar_events WHERE agent_id = ? ORDER BY date ASC, time ASC `, agentID) if err != nil { return nil, err @@ -698,7 +909,7 @@ func (d *DB) ListCalendarEventsByAgent(agentID int64) ([]*CalendarEvent, error) for rows.Next() { e := &CalendarEvent{} var executed int - if err := rows.Scan(&e.ID, &e.AgentID, &e.Date, &e.Time, &e.Prompt, &executed, &e.CreatedAt); err != nil { + if err := rows.Scan(&e.ID, &e.AgentID, &e.Date, &e.Time, &e.Prompt, &e.EventType, &executed, &e.CreatedAt); err != nil { return nil, err } e.Executed = executed == 1 @@ -709,8 +920,8 @@ func (d *DB) ListCalendarEventsByAgent(agentID int64) ([]*CalendarEvent, error) func (d *DB) GetPendingCalendarEvents() ([]*CalendarEvent, error) { rows, err := d.db.Query(` - SELECT id, agent_id, date, time, prompt, executed, created_at - FROM calendar WHERE executed = 0 ORDER BY date ASC, time ASC + SELECT id, agent_id, date, time, prompt, COALESCE(event_type, 'action'), executed, created_at + FROM calendar_events WHERE executed = 0 ORDER BY date ASC, time ASC `) if err != nil { return nil, err @@ -721,7 +932,7 @@ func (d *DB) GetPendingCalendarEvents() ([]*CalendarEvent, error) { for rows.Next() { e := &CalendarEvent{} var executed int - if err := rows.Scan(&e.ID, &e.AgentID, &e.Date, &e.Time, &e.Prompt, &executed, &e.CreatedAt); err != nil { + if err := rows.Scan(&e.ID, &e.AgentID, &e.Date, &e.Time, &e.Prompt, &e.EventType, &executed, &e.CreatedAt); err != nil { return nil, err } e.Executed = executed == 1 @@ -731,12 +942,19 @@ func (d *DB) GetPendingCalendarEvents() ([]*CalendarEvent, error) { } func (d *DB) MarkCalendarEventExecuted(id int64) error { - _, err := d.db.Exec("UPDATE calendar SET executed = 1 WHERE id = ?", id) - return err + result, err := d.db.Exec("UPDATE calendar_events SET executed = 1 WHERE id = ? AND executed = 0", id) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("event already executed") + } + return nil } func (d *DB) DeleteCalendarEvent(id int64) error { - _, err := d.db.Exec("DELETE FROM calendar WHERE id = ?", id) + _, err := d.db.Exec("DELETE FROM calendar_events WHERE id = ?", id) return err } diff --git a/internal/parser/contract.go b/internal/parser/contract.go index 3ae08ed..c2758ed 100644 --- a/internal/parser/contract.go +++ b/internal/parser/contract.go @@ -7,6 +7,7 @@ package parser import ( "log" "regexp" + "strconv" "strings" ) @@ -28,10 +29,15 @@ type ParsedAction struct { } type ParsedCalendar struct { - DateTime string `json:"datetime,omitempty"` - Prompt string `json:"prompt,omitempty"` - ID string `json:"id,omitempty"` - Action string `json:"action,omitempty"` // "create", "list", "delete", "update" + DateTime string `json:"datetime,omitempty"` + Prompt string `json:"prompt,omitempty"` + ID string `json:"id,omitempty"` + Action string `json:"action,omitempty"` // "create", "list", "delete", "update" + EventType string `json:"eventType,omitempty"` // "action" (default) or "deliver" + // Relative time support via + ScheduleMinutes int `json:"scheduleMinutes,omitempty"` + ScheduleHours int `json:"scheduleHours,omitempty"` + ScheduleDays int `json:"scheduleDays,omitempty"` } // ParsedApp represents a parsed tag with HTML, CSS, and JS content. @@ -66,6 +72,11 @@ var ( calendarListRegex = regexp.MustCompile(`(?is)`) calendarDeleteRegex = regexp.MustCompile(`(?is)]+)["']?\s*/>`) calendarUpdateRegex = regexp.MustCompile(`(?is)]+)["']?>(.*?)`) + // Calendar type attribute for CRON-like scheduling + // type="action" (default) = call LLM to process when triggered + // type="deliver" = send content directly as agent message (no LLM call) + calendarTypeRegex = regexp.MustCompile(`(?is)]+)["']?\s*>`) + calendarCreateRegex = regexp.MustCompile(`(?is)]+)["']?\s*>(.*?)`) // New tags: , giveRegex = regexp.MustCompile(`(?is)(.*?)`) appRegex = regexp.MustCompile(`(?is)]+)["']?>(.*?)`) @@ -73,11 +84,22 @@ var ( appCSSRegex = regexp.MustCompile(`(?is)`) appJSRegex = regexp.MustCompile(`(?is)`) deployRegex = regexp.MustCompile(`(?is)(.*?)`) + // New unified tag for relative time scheduling + // Usage: reminder text + scheduleMinutesRegex = regexp.MustCompile(`minutes=["']?(\d+)["']?`) + scheduleHoursRegex = regexp.MustCompile(`hours=["']?(\d+)["']?`) + scheduleDaysRegex = regexp.MustCompile(`days=["']?(\d+)["']?`) ) // ParseLLMOutput parses XML tags from LLM response. // Docs: See docs/xml-tags.md for all supported tags. // Supported tags: , , , , , , , +// +// Rules: +// - tags: Extracted for user transport (Telegram/HermitChat) +// - tags: Internal only, NOT sent to user +// - Plain text outside tags: Ignored (not sent to user) +// - At least one tag is REQUIRED, otherwise response is rejected func ParseLLMOutput(text string) ParsedResponse { log.Printf("[PARSER] Input text: %s", text) @@ -93,6 +115,8 @@ func ParseLLMOutput(text string) ParsedResponse { resp.Thought = strings.TrimSpace(m[1]) } + // Extract tag content + // Plain text outside tags is IGNORED - only content goes to user if m := messageRegex.FindStringSubmatch(text); len(m) > 1 { resp.Message = strings.TrimSpace(m[1]) } @@ -228,8 +252,54 @@ func ParseLLMOutput(text string) ParsedResponse { } if prompt != "" || datetime != "" { - resp.Calendars = append(resp.Calendars, ParsedCalendar{DateTime: datetime, Prompt: prompt, Action: "create"}) - log.Printf("[PARSER DEBUG] Added calendar event") + resp.Calendars = append(resp.Calendars, ParsedCalendar{DateTime: datetime, Prompt: prompt, Action: "create", EventType: "action"}) + log.Printf("[PARSER DEBUG] Added calendar event (type=action)") + } + } + + // Handle or with explicit type + typeMatches := calendarCreateRegex.FindAllStringSubmatch(text, -1) + for _, m := range typeMatches { + if len(m) < 3 { + continue + } + eventType := strings.ToLower(strings.TrimSpace(m[1])) + calendarContent := m[2] + + // Skip if this was already processed by the basic calendarRegex + // Only process if type is explicitly "deliver" + if eventType != "deliver" && eventType != "action" { + continue + } + + log.Printf("[PARSER DEBUG] calendar with type=%s, content: %s", eventType, calendarContent) + + // Extract prompt + promptRegex := regexp.MustCompile(`(?is)(.*?)`) + prompt := "" + if pm := promptRegex.FindStringSubmatch(calendarContent); len(pm) > 1 { + prompt = strings.TrimSpace(pm[1]) + } + + // Extract datetime + datetime := "" + if dt := calendarDateTimeRegex.FindStringSubmatch(calendarContent); len(dt) > 1 { + datetime = strings.TrimSpace(dt[1]) + } else { + dateVal := "" + timeVal := "" + if d := calendarDateRegex.FindStringSubmatch(calendarContent); len(d) > 1 { + dateVal = strings.TrimSpace(d[1]) + } + if t := calendarTimeRegex.FindStringSubmatch(calendarContent); len(t) > 1 { + timeVal = strings.TrimSpace(t[1]) + } + datetime = strings.TrimSpace(strings.TrimSpace(dateVal) + " " + strings.TrimSpace(timeVal)) + } + + if prompt != "" || datetime != "" { + resp.Calendars = append(resp.Calendars, ParsedCalendar{DateTime: datetime, Prompt: prompt, Action: "create", EventType: eventType}) + log.Printf("[PARSER DEBUG] Added calendar event (type=%s)", eventType) } } @@ -238,6 +308,69 @@ func ParseLLMOutput(text string) ParsedResponse { resp.Calendars = append(resp.Calendars, ParsedCalendar{Action: "list"}) } + // Handle unified tag for relative time scheduling + // Usage: ... + // ... + // ... + scheduleTagRegex := regexp.MustCompile(`(?is)]*)>(.*?)`) + scheduleMatches := scheduleTagRegex.FindAllStringSubmatch(text, -1) + for _, m := range scheduleMatches { + if len(m) < 3 { + continue + } + attrs := m[1] + scheduleContent := m[2] + + minutes := 0 + hours := 0 + days := 0 + eventType := "action" // default + + if minMatch := scheduleMinutesRegex.FindStringSubmatch(attrs); len(minMatch) > 1 { + if val, err := strconv.Atoi(minMatch[1]); err == nil { + minutes = val + } + } + if hrMatch := scheduleHoursRegex.FindStringSubmatch(attrs); len(hrMatch) > 1 { + if val, err := strconv.Atoi(hrMatch[1]); err == nil { + hours = val + } + } + if dayMatch := scheduleDaysRegex.FindStringSubmatch(attrs); len(dayMatch) > 1 { + if val, err := strconv.Atoi(dayMatch[1]); err == nil { + days = val + } + } + + // Check for type attribute on schedule tag + scheduleTypeRegex := regexp.MustCompile(`type=["']?([^"'\s]+)["']?`) + if typeMatch := scheduleTypeRegex.FindStringSubmatch(attrs); len(typeMatch) > 1 { + eventType = strings.ToLower(strings.TrimSpace(typeMatch[1])) + } + + // Extract prompt from schedule content + schedulePrompt := "" + innerPromptRegex := regexp.MustCompile(`(?is)(.*?)`) + if pm := innerPromptRegex.FindStringSubmatch(scheduleContent); len(pm) > 1 { + schedulePrompt = strings.TrimSpace(pm[1]) + } else { + // Use the content itself as the prompt if no tag + schedulePrompt = strings.TrimSpace(scheduleContent) + } + + if schedulePrompt != "" && (minutes > 0 || hours > 0 || days > 0) { + resp.Calendars = append(resp.Calendars, ParsedCalendar{ + Action: "create", + Prompt: schedulePrompt, + EventType: eventType, + ScheduleMinutes: minutes, + ScheduleHours: hours, + ScheduleDays: days, + }) + log.Printf("[PARSER DEBUG] Added schedule event: minutes=%d, hours=%d, days=%d, type=%s", minutes, hours, days, eventType) + } + } + // Handle calendar delete action if m := calendarDeleteRegex.FindStringSubmatch(text); len(m) > 1 { resp.Calendars = append(resp.Calendars, ParsedCalendar{Action: "delete", ID: strings.TrimSpace(m[1])})