From eb44b1e4b244e5a93e7d465b14df39819d8dfaba Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Fri, 16 Jan 2026 16:53:19 +0300 Subject: Feat: impl attempt --- bot.go | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) (limited to 'bot.go') diff --git a/bot.go b/bot.go index 4d6da58..967c060 100644 --- a/bot.go +++ b/bot.go @@ -18,6 +18,7 @@ import ( "net/url" "os" "path" + "regexp" "strconv" "strings" "sync" @@ -68,6 +69,111 @@ var ( LocalModels = []string{} ) +// parseKnownToTag extracts known_to list from content using configured tag. +// Returns cleaned content and list of character names. +func parseKnownToTag(content string) (string, []string) { + if cfg == nil || !cfg.CharSpecificContextEnabled { + return content, nil + } + tag := cfg.CharSpecificContextTag + if tag == "" { + tag = "__known_to_chars__" + } + // Pattern: tag + list + "__" + pattern := regexp.QuoteMeta(tag) + `(.*?)__` + re := regexp.MustCompile(pattern) + matches := re.FindAllStringSubmatch(content, -1) + if len(matches) == 0 { + return content, nil + } + // There may be multiple tags; we combine all. + var knownTo []string + cleaned := content + for _, match := range matches { + if len(match) < 2 { + continue + } + // Remove the entire matched tag from content + cleaned = strings.Replace(cleaned, match[0], "", 1) + + list := strings.TrimSpace(match[1]) + if list == "" { + continue + } + parts := strings.Split(list, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + knownTo = append(knownTo, p) + } + } + } + // Also remove any leftover trailing "__" that might be orphaned? Not needed. + return strings.TrimSpace(cleaned), knownTo +} + +// processMessageTag processes a message for known_to tag and sets KnownTo field. +// It also ensures the sender's role is included in KnownTo. +// If KnownTo already set (e.g., from DB), preserves it unless new tag found. +func processMessageTag(msg models.RoleMsg) models.RoleMsg { + if cfg == nil || !cfg.CharSpecificContextEnabled { + return msg + } + // If KnownTo already set, assume tag already processed (content cleaned). + // However, we still check for new tags (maybe added later). + cleaned, knownTo := parseKnownToTag(msg.Content) + if cleaned != msg.Content { + msg.Content = cleaned + } + // If tag found, replace KnownTo with new list (merge with existing?) + // For simplicity, if knownTo is not nil, replace. + if knownTo != nil { + msg.KnownTo = knownTo + } + // Ensure sender role is in KnownTo + if msg.Role != "" { + senderAdded := false + for _, k := range msg.KnownTo { + if k == msg.Role { + senderAdded = true + break + } + } + if !senderAdded { + msg.KnownTo = append(msg.KnownTo, msg.Role) + } + } + return msg +} + +// filterMessagesForCharacter returns messages visible to the specified character. +// If CharSpecificContextEnabled is false, returns all messages. +func filterMessagesForCharacter(messages []models.RoleMsg, character string) []models.RoleMsg { + if cfg == nil || !cfg.CharSpecificContextEnabled || character == "" { + return messages + } + filtered := make([]models.RoleMsg, 0, len(messages)) + for _, msg := range messages { + // If KnownTo is nil or empty, message is visible to all + if len(msg.KnownTo) == 0 { + filtered = append(filtered, msg) + continue + } + // Check if character is in KnownTo list + found := false + for _, k := range msg.KnownTo { + if k == character { + found = true + break + } + } + if found { + filtered = append(filtered, msg) + } + } + return filtered +} + // cleanNullMessages removes messages with null or empty content to prevent API issues func cleanNullMessages(messages []models.RoleMsg) []models.RoleMsg { // // deletes tool calls which we don't want for now -- cgit v1.2.3 From 8b162ef34f0755e2224c43499218def16d4b6845 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Sat, 17 Jan 2026 11:42:35 +0300 Subject: Enha: change textview chat history based on current user persona --- bot.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index 967c060..a5d3b12 100644 --- a/bot.go +++ b/bot.go @@ -153,7 +153,8 @@ func filterMessagesForCharacter(messages []models.RoleMsg, character string) []m return messages } filtered := make([]models.RoleMsg, 0, len(messages)) - for _, msg := range messages { + for i, msg := range messages { + logger.Info("filtering messages", "character", character, "index", i, "known_to", msg.KnownTo) // If KnownTo is nil or empty, message is visible to all if len(msg.KnownTo) == 0 { filtered = append(filtered, msg) @@ -1003,9 +1004,9 @@ func findCall(msg, toolCall string, tv *tview.TextView) { chatRound("", cfg.AssistantRole, tv, false, false) } -func chatToTextSlice(showSys bool) []string { - resp := make([]string, len(chatBody.Messages)) - for i, msg := range chatBody.Messages { +func chatToTextSlice(messages []models.RoleMsg, showSys bool) []string { + resp := make([]string, len(messages)) + for i, msg := range messages { // INFO: skips system msg and tool msg if !showSys && (msg.Role == cfg.ToolRole || msg.Role == "system") { continue @@ -1015,8 +1016,8 @@ func chatToTextSlice(showSys bool) []string { return resp } -func chatToText(showSys bool) string { - s := chatToTextSlice(showSys) +func chatToText(messages []models.RoleMsg, showSys bool) string { + s := chatToTextSlice(messages, showSys) return strings.Join(s, "\n") } @@ -1140,7 +1141,7 @@ func summarizeAndStartNewChat() { } chatBody.Messages = append(chatBody.Messages, toolMsg) // Update UI - textView.SetText(chatToText(cfg.ShowSys)) + textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) colorText() // Update storage if err := updateStorageChat(activeChatName, chatBody.Messages); err != nil { -- cgit v1.2.3 From ec2d1c05ace9905e0ff88e25d5d65f0d7ff58274 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Sat, 17 Jan 2026 11:54:52 +0300 Subject: Fix: do not skip system msgs --- bot.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index a5d3b12..fc878d5 100644 --- a/bot.go +++ b/bot.go @@ -95,7 +95,6 @@ func parseKnownToTag(content string) (string, []string) { } // Remove the entire matched tag from content cleaned = strings.Replace(cleaned, match[0], "", 1) - list := strings.TrimSpace(match[1]) if list == "" { continue @@ -122,6 +121,7 @@ func processMessageTag(msg models.RoleMsg) models.RoleMsg { // If KnownTo already set, assume tag already processed (content cleaned). // However, we still check for new tags (maybe added later). cleaned, knownTo := parseKnownToTag(msg.Content) + logger.Info("processing tags", "msg", msg.Content, "known_to", knownTo) if cleaned != msg.Content { msg.Content = cleaned } @@ -156,7 +156,8 @@ func filterMessagesForCharacter(messages []models.RoleMsg, character string) []m for i, msg := range messages { logger.Info("filtering messages", "character", character, "index", i, "known_to", msg.KnownTo) // If KnownTo is nil or empty, message is visible to all - if len(msg.KnownTo) == 0 { + // system msg cannot be filtered + if len(msg.KnownTo) == 0 || msg.Role == "system" { filtered = append(filtered, msg) continue } -- cgit v1.2.3 From fd84dd58266bdeff498f939721e9a1998318473b Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Sat, 17 Jan 2026 12:28:19 +0300 Subject: Enha: do not remove tag --- bot.go | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index fc878d5..3af0ac9 100644 --- a/bot.go +++ b/bot.go @@ -71,9 +71,9 @@ var ( // parseKnownToTag extracts known_to list from content using configured tag. // Returns cleaned content and list of character names. -func parseKnownToTag(content string) (string, []string) { +func parseKnownToTag(content string) []string { if cfg == nil || !cfg.CharSpecificContextEnabled { - return content, nil + return nil } tag := cfg.CharSpecificContextTag if tag == "" { @@ -84,17 +84,15 @@ func parseKnownToTag(content string) (string, []string) { re := regexp.MustCompile(pattern) matches := re.FindAllStringSubmatch(content, -1) if len(matches) == 0 { - return content, nil + return nil } // There may be multiple tags; we combine all. var knownTo []string - cleaned := content for _, match := range matches { if len(match) < 2 { continue } // Remove the entire matched tag from content - cleaned = strings.Replace(cleaned, match[0], "", 1) list := strings.TrimSpace(match[1]) if list == "" { continue @@ -108,7 +106,7 @@ func parseKnownToTag(content string) (string, []string) { } } // Also remove any leftover trailing "__" that might be orphaned? Not needed. - return strings.TrimSpace(cleaned), knownTo + return knownTo } // processMessageTag processes a message for known_to tag and sets KnownTo field. @@ -120,11 +118,8 @@ func processMessageTag(msg models.RoleMsg) models.RoleMsg { } // If KnownTo already set, assume tag already processed (content cleaned). // However, we still check for new tags (maybe added later). - cleaned, knownTo := parseKnownToTag(msg.Content) + knownTo := parseKnownToTag(msg.Content) logger.Info("processing tags", "msg", msg.Content, "known_to", knownTo) - if cleaned != msg.Content { - msg.Content = cleaned - } // If tag found, replace KnownTo with new list (merge with existing?) // For simplicity, if knownTo is not nil, replace. if knownTo != nil { @@ -789,10 +784,17 @@ out: if resume { chatBody.Messages[len(chatBody.Messages)-1].Content += respText.String() // lastM.Content = lastM.Content + respText.String() + // Process the updated message to check for known_to tags in resumed response + updatedMsg := chatBody.Messages[len(chatBody.Messages)-1] + processedMsg := processMessageTag(updatedMsg) + chatBody.Messages[len(chatBody.Messages)-1] = processedMsg } else { - chatBody.Messages = append(chatBody.Messages, models.RoleMsg{ + newMsg := models.RoleMsg{ Role: botPersona, Content: respText.String(), - }) + } + // Process the new message to check for known_to tags in LLM response + newMsg = processMessageTag(newMsg) + chatBody.Messages = append(chatBody.Messages, newMsg) } logger.Debug("chatRound: before cleanChatBody", "messages_before_clean", len(chatBody.Messages)) for i, msg := range chatBody.Messages { -- cgit v1.2.3 From 3e2a1b6f9975aaa2b9cb45bcb77aac146a37fd3c Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Sat, 17 Jan 2026 13:03:30 +0300 Subject: Fix: KnowTo is added only if tag present --- bot.go | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index 3af0ac9..f3fef8c 100644 --- a/bot.go +++ b/bot.go @@ -124,18 +124,19 @@ func processMessageTag(msg models.RoleMsg) models.RoleMsg { // For simplicity, if knownTo is not nil, replace. if knownTo != nil { msg.KnownTo = knownTo - } - // Ensure sender role is in KnownTo - if msg.Role != "" { - senderAdded := false - for _, k := range msg.KnownTo { - if k == msg.Role { - senderAdded = true - break + // Only ensure sender role is in KnownTo if there was a tag + // This means the message is intended for specific characters + if msg.Role != "" { + senderAdded := false + for _, k := range msg.KnownTo { + if k == msg.Role { + senderAdded = true + break + } + } + if !senderAdded { + msg.KnownTo = append(msg.KnownTo, msg.Role) } - } - if !senderAdded { - msg.KnownTo = append(msg.KnownTo, msg.Role) } } return msg -- cgit v1.2.3 From 4e597e944eacbeb5269dfdf586dd4a2163762a17 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Sat, 17 Jan 2026 13:07:14 +0300 Subject: Chore: log cleanup --- bot.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index f3fef8c..2c59f07 100644 --- a/bot.go +++ b/bot.go @@ -119,7 +119,7 @@ func processMessageTag(msg models.RoleMsg) models.RoleMsg { // If KnownTo already set, assume tag already processed (content cleaned). // However, we still check for new tags (maybe added later). knownTo := parseKnownToTag(msg.Content) - logger.Info("processing tags", "msg", msg.Content, "known_to", knownTo) + // logger.Info("processing tags", "msg", msg.Content, "known_to", knownTo) // If tag found, replace KnownTo with new list (merge with existing?) // For simplicity, if knownTo is not nil, replace. if knownTo != nil { @@ -149,8 +149,7 @@ func filterMessagesForCharacter(messages []models.RoleMsg, character string) []m return messages } filtered := make([]models.RoleMsg, 0, len(messages)) - for i, msg := range messages { - logger.Info("filtering messages", "character", character, "index", i, "known_to", msg.KnownTo) + for _, msg := range messages { // If KnownTo is nil or empty, message is visible to all // system msg cannot be filtered if len(msg.KnownTo) == 0 || msg.Role == "system" { -- cgit v1.2.3 From a28e8ef9e250ace5c9624393da308c189c0839f6 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Wed, 21 Jan 2026 21:01:01 +0300 Subject: Enha: charlist in cards --- bot.go | 1 - 1 file changed, 1 deletion(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index 2c59f07..112af07 100644 --- a/bot.go +++ b/bot.go @@ -1063,7 +1063,6 @@ func addNewChat(chatName string) { func applyCharCard(cc *models.CharCard) { cfg.AssistantRole = cc.Role - // FIXME: remove history, err := loadAgentsLastChat(cfg.AssistantRole) if err != nil { // too much action for err != nil; loadAgentsLastChat needs to be split up -- cgit v1.2.3 From 98138728542d0ed529d9d3a389c3531945d971f3 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Thu, 22 Jan 2026 09:29:56 +0300 Subject: Chore: bool colors for statusline --- bot.go | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index 112af07..cd35445 100644 --- a/bot.go +++ b/bot.go @@ -35,19 +35,18 @@ var ( logLevel = new(slog.LevelVar) ) var ( - activeChatName string - chunkChan = make(chan string, 10) - openAIToolChan = make(chan string, 10) - streamDone = make(chan bool, 1) - chatBody *models.ChatBody - store storage.FullRepo - defaultFirstMsg = "Hello! What can I do for you?" - defaultStarter = []models.RoleMsg{} - defaultStarterBytes = []byte{} - interruptResp = false - ragger *rag.RAG - chunkParser ChunkParser - lastToolCall *models.FuncCall + activeChatName string + chunkChan = make(chan string, 10) + openAIToolChan = make(chan string, 10) + streamDone = make(chan bool, 1) + chatBody *models.ChatBody + store storage.FullRepo + defaultFirstMsg = "Hello! What can I do for you?" + defaultStarter = []models.RoleMsg{} + interruptResp = false + ragger *rag.RAG + chunkParser ChunkParser + lastToolCall *models.FuncCall //nolint:unused // TTS_ENABLED conditionally uses this orator Orator asr STT @@ -1170,11 +1169,6 @@ func init() { slog.Error("failed to open log file", "error", err, "filename", cfg.LogFile) return } - defaultStarterBytes, err = json.Marshal(defaultStarter) - if err != nil { - slog.Error("failed to marshal defaultStarter", "error", err) - return - } // load cards basicCard.Role = cfg.AssistantRole // toolCard.Role = cfg.AssistantRole -- cgit v1.2.3 From fa192a262410eb98b42ff8fb9e0f4e1111240514 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Sun, 25 Jan 2026 09:59:07 +0300 Subject: Feat: autoturn --- bot.go | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) (limited to 'bot.go') diff --git a/bot.go b/bot.go index cd35445..1a2cebb 100644 --- a/bot.go +++ b/bot.go @@ -813,7 +813,18 @@ out: if err := updateStorageChat(activeChatName, chatBody.Messages); err != nil { logger.Warn("failed to update storage", "error", err, "name", activeChatName) } + // FIXME: recursive calls findCall(respText.String(), toolResp.String(), tv) + // TODO: have a config attr + // Check if this message was sent privately to specific characters + // If so, trigger those characters to respond if that char is not controlled by user + // perhaps we should have narrator role to determine which char is next to act + if cfg.AutoTurn { + lastMsg := chatBody.Messages[len(chatBody.Messages)-1] + if len(lastMsg.KnownTo) > 0 { + triggerPrivateMessageResponses(lastMsg, tv) + } + } } // cleanChatBody removes messages with null or empty content to prevent API issues @@ -1205,3 +1216,27 @@ func init() { scrollToEndEnabled = cfg.AutoScrollEnabled go updateModelLists() } + +// triggerPrivateMessageResponses checks if a message was sent privately to specific characters +// and triggers those non-user characters to respond +func triggerPrivateMessageResponses(msg models.RoleMsg, tv *tview.TextView) { + if cfg == nil || !cfg.CharSpecificContextEnabled { + return + } + userCharacter := cfg.UserRole + if cfg.WriteNextMsgAs != "" { + userCharacter = cfg.WriteNextMsgAs + } + // Check each character in the KnownTo list + for _, recipient := range msg.KnownTo { + // Skip if this is the user character or the sender of the message + if recipient == cfg.UserRole || recipient == userCharacter || recipient == msg.Role || recipient == cfg.ToolRole { + continue + } + // Trigger the recipient character to respond by simulating a prompt + // that indicates it's their turn + triggerMsg := recipient + ":\n" + // Call chatRound with the trigger message to make the recipient respond + chatRound(triggerMsg, recipient, tv, false, false) + } +} -- cgit v1.2.3 From 3a11210f52a850f84771e1642cafcc3027b85075 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Sat, 31 Jan 2026 12:57:53 +0300 Subject: Enha: avoid recursion in llm calls --- bot.go | 145 ++++++++++++++++++++++++++++++++++++++++++----------------------- 1 file changed, 94 insertions(+), 51 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index 1a2cebb..6e7d094 100644 --- a/bot.go +++ b/bot.go @@ -25,17 +25,16 @@ import ( "time" "github.com/neurosnap/sentences/english" - "github.com/rivo/tview" ) var ( - httpClient = &http.Client{} - cfg *config.Config - logger *slog.Logger - logLevel = new(slog.LevelVar) -) -var ( + httpClient = &http.Client{} + cfg *config.Config + logger *slog.Logger + logLevel = new(slog.LevelVar) + ctx, cancel = context.WithCancel(context.Background()) activeChatName string + chatRoundChan = make(chan *models.ChatRoundReq, 1) chunkChan = make(chan string, 10) openAIToolChan = make(chan string, 10) streamDone = make(chan bool, 1) @@ -699,7 +698,23 @@ func roleToIcon(role string) string { return "<" + role + ">: " } -func chatRound(userMsg, role string, tv *tview.TextView, regen, resume bool) { +func chatWatcher(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case chatRoundReq := <-chatRoundChan: + if err := chatRound(chatRoundReq); err != nil { + logger.Error("failed to chatRound", "err", err) + } + } + } +} + +func chatRound(r *models.ChatRoundReq) error { + // chunkChan := make(chan string, 10) + // openAIToolChan := make(chan string, 10) + // streamDone := make(chan bool, 1) botRespMode = true botPersona := cfg.AssistantRole if cfg.WriteNextMsgAsCompletionAgent != "" { @@ -707,32 +722,23 @@ func chatRound(userMsg, role string, tv *tview.TextView, regen, resume bool) { } defer func() { botRespMode = false }() // check that there is a model set to use if is not local - if cfg.CurrentAPI == cfg.DeepSeekChatAPI || cfg.CurrentAPI == cfg.DeepSeekCompletionAPI { - if chatBody.Model != "deepseek-chat" && chatBody.Model != "deepseek-reasoner" { - if err := notifyUser("bad request", "wrong deepseek model name"); err != nil { - logger.Warn("failed ot notify user", "error", err) - return - } - return - } - } choseChunkParser() - reader, err := chunkParser.FormMsg(userMsg, role, resume) + reader, err := chunkParser.FormMsg(r.UserMsg, r.Role, r.Resume) if reader == nil || err != nil { - logger.Error("empty reader from msgs", "role", role, "error", err) - return + logger.Error("empty reader from msgs", "role", r.Role, "error", err) + return err } if cfg.SkipLLMResp { - return + return nil } go sendMsgToLLM(reader) - logger.Debug("looking at vars in chatRound", "msg", userMsg, "regen", regen, "resume", resume) - if !resume { - fmt.Fprintf(tv, "\n[-:-:b](%d) ", len(chatBody.Messages)) - fmt.Fprint(tv, roleToIcon(botPersona)) - fmt.Fprint(tv, "[-:-:-]\n") + logger.Debug("looking at vars in chatRound", "msg", r.UserMsg, "regen", r.Regen, "resume", r.Resume) + if !r.Resume { + fmt.Fprintf(textView, "\n[-:-:b](%d) ", len(chatBody.Messages)) + fmt.Fprint(textView, roleToIcon(botPersona)) + fmt.Fprint(textView, "[-:-:-]\n") if cfg.ThinkUse && !strings.Contains(cfg.CurrentAPI, "v1") { - // fmt.Fprint(tv, "") + // fmt.Fprint(textView, "") chunkChan <- "" } } @@ -742,29 +748,29 @@ out: for { select { case chunk := <-chunkChan: - fmt.Fprint(tv, chunk) + fmt.Fprint(textView, chunk) respText.WriteString(chunk) if scrollToEndEnabled { - tv.ScrollToEnd() + textView.ScrollToEnd() } // Send chunk to audio stream handler if cfg.TTS_ENABLED { TTSTextChan <- chunk } case toolChunk := <-openAIToolChan: - fmt.Fprint(tv, toolChunk) + fmt.Fprint(textView, toolChunk) toolResp.WriteString(toolChunk) if scrollToEndEnabled { - tv.ScrollToEnd() + textView.ScrollToEnd() } case <-streamDone: // drain any remaining chunks from chunkChan before exiting for len(chunkChan) > 0 { chunk := <-chunkChan - fmt.Fprint(tv, chunk) + fmt.Fprint(textView, chunk) respText.WriteString(chunk) if scrollToEndEnabled { - tv.ScrollToEnd() + textView.ScrollToEnd() } if cfg.TTS_ENABLED { // Send chunk to audio stream handler @@ -780,7 +786,7 @@ out: } botRespMode = false // numbers in chatbody and displayed must be the same - if resume { + if r.Resume { chatBody.Messages[len(chatBody.Messages)-1].Content += respText.String() // lastM.Content = lastM.Content + respText.String() // Process the updated message to check for known_to tags in resumed response @@ -797,7 +803,9 @@ out: } logger.Debug("chatRound: before cleanChatBody", "messages_before_clean", len(chatBody.Messages)) for i, msg := range chatBody.Messages { - logger.Debug("chatRound: before cleaning", "index", i, "role", msg.Role, "content_len", len(msg.Content), "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID) + logger.Debug("chatRound: before cleaning", "index", i, + "role", msg.Role, "content_len", len(msg.Content), + "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID) } // // Clean null/empty messages to prevent API issues with endpoints like llama.cpp jinja template cleanChatBody() @@ -813,8 +821,9 @@ out: if err := updateStorageChat(activeChatName, chatBody.Messages); err != nil { logger.Warn("failed to update storage", "error", err, "name", activeChatName) } - // FIXME: recursive calls - findCall(respText.String(), toolResp.String(), tv) + if findCall(respText.String(), toolResp.String()) { + return nil + } // TODO: have a config attr // Check if this message was sent privately to specific characters // If so, trigger those characters to respond if that char is not controlled by user @@ -822,9 +831,10 @@ out: if cfg.AutoTurn { lastMsg := chatBody.Messages[len(chatBody.Messages)-1] if len(lastMsg.KnownTo) > 0 { - triggerPrivateMessageResponses(lastMsg, tv) + triggerPrivateMessageResponses(lastMsg) } } + return nil } // cleanChatBody removes messages with null or empty content to prevent API issues @@ -909,7 +919,8 @@ func unmarshalFuncCall(jsonStr string) (*models.FuncCall, error) { return fc, nil } -func findCall(msg, toolCall string, tv *tview.TextView) { +// findCall: adds chatRoundReq into the chatRoundChan and returns true if does +func findCall(msg, toolCall string) bool { fc := &models.FuncCall{} if toolCall != "" { // HTML-decode the tool call string to handle encoded characters like < -> <= @@ -927,8 +938,13 @@ func findCall(msg, toolCall string, tv *tview.TextView) { chatBody.Messages = append(chatBody.Messages, toolResponseMsg) // Clear the stored tool call ID after using it (no longer needed) // Trigger the assistant to continue processing with the error message - chatRound("", cfg.AssistantRole, tv, false, false) - return + crr := &models.ChatRoundReq{ + Role: cfg.AssistantRole, + } + // provoke next llm msg after failed tool call + chatRoundChan <- crr + // chatRound("", cfg.AssistantRole, tv, false, false) + return true } lastToolCall.Args = openAIToolMap fc = lastToolCall @@ -940,8 +956,8 @@ func findCall(msg, toolCall string, tv *tview.TextView) { } } else { jsStr := toolCallRE.FindString(msg) - if jsStr == "" { - return + if jsStr == "" { // no tool call case + return false } prefix := "__tool_call__\n" suffix := "\n__tool_call__" @@ -960,8 +976,13 @@ func findCall(msg, toolCall string, tv *tview.TextView) { chatBody.Messages = append(chatBody.Messages, toolResponseMsg) logger.Debug("findCall: added tool error response", "role", toolResponseMsg.Role, "content_len", len(toolResponseMsg.Content), "message_count_after_add", len(chatBody.Messages)) // Trigger the assistant to continue processing with the error message - chatRound("", cfg.AssistantRole, tv, false, false) - return + // chatRound("", cfg.AssistantRole, tv, false, false) + crr := &models.ChatRoundReq{ + Role: cfg.AssistantRole, + } + // provoke next llm msg after failed tool call + chatRoundChan <- crr + return true } // Update lastToolCall with parsed function call lastToolCall.ID = fc.ID @@ -994,13 +1015,17 @@ func findCall(msg, toolCall string, tv *tview.TextView) { lastToolCall.ID = "" // Trigger the assistant to continue processing with the new tool response // by calling chatRound with empty content to continue the assistant's response - chatRound("", cfg.AssistantRole, tv, false, false) - return + crr := &models.ChatRoundReq{ + Role: cfg.AssistantRole, + } + // failed to find tool + chatRoundChan <- crr + return true } resp := callToolWithAgent(fc.Name, fc.Args) toolMsg := string(resp) // Remove the "tool response: " prefix and %+v formatting logger.Info("llm used tool call", "tool_resp", toolMsg, "tool_attrs", fc) - fmt.Fprintf(tv, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n", + fmt.Fprintf(textView, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n", "\n\n", len(chatBody.Messages), cfg.ToolRole, toolMsg) // Create tool response message with the proper tool_call_id toolResponseMsg := models.RoleMsg{ @@ -1014,7 +1039,11 @@ func findCall(msg, toolCall string, tv *tview.TextView) { lastToolCall.ID = "" // Trigger the assistant to continue processing with the new tool response // by calling chatRound with empty content to continue the assistant's response - chatRound("", cfg.AssistantRole, tv, false, false) + crr := &models.ChatRoundReq{ + Role: cfg.AssistantRole, + } + chatRoundChan <- crr + return true } func chatToTextSlice(messages []models.RoleMsg, showSys bool) []string { @@ -1163,10 +1192,12 @@ func summarizeAndStartNewChat() { } func init() { + // ctx, cancel := context.WithCancel(context.Background()) var err error cfg, err = config.LoadConfig("config.toml") if err != nil { fmt.Println("failed to load config.toml") + cancel() os.Exit(1) return } @@ -1178,6 +1209,8 @@ func init() { os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { slog.Error("failed to open log file", "error", err, "filename", cfg.LogFile) + cancel() + os.Exit(1) return } // load cards @@ -1188,13 +1221,17 @@ func init() { logger = slog.New(slog.NewTextHandler(logfile, &slog.HandlerOptions{Level: logLevel})) store = storage.NewProviderSQL(cfg.DBPATH, logger) if store == nil { + cancel() os.Exit(1) + return } ragger = rag.New(logger, store, cfg) // https://github.com/coreydaley/ggerganov-llama.cpp/blob/master/examples/server/README.md // load all chats in memory if _, err := loadHistoryChats(); err != nil { logger.Error("failed to load chat", "error", err) + cancel() + os.Exit(1) return } lastToolCall = &models.FuncCall{} @@ -1215,11 +1252,12 @@ func init() { // Initialize scrollToEndEnabled based on config scrollToEndEnabled = cfg.AutoScrollEnabled go updateModelLists() + go chatWatcher(ctx) } // triggerPrivateMessageResponses checks if a message was sent privately to specific characters // and triggers those non-user characters to respond -func triggerPrivateMessageResponses(msg models.RoleMsg, tv *tview.TextView) { +func triggerPrivateMessageResponses(msg models.RoleMsg) { if cfg == nil || !cfg.CharSpecificContextEnabled { return } @@ -1237,6 +1275,11 @@ func triggerPrivateMessageResponses(msg models.RoleMsg, tv *tview.TextView) { // that indicates it's their turn triggerMsg := recipient + ":\n" // Call chatRound with the trigger message to make the recipient respond - chatRound(triggerMsg, recipient, tv, false, false) + // chatRound(triggerMsg, recipient, tv, false, false) + crr := &models.ChatRoundReq{ + UserMsg: triggerMsg, + Role: recipient, + } + chatRoundChan <- crr } } -- cgit v1.2.3 From 6f6a35459ef4de340c0c6825da20828e7f579207 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Sun, 1 Feb 2026 11:38:51 +0300 Subject: Chore: cleaning --- bot.go | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index 6e7d094..f55fc6d 100644 --- a/bot.go +++ b/bot.go @@ -169,26 +169,10 @@ func filterMessagesForCharacter(messages []models.RoleMsg, character string) []m return filtered } -// cleanNullMessages removes messages with null or empty content to prevent API issues -func cleanNullMessages(messages []models.RoleMsg) []models.RoleMsg { - // // deletes tool calls which we don't want for now - // cleaned := make([]models.RoleMsg, 0, len(messages)) - // for _, msg := range messages { - // // is there a sense for this check at all? - // if msg.HasContent() || msg.ToolCallID != "" || msg.Role == cfg.AssistantRole || msg.Role == cfg.WriteNextMsgAsCompletionAgent { - // cleaned = append(cleaned, msg) - // } else { - // // Log filtered messages for debugging - // logger.Warn("filtering out message during cleaning", "role", msg.Role, "content", msg.Content, "tool_call_id", msg.ToolCallID, "has_content", msg.HasContent()) - // } - // } - return consolidateConsecutiveAssistantMessages(messages) -} - func cleanToolCalls(messages []models.RoleMsg) []models.RoleMsg { // If AutoCleanToolCallsFromCtx is false, keep tool call messages in context if cfg != nil && !cfg.AutoCleanToolCallsFromCtx { - return consolidateConsecutiveAssistantMessages(messages) + return consolidateAssistantMessages(messages) } cleaned := make([]models.RoleMsg, 0, len(messages)) for i, msg := range messages { @@ -198,11 +182,11 @@ func cleanToolCalls(messages []models.RoleMsg) []models.RoleMsg { cleaned = append(cleaned, msg) } } - return consolidateConsecutiveAssistantMessages(cleaned) + return consolidateAssistantMessages(cleaned) } -// consolidateConsecutiveAssistantMessages merges consecutive assistant messages into a single message -func consolidateConsecutiveAssistantMessages(messages []models.RoleMsg) []models.RoleMsg { +// consolidateAssistantMessages merges consecutive assistant messages into a single message +func consolidateAssistantMessages(messages []models.RoleMsg) []models.RoleMsg { if len(messages) == 0 { return messages } @@ -211,6 +195,7 @@ func consolidateConsecutiveAssistantMessages(messages []models.RoleMsg) []models isBuildingAssistantMsg := false for i := 0; i < len(messages); i++ { msg := messages[i] + // what about the case with multiplpe assistant roles? if msg.Role == cfg.AssistantRole || msg.Role == cfg.WriteNextMsgAsCompletionAgent { // If this is an assistant message, start or continue building if !isBuildingAssistantMsg { @@ -824,7 +809,6 @@ out: if findCall(respText.String(), toolResp.String()) { return nil } - // TODO: have a config attr // Check if this message was sent privately to specific characters // If so, trigger those characters to respond if that char is not controlled by user // perhaps we should have narrator role to determine which char is next to act @@ -850,7 +834,7 @@ func cleanChatBody() { // Tool request cleaning is now configurable via AutoCleanToolCallsFromCtx (default false) // /completion msg where part meant for user and other part tool call chatBody.Messages = cleanToolCalls(chatBody.Messages) - chatBody.Messages = cleanNullMessages(chatBody.Messages) + chatBody.Messages = consolidateAssistantMessages(chatBody.Messages) logger.Debug("cleanChatBody: after cleaning", "original_len", originalLen, "new_len", len(chatBody.Messages)) for i, msg := range chatBody.Messages { logger.Debug("cleanChatBody: after clean", "index", i, "role", msg.Role, "content_len", len(msg.Content), "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID) -- cgit v1.2.3 From e52e8ce2cc44b4e8cc950fe6811810db4142921d Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Mon, 2 Feb 2026 08:18:49 +0300 Subject: Enha: consolidate assistant messages only --- bot.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index f55fc6d..9a991d6 100644 --- a/bot.go +++ b/bot.go @@ -195,8 +195,8 @@ func consolidateAssistantMessages(messages []models.RoleMsg) []models.RoleMsg { isBuildingAssistantMsg := false for i := 0; i < len(messages); i++ { msg := messages[i] - // what about the case with multiplpe assistant roles? - if msg.Role == cfg.AssistantRole || msg.Role == cfg.WriteNextMsgAsCompletionAgent { + // assistant role only + if msg.Role == cfg.AssistantRole { // If this is an assistant message, start or continue building if !isBuildingAssistantMsg { // Start accumulating assistant message -- cgit v1.2.3 From 65b4f01177a38497b0ecb82b09f9dcded55c5acb Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Tue, 3 Feb 2026 11:00:12 +0300 Subject: Doc: char context doc --- bot.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index 9a991d6..b836824 100644 --- a/bot.go +++ b/bot.go @@ -1251,15 +1251,18 @@ func triggerPrivateMessageResponses(msg models.RoleMsg) { } // Check each character in the KnownTo list for _, recipient := range msg.KnownTo { - // Skip if this is the user character or the sender of the message - if recipient == cfg.UserRole || recipient == userCharacter || recipient == msg.Role || recipient == cfg.ToolRole { + if recipient == msg.Role || recipient == cfg.ToolRole { + // weird cases, skip continue } + // Skip if this is the user character or the sender of the message + if recipient == cfg.UserRole || recipient == userCharacter { + return // user in known_to => users turn + } // Trigger the recipient character to respond by simulating a prompt // that indicates it's their turn triggerMsg := recipient + ":\n" // Call chatRound with the trigger message to make the recipient respond - // chatRound(triggerMsg, recipient, tv, false, false) crr := &models.ChatRoundReq{ UserMsg: triggerMsg, Role: recipient, -- cgit v1.2.3 From 76f14ce4a376bbbb99c79cc2090c067b5ba28484 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Tue, 3 Feb 2026 16:56:31 +0300 Subject: Enha: detailed error --- bot.go | 116 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 103 insertions(+), 13 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index b836824..120a3fb 100644 --- a/bot.go +++ b/bot.go @@ -498,6 +498,58 @@ func monitorModelLoad(modelID string) { }() } + +// extractDetailedErrorFromBytes extracts detailed error information from response body bytes +func extractDetailedErrorFromBytes(body []byte, statusCode int) string { + // Try to parse as JSON to extract detailed error information + var errorResponse map[string]interface{} + if err := json.Unmarshal(body, &errorResponse); err == nil { + // Check if it's an error response with detailed information + if errorData, ok := errorResponse["error"]; ok { + if errorMap, ok := errorData.(map[string]interface{}); ok { + var errorMsg string + if msg, ok := errorMap["message"]; ok { + errorMsg = fmt.Sprintf("%v", msg) + } + + var details []string + if code, ok := errorMap["code"]; ok { + details = append(details, fmt.Sprintf("Code: %v", code)) + } + + if metadata, ok := errorMap["metadata"]; ok { + // Handle metadata which might contain raw error details + if metadataMap, ok := metadata.(map[string]interface{}); ok { + if raw, ok := metadataMap["raw"]; ok { + // Parse the raw error string if it's JSON + var rawError map[string]interface{} + if rawStr, ok := raw.(string); ok && json.Unmarshal([]byte(rawStr), &rawError) == nil { + if rawErrorData, ok := rawError["error"]; ok { + if rawErrorMap, ok := rawErrorData.(map[string]interface{}); ok { + if rawMsg, ok := rawErrorMap["message"]; ok { + return fmt.Sprintf("API Error: %s", rawMsg) + } + } + } + } + } + } + details = append(details, fmt.Sprintf("Metadata: %v", metadata)) + } + + if len(details) > 0 { + return fmt.Sprintf("API Error: %s (%s)", errorMsg, strings.Join(details, ", ")) + } + + return "API Error: " + errorMsg + } + } + } + + // If not a structured error response, return the raw body with status + return fmt.Sprintf("HTTP Status: %d, Response Body: %s", statusCode, string(body)) +} + // sendMsgToLLM expects streaming resp func sendMsgToLLM(body io.Reader) { choseChunkParser() @@ -524,6 +576,33 @@ func sendMsgToLLM(body io.Reader) { streamDone <- true return } + + // Check if the initial response is an error before starting to stream + if resp.StatusCode >= 400 { + // Read the response body to get detailed error information + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + logger.Error("failed to read error response body", "error", err, "status_code", resp.StatusCode) + detailedError := fmt.Sprintf("HTTP Status: %d, Failed to read response body: %v", resp.StatusCode, err) + if err := notifyUser("API Error", detailedError); err != nil { + logger.Error("failed to notify", "error", err) + } + resp.Body.Close() + streamDone <- true + return + } + + // Parse the error response for detailed information + detailedError := extractDetailedErrorFromBytes(bodyBytes, resp.StatusCode) + logger.Error("API returned error status", "status_code", resp.StatusCode, "detailed_error", detailedError) + if err := notifyUser("API Error", detailedError); err != nil { + logger.Error("failed to notify", "error", err) + } + resp.Body.Close() + streamDone <- true + return + } + defer resp.Body.Close() reader := bufio.NewReader(resp.Body) counter := uint32(0) @@ -541,11 +620,23 @@ func sendMsgToLLM(body io.Reader) { } line, err := reader.ReadBytes('\n') if err != nil { - logger.Error("error reading response body", "error", err, "line", string(line), - "user_role", cfg.UserRole, "parser", chunkParser, "link", cfg.CurrentAPI) - // if err.Error() != "EOF" { - if err := notifyUser("API error", err.Error()); err != nil { - logger.Error("failed to notify", "error", err) + // Check if this is an EOF error and if the response contains detailed error information + if err == io.EOF { + // For streaming responses, we may have already consumed the error body + // So we'll use the original status code to provide context + detailedError := fmt.Sprintf("Streaming connection closed unexpectedly (Status: %d). This may indicate an API error. Check your API provider and model settings.", resp.StatusCode) + logger.Error("error reading response body", "error", err, "detailed_error", detailedError, + "status_code", resp.StatusCode, "user_role", cfg.UserRole, "parser", chunkParser, "link", cfg.CurrentAPI) + if err := notifyUser("API Error", detailedError); err != nil { + logger.Error("failed to notify", "error", err) + } + } else { + logger.Error("error reading response body", "error", err, "line", string(line), + "user_role", cfg.UserRole, "parser", chunkParser, "link", cfg.CurrentAPI) + // if err.Error() != "EOF" { + if err := notifyUser("API error", err.Error()); err != nil { + logger.Error("failed to notify", "error", err) + } } streamDone <- true break @@ -798,7 +889,7 @@ out: for i, msg := range chatBody.Messages { logger.Debug("chatRound: after cleaning", "index", i, "role", msg.Role, "content_len", len(msg.Content), "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID) } - colorText() + refreshChatDisplay() updateStatusLine() // bot msg is done; // now check it for func call @@ -1255,16 +1346,15 @@ func triggerPrivateMessageResponses(msg models.RoleMsg) { // weird cases, skip continue } - // Skip if this is the user character or the sender of the message + // Skip if this is the user character (user handles their own turn) + // If user is in KnownTo, stop processing - it's the user's turn if recipient == cfg.UserRole || recipient == userCharacter { - return // user in known_to => users turn + return // user in known_to => user's turn } - // Trigger the recipient character to respond by simulating a prompt - // that indicates it's their turn - triggerMsg := recipient + ":\n" - // Call chatRound with the trigger message to make the recipient respond + // Trigger the recipient character to respond + // Send empty message so LLM continues naturally from the conversation crr := &models.ChatRoundReq{ - UserMsg: triggerMsg, + UserMsg: "", // Empty message - LLM will continue the conversation Role: recipient, } chatRoundChan <- crr -- cgit v1.2.3 From 654d6a47ec2d991277e87ca5b2144076eb9f7458 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Tue, 3 Feb 2026 19:06:09 +0300 Subject: Fix: trigger auto turn cannot be empty empty message means to continue merging new reply to the last message --- bot.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index 120a3fb..13d488a 100644 --- a/bot.go +++ b/bot.go @@ -498,7 +498,6 @@ func monitorModelLoad(modelID string) { }() } - // extractDetailedErrorFromBytes extracts detailed error information from response body bytes func extractDetailedErrorFromBytes(body []byte, statusCode int) string { // Try to parse as JSON to extract detailed error information @@ -1352,9 +1351,10 @@ func triggerPrivateMessageResponses(msg models.RoleMsg) { return // user in known_to => user's turn } // Trigger the recipient character to respond + triggerMsg := recipient + ":\n" // Send empty message so LLM continues naturally from the conversation crr := &models.ChatRoundReq{ - UserMsg: "", // Empty message - LLM will continue the conversation + UserMsg: triggerMsg, Role: recipient, } chatRoundChan <- crr -- cgit v1.2.3 From e3965db3c7e7f5e3cdbf5d03ac06103c2709c0d8 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Wed, 4 Feb 2026 08:26:30 +0300 Subject: Enha: use slices methods --- bot.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index 13d488a..a28097f 100644 --- a/bot.go +++ b/bot.go @@ -19,6 +19,7 @@ import ( "os" "path" "regexp" + "slices" "strconv" "strings" "sync" @@ -154,15 +155,8 @@ func filterMessagesForCharacter(messages []models.RoleMsg, character string) []m filtered = append(filtered, msg) continue } - // Check if character is in KnownTo list - found := false - for _, k := range msg.KnownTo { - if k == character { - found = true - break - } - } - if found { + if slices.Contains(msg.KnownTo, character) { + // Check if character is in KnownTo lis filtered = append(filtered, msg) } } -- cgit v1.2.3 From 79861e7c2bc6f2ed95309ca6e83577ddc4e2c63a Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Wed, 4 Feb 2026 11:22:17 +0300 Subject: Enha: privateMessageResp with resume --- bot.go | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index a28097f..d195431 100644 --- a/bot.go +++ b/bot.go @@ -96,8 +96,10 @@ func parseKnownToTag(content string) []string { if list == "" { continue } - parts := strings.Split(list, ",") - for _, p := range parts { + strings.SplitSeq(list, ",") + // parts := strings.Split(list, ",") + // for _, p := range parts { + for p := range strings.SplitSeq(list, ",") { p = strings.TrimSpace(p) if p != "" { knownTo = append(knownTo, p) @@ -118,25 +120,17 @@ func processMessageTag(msg models.RoleMsg) models.RoleMsg { // If KnownTo already set, assume tag already processed (content cleaned). // However, we still check for new tags (maybe added later). knownTo := parseKnownToTag(msg.Content) - // logger.Info("processing tags", "msg", msg.Content, "known_to", knownTo) // If tag found, replace KnownTo with new list (merge with existing?) // For simplicity, if knownTo is not nil, replace. - if knownTo != nil { - msg.KnownTo = knownTo - // Only ensure sender role is in KnownTo if there was a tag - // This means the message is intended for specific characters - if msg.Role != "" { - senderAdded := false - for _, k := range msg.KnownTo { - if k == msg.Role { - senderAdded = true - break - } - } - if !senderAdded { - msg.KnownTo = append(msg.KnownTo, msg.Role) - } - } + if knownTo == nil { + return msg + } + msg.KnownTo = knownTo + if msg.Role == "" { + return msg + } + if !slices.Contains(msg.KnownTo, msg.Role) { + msg.KnownTo = append(msg.KnownTo, msg.Role) } return msg } @@ -781,9 +775,6 @@ func chatWatcher(ctx context.Context) { } func chatRound(r *models.ChatRoundReq) error { - // chunkChan := make(chan string, 10) - // openAIToolChan := make(chan string, 10) - // streamDone := make(chan bool, 1) botRespMode = true botPersona := cfg.AssistantRole if cfg.WriteNextMsgAsCompletionAgent != "" { @@ -1350,6 +1341,7 @@ func triggerPrivateMessageResponses(msg models.RoleMsg) { crr := &models.ChatRoundReq{ UserMsg: triggerMsg, Role: recipient, + Resume: true, } chatRoundChan <- crr } -- cgit v1.2.3 From 7187df509fe9cc506695a1036b840e03eeb25cff Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Wed, 4 Feb 2026 12:47:54 +0300 Subject: Enha: stricter stop string --- bot.go | 20 -------------------- 1 file changed, 20 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index d195431..c396d07 100644 --- a/bot.go +++ b/bot.go @@ -861,18 +861,7 @@ out: newMsg = processMessageTag(newMsg) chatBody.Messages = append(chatBody.Messages, newMsg) } - logger.Debug("chatRound: before cleanChatBody", "messages_before_clean", len(chatBody.Messages)) - for i, msg := range chatBody.Messages { - logger.Debug("chatRound: before cleaning", "index", i, - "role", msg.Role, "content_len", len(msg.Content), - "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID) - } - // // Clean null/empty messages to prevent API issues with endpoints like llama.cpp jinja template cleanChatBody() - logger.Debug("chatRound: after cleanChatBody", "messages_after_clean", len(chatBody.Messages)) - for i, msg := range chatBody.Messages { - logger.Debug("chatRound: after cleaning", "index", i, "role", msg.Role, "content_len", len(msg.Content), "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID) - } refreshChatDisplay() updateStatusLine() // bot msg is done; @@ -901,19 +890,10 @@ func cleanChatBody() { if chatBody == nil || chatBody.Messages == nil { return } - originalLen := len(chatBody.Messages) - logger.Debug("cleanChatBody: before cleaning", "message_count", originalLen) - for i, msg := range chatBody.Messages { - logger.Debug("cleanChatBody: before clean", "index", i, "role", msg.Role, "content_len", len(msg.Content), "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID) - } // Tool request cleaning is now configurable via AutoCleanToolCallsFromCtx (default false) // /completion msg where part meant for user and other part tool call chatBody.Messages = cleanToolCalls(chatBody.Messages) chatBody.Messages = consolidateAssistantMessages(chatBody.Messages) - logger.Debug("cleanChatBody: after cleaning", "original_len", originalLen, "new_len", len(chatBody.Messages)) - for i, msg := range chatBody.Messages { - logger.Debug("cleanChatBody: after clean", "index", i, "role", msg.Role, "content_len", len(msg.Content), "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID) - } } // convertJSONToMapStringString unmarshals JSON into map[string]interface{} and converts all values to strings. -- cgit v1.2.3 From 685738a5a4f7488a0f1b87d360c71912aa575d65 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Wed, 4 Feb 2026 13:54:54 +0300 Subject: Enha: force stop string on client side --- bot.go | 8 ++++++++ 1 file changed, 8 insertions(+) (limited to 'bot.go') diff --git a/bot.go b/bot.go index c396d07..bbb3f65 100644 --- a/bot.go +++ b/bot.go @@ -540,6 +540,8 @@ func extractDetailedErrorFromBytes(body []byte, statusCode int) string { // sendMsgToLLM expects streaming resp func sendMsgToLLM(body io.Reader) { choseChunkParser() + // openrouter does not respect stop strings, so we have to cut the message ourselves + stopStrings := chatBody.MakeStopSliceExcluding("", listChatRoles()) req, err := http.NewRequest("POST", cfg.CurrentAPI, body) if err != nil { logger.Error("newreq error", "error", err) @@ -678,6 +680,12 @@ func sendMsgToLLM(body io.Reader) { } // bot sends way too many \n answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n") + // Accumulate text to check for stop strings that might span across chunks + // check if chunk is in stopstrings => stop + if slices.Contains(stopStrings, answerText) { + logger.Debug("Stop string detected and handled", "stop_string", answerText) + streamDone <- true + } chunkChan <- answerText openAIToolChan <- chunk.ToolChunk if chunk.FuncName != "" { -- cgit v1.2.3 From 478a505869bf26b15dcbc77feb2c09c1f2ff4aac Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Fri, 6 Feb 2026 11:32:06 +0300 Subject: Enha: client stop string for completion only --- bot.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index bbb3f65..c6c1e77 100644 --- a/bot.go +++ b/bot.go @@ -682,8 +682,10 @@ func sendMsgToLLM(body io.Reader) { answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n") // Accumulate text to check for stop strings that might span across chunks // check if chunk is in stopstrings => stop - if slices.Contains(stopStrings, answerText) { - logger.Debug("Stop string detected and handled", "stop_string", answerText) + // this check is needed only for openrouter /v1/completion, since it does not respect stop slice + if chunkParser.GetAPIType() == models.APITypeCompletion && + slices.Contains(stopStrings, answerText) { + logger.Debug("stop string detected on client side for completion endpoint", "stop_string", answerText) streamDone <- true } chunkChan <- answerText -- cgit v1.2.3 From 4af866079c3f21eab12b02c3158567539ca40c50 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Fri, 6 Feb 2026 12:42:06 +0300 Subject: Chore: linter complaints --- bot.go | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index c6c1e77..2af0453 100644 --- a/bot.go +++ b/bot.go @@ -113,7 +113,7 @@ func parseKnownToTag(content string) []string { // processMessageTag processes a message for known_to tag and sets KnownTo field. // It also ensures the sender's role is included in KnownTo. // If KnownTo already set (e.g., from DB), preserves it unless new tag found. -func processMessageTag(msg models.RoleMsg) models.RoleMsg { +func processMessageTag(msg *models.RoleMsg) *models.RoleMsg { if cfg == nil || !cfg.CharSpecificContextEnabled { return msg } @@ -297,7 +297,8 @@ func warmUpModel() { go func() { var data []byte var err error - if strings.HasSuffix(cfg.CurrentAPI, "/completion") { + switch { + case strings.HasSuffix(cfg.CurrentAPI, "/completion"): // Old completion endpoint req := models.NewLCPReq(".", chatBody.Model, nil, map[string]float32{ "temperature": 0.8, @@ -307,7 +308,7 @@ func warmUpModel() { }, []string{}) req.Stream = false data, err = json.Marshal(req) - } else if strings.Contains(cfg.CurrentAPI, "/v1/chat/completions") { + case strings.Contains(cfg.CurrentAPI, "/v1/chat/completions"): // OpenAI-compatible chat endpoint req := models.OpenAIReq{ ChatBody: &models.ChatBody{ @@ -320,7 +321,7 @@ func warmUpModel() { Tools: nil, } data, err = json.Marshal(req) - } else { + default: // Unknown local endpoint, skip return } @@ -861,14 +862,14 @@ out: // lastM.Content = lastM.Content + respText.String() // Process the updated message to check for known_to tags in resumed response updatedMsg := chatBody.Messages[len(chatBody.Messages)-1] - processedMsg := processMessageTag(updatedMsg) - chatBody.Messages[len(chatBody.Messages)-1] = processedMsg + processedMsg := processMessageTag(&updatedMsg) + chatBody.Messages[len(chatBody.Messages)-1] = *processedMsg } else { newMsg := models.RoleMsg{ Role: botPersona, Content: respText.String(), } // Process the new message to check for known_to tags in LLM response - newMsg = processMessageTag(newMsg) + newMsg = *processMessageTag(&newMsg) chatBody.Messages = append(chatBody.Messages, newMsg) } cleanChatBody() @@ -889,7 +890,7 @@ out: if cfg.AutoTurn { lastMsg := chatBody.Messages[len(chatBody.Messages)-1] if len(lastMsg.KnownTo) > 0 { - triggerPrivateMessageResponses(lastMsg) + triggerPrivateMessageResponses(&lastMsg) } } return nil @@ -970,7 +971,7 @@ func unmarshalFuncCall(jsonStr string) (*models.FuncCall, error) { // findCall: adds chatRoundReq into the chatRoundChan and returns true if does func findCall(msg, toolCall string) bool { - fc := &models.FuncCall{} + var fc *models.FuncCall if toolCall != "" { // HTML-decode the tool call string to handle encoded characters like < -> <= decodedToolCall := html.UnescapeString(toolCall) @@ -1306,7 +1307,7 @@ func init() { // triggerPrivateMessageResponses checks if a message was sent privately to specific characters // and triggers those non-user characters to respond -func triggerPrivateMessageResponses(msg models.RoleMsg) { +func triggerPrivateMessageResponses(msg *models.RoleMsg) { if cfg == nil || !cfg.CharSpecificContextEnabled { return } -- cgit v1.2.3 From 93284312cfdb5784654fa4817c726728739b1b34 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Sun, 8 Feb 2026 17:11:29 +0300 Subject: Enha: auto turn role display --- bot.go | 3 +++ 1 file changed, 3 insertions(+) (limited to 'bot.go') diff --git a/bot.go b/bot.go index 2af0453..da2424d 100644 --- a/bot.go +++ b/bot.go @@ -1334,6 +1334,9 @@ func triggerPrivateMessageResponses(msg *models.RoleMsg) { Role: recipient, Resume: true, } + fmt.Fprintf(textView, "\n[-:-:b](%d) ", len(chatBody.Messages)) + fmt.Fprint(textView, roleToIcon(recipient)) + fmt.Fprint(textView, "[-:-:-]\n") chatRoundChan <- crr } } -- cgit v1.2.3 From 1bf9e6eef72ec2eec7282b1554b41a0dc3d8d1b8 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Sun, 8 Feb 2026 21:50:03 +0300 Subject: Enha: extract first valid recipient from knownto --- bot.go | 55 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 21 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index da2424d..8e0e856 100644 --- a/bot.go +++ b/bot.go @@ -1305,15 +1305,17 @@ func init() { go chatWatcher(ctx) } -// triggerPrivateMessageResponses checks if a message was sent privately to specific characters -// and triggers those non-user characters to respond -func triggerPrivateMessageResponses(msg *models.RoleMsg) { +func getValidKnowToRecipient(msg *models.RoleMsg) (string, bool) { if cfg == nil || !cfg.CharSpecificContextEnabled { - return + return "", false } - userCharacter := cfg.UserRole - if cfg.WriteNextMsgAs != "" { - userCharacter = cfg.WriteNextMsgAs + // case where all roles are in the tag => public message + cr := listChatRoles() + slices.Sort(cr) + slices.Sort(msg.KnownTo) + if slices.Equal(cr, msg.KnownTo) { + logger.Info("got msg with tag mentioning every role") + return "", false } // Check each character in the KnownTo list for _, recipient := range msg.KnownTo { @@ -1323,20 +1325,31 @@ func triggerPrivateMessageResponses(msg *models.RoleMsg) { } // Skip if this is the user character (user handles their own turn) // If user is in KnownTo, stop processing - it's the user's turn - if recipient == cfg.UserRole || recipient == userCharacter { - return // user in known_to => user's turn - } - // Trigger the recipient character to respond - triggerMsg := recipient + ":\n" - // Send empty message so LLM continues naturally from the conversation - crr := &models.ChatRoundReq{ - UserMsg: triggerMsg, - Role: recipient, - Resume: true, + if recipient == cfg.UserRole || recipient == cfg.WriteNextMsgAs { + return "", false } - fmt.Fprintf(textView, "\n[-:-:b](%d) ", len(chatBody.Messages)) - fmt.Fprint(textView, roleToIcon(recipient)) - fmt.Fprint(textView, "[-:-:-]\n") - chatRoundChan <- crr + return recipient, true } + return "", false +} + +// triggerPrivateMessageResponses checks if a message was sent privately to specific characters +// and triggers those non-user characters to respond +func triggerPrivateMessageResponses(msg *models.RoleMsg) { + recipient, ok := getValidKnowToRecipient(msg) + if !ok || recipient == "" { + return + } + // Trigger the recipient character to respond + triggerMsg := recipient + ":\n" + // Send empty message so LLM continues naturally from the conversation + crr := &models.ChatRoundReq{ + UserMsg: triggerMsg, + Role: recipient, + Resume: true, + } + fmt.Fprintf(textView, "\n[-:-:b](%d) ", len(chatBody.Messages)) + fmt.Fprint(textView, roleToIcon(recipient)) + fmt.Fprint(textView, "[-:-:-]\n") + chatRoundChan <- crr } -- cgit v1.2.3 From 5e7ddea6827765ac56155577cf7dcc809fe1128c Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Mon, 9 Feb 2026 09:44:54 +0300 Subject: Enha: change __known_by_char tag to @ --- bot.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index 8e0e856..7209679 100644 --- a/bot.go +++ b/bot.go @@ -76,10 +76,10 @@ func parseKnownToTag(content string) []string { } tag := cfg.CharSpecificContextTag if tag == "" { - tag = "__known_to_chars__" + tag = "@" } - // Pattern: tag + list + "__" - pattern := regexp.QuoteMeta(tag) + `(.*?)__` + // Pattern: tag + list + "@" + pattern := regexp.QuoteMeta(tag) + `(.*?)@` re := regexp.MustCompile(pattern) matches := re.FindAllStringSubmatch(content, -1) if len(matches) == 0 { -- cgit v1.2.3 From 67733ad8dd0151f700e9e43748fb1700101fe651 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Mon, 9 Feb 2026 10:11:56 +0300 Subject: Enha: add bool to apply card --- bot.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index 7209679..2869fa0 100644 --- a/bot.go +++ b/bot.go @@ -1150,16 +1150,16 @@ func addNewChat(chatName string) { activeChatName = chat.Name } -func applyCharCard(cc *models.CharCard) { +func applyCharCard(cc *models.CharCard, loadHistory bool) { cfg.AssistantRole = cc.Role history, err := loadAgentsLastChat(cfg.AssistantRole) - if err != nil { + if err != nil || !loadHistory { // too much action for err != nil; loadAgentsLastChat needs to be split up - logger.Warn("failed to load last agent chat;", "agent", cc.Role, "err", err) history = []models.RoleMsg{ {Role: "system", Content: cc.SysPrompt}, {Role: cfg.AssistantRole, Content: cc.FirstMsg}, } + logger.Warn("failed to load last agent chat;", "agent", cc.Role, "err", err, "new_history", history) addNewChat("") } chatBody.Messages = history @@ -1170,7 +1170,7 @@ func charToStart(agentName string) bool { if !ok { return false } - applyCharCard(cc) + applyCharCard(cc, true) return true } -- cgit v1.2.3 From 3f4d8a946775cfba6fc6d0ac7ade30b310bb883b Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Mon, 9 Feb 2026 11:29:47 +0300 Subject: Fix (f1): load from the card --- bot.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'bot.go') diff --git a/bot.go b/bot.go index 2869fa0..6693d4c 100644 --- a/bot.go +++ b/bot.go @@ -1165,12 +1165,12 @@ func applyCharCard(cc *models.CharCard, loadHistory bool) { chatBody.Messages = history } -func charToStart(agentName string) bool { +func charToStart(agentName string, keepSysP bool) bool { cc, ok := sysMap[agentName] if !ok { return false } - applyCharCard(cc, true) + applyCharCard(cc, keepSysP) return true } @@ -1223,7 +1223,7 @@ func summarizeAndStartNewChat() { return } // Start a new chat - startNewChat() + startNewChat(true) // Inject summary as a tool call response toolMsg := models.RoleMsg{ Role: cfg.ToolRole, -- cgit v1.2.3 From 83aeee2576ee7dc332f9ba8ab13f5deb17ef20d2 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Mon, 9 Feb 2026 12:26:21 +0300 Subject: Enha: alice_bob_carl card update; system to see all the messages --- bot.go | 3 +++ 1 file changed, 3 insertions(+) (limited to 'bot.go') diff --git a/bot.go b/bot.go index 6693d4c..1310c20 100644 --- a/bot.go +++ b/bot.go @@ -141,6 +141,9 @@ func filterMessagesForCharacter(messages []models.RoleMsg, character string) []m if cfg == nil || !cfg.CharSpecificContextEnabled || character == "" { return messages } + if character == "system" { // system sees every message + return messages + } filtered := make([]models.RoleMsg, 0, len(messages)) for _, msg := range messages { // If KnownTo is nil or empty, message is visible to all -- cgit v1.2.3