Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refactor local tool notes injection and remove unused contacts feedback update tool

Lyric 31ce5392 33adfa77

+707 -380
-22
agent/engine_helpers.go
··· 133 133 if v, ok := params["freshness_window"].(string); ok && strings.TrimSpace(v) != "" { 134 134 out["freshness_window"] = truncateString(strings.TrimSpace(v), 40) 135 135 } 136 - if v, ok := summaryFloat(params, "freshness_window_hours"); ok { 137 - out["freshness_window_hours"] = v 138 - } 139 - if v, ok := params["human_enabled"].(bool); ok { 140 - out["human_enabled"] = v 141 - } 142 136 if v, ok := params["human_public_send_enabled"].(bool); ok { 143 137 out["human_public_send_enabled"] = v 144 138 } ··· 166 160 } 167 161 if v, ok := params["source_chat_type"].(string); ok && strings.TrimSpace(v) != "" { 168 162 out["source_chat_type"] = truncateString(strings.TrimSpace(v), 32) 169 - } 170 - case "contacts_feedback_update": 171 - if v, ok := params["contact_id"].(string); ok && strings.TrimSpace(v) != "" { 172 - out["contact_id"] = truncateString(strings.TrimSpace(v), opts.MaxStringValueChars) 173 - } 174 - if v, ok := params["signal"].(string); ok && strings.TrimSpace(v) != "" { 175 - out["signal"] = truncateString(strings.TrimSpace(v), 32) 176 - } 177 - if v, ok := params["topic"].(string); ok && strings.TrimSpace(v) != "" { 178 - out["topic"] = truncateString(strings.TrimSpace(v), 80) 179 - } 180 - if v, ok := params["session_id"].(string); ok && strings.TrimSpace(v) != "" { 181 - out["session_id"] = truncateString(strings.TrimSpace(v), 120) 182 - } 183 - if v, ok := params["end_session"].(bool); ok { 184 - out["end_session"] = v 185 163 } 186 164 case "echo": 187 165 if v, ok := params["value"].(string); ok && strings.TrimSpace(v) != "" {
+1 -13
agent/engine_helpers_test.go
··· 29 29 } 30 30 } 31 31 32 - func TestToolArgsSummary_ContactsFeedbackAndList(t *testing.T) { 32 + func TestToolArgsSummary_ContactsList(t *testing.T) { 33 33 opts := DefaultLogOptions() 34 - 35 - feedback := toolArgsSummary("contacts_feedback_update", map[string]any{ 36 - "contact_id": "telegram:alice", 37 - "signal": "positive", 38 - "end_session": true, 39 - }, opts) 40 - if feedback == nil { 41 - t.Fatalf("feedback summary should not be nil") 42 - } 43 - if feedback["signal"] != "positive" { 44 - t.Fatalf("unexpected signal summary: %#v", feedback["signal"]) 45 - } 46 34 47 35 list := toolArgsSummary("contacts_list", map[string]any{ 48 36 "status": "active",
+2 -2
assets/config/HEARTBEAT.md
··· 6 6 - Use `contacts_list` (`status=active`) to review active contacts. 7 7 - Rank current candidates with `contacts_candidate_rank` (`limit=3`) and pick top results. 8 8 - Send selected items using `contacts_send` (one send call per selected contact). 9 - - After observing response/engagement, call `contacts_feedback_update` with `signal=positive|neutral|negative`. 9 + - Session feedback states are updated by runtime program flow (no LLM tool call needed). 10 10 - If no contact is selected, summarize the reason (for example: no fresh candidates, cooldown, trust constraints). 11 - - If sending fails, summarize the error and next retry action. 11 + - If sending fails, summarize the error and move to next action.
+1 -4
assets/config/SOUL.md
··· 21 21 ## Boundaries 22 22 23 23 - Private things stay private. Period. 24 - 25 24 - When in doubt, ask before acting externally. 26 - 27 25 - Never send half-baked replies to messaging surfaces. 28 - 29 26 - You're not the user's voice — be careful in group chats. 30 27 31 28 ## Vibe ··· 34 31 35 32 ## Continuity 36 33 37 - Each session, you wake up fresh. These files *are* your memory. Read them. Update them. They're how you persist. 34 + Each session, you wake up fresh. These files *are* your durable memory. Read them. Update them. They're how you persist. 38 35 39 36 If you change this file, tell the user — it's your soul, and they should know. 40 37
+27
assets/config/TOOLS.md
··· 1 + # TOOLS.md - Local Tool Notes 2 + 3 + This file is optional local context for tool execution. 4 + 5 + Use it to record environment-specific notes that help the agent use tools correctly in this workspace. 6 + 7 + <!-- 8 + ## Recommended Content 9 + 10 + - Shell/runtime details: 11 + - preferred shell 12 + - test/build commands 13 + - Local service addresses: 14 + - dev servers 15 + - mock endpoints 16 + - Common workflow notes: 17 + - where generated files should go 18 + - known slow commands 19 + - required env vars (names only, no secret values) 20 + --> 21 + 22 + <!-- 23 + ## Example 24 + 25 + - Use `go test ./...` for full test run. 26 + - Local dev server at `http://localhost:8080` 27 + -->
+4 -1
assets/config/config.example.yaml
··· 158 158 # Max items returned by memory_recently. 159 159 max_items: 50 160 160 contacts: 161 - # Enable contacts tools (contacts_list / contacts_candidate_rank / contacts_send / contacts_feedback_update). 161 + # Enable contacts tools (contacts_upsert / contacts_list / contacts_candidate_rank / contacts_send). 162 162 enabled: true 163 163 url_fetch: 164 164 # Enable the url_fetch tool (HTTP(S) GET/POST/PUT/DELETE, truncated output). ··· 298 298 # - strict: only /ask, replies, and @mentions trigger in groups. 299 299 # - smart: replies/@mentions trigger; aliases are LLM-validated for direct addressing. 300 300 group_trigger_mode: "smart" 301 + # Group reply policy is fixed to humanlike (not configurable). 301 302 # In smart mode, how far from the start (in runes) an alias can appear to count as "addressing". 302 303 smart_addressing_max_chars: 24 303 304 # Minimum confidence required to accept the LLM addressing classification. ··· 335 336 trace: false 336 337 # Base directory for local state (memory/skills/heartbeat). 337 338 file_state_dir: "~/.morph" 339 + # Prompt profile context injection. 340 + # Note: "Local Tool Notes" (TOOLS.md) injection max bytes is fixed at 8192 (not configurable). 338 341 # Global temporary file cache directory used for inbound/outbound file handling (e.g. Telegram). 339 342 file_cache_dir: "/var/cache/morph" 340 343 file_cache:
+2
cmd/mistermorph/daemoncmd/serve.go
··· 487 487 return nil, nil, err 488 488 } 489 489 promptprofile.ApplyPersonaIdentity(&promptSpec, logger) 490 + promptprofile.AppendLocalToolNotesBlock(&promptSpec, logger) 490 491 engine := agent.New( 491 492 client, 492 493 registry, ··· 503 504 func resumeOneTask(ctx context.Context, logger *slog.Logger, logOpts agent.LogOptions, client llm.Client, registry *tools.Registry, baseCfg agent.Config, sharedGuard *guard.Guard, approvalRequestID string) (*agent.Final, *agent.Context, error) { 504 505 promptSpec := agent.DefaultPromptSpec() 505 506 promptprofile.ApplyPersonaIdentity(&promptSpec, logger) 507 + promptprofile.AppendLocalToolNotesBlock(&promptSpec, logger) 506 508 engine := agent.New( 507 509 client, 508 510 registry,
+20 -1
cmd/mistermorph/install.go
··· 20 20 func newInstallCmd() *cobra.Command { 21 21 cmd := &cobra.Command{ 22 22 Use: "install [dir]", 23 - Short: "Install config.yaml, HEARTBEAT.md, IDENTITY.md, SOUL.md, and built-in skills", 23 + Short: "Install config.yaml, HEARTBEAT.md, TOOLS.md, IDENTITY.md, SOUL.md, and built-in skills", 24 24 Args: cobra.MaximumNArgs(1), 25 25 RunE: func(cmd *cobra.Command, args []string) error { 26 26 dir := "~/.morph/" ··· 53 53 writeHeartbeat := true 54 54 if _, err := os.Stat(hbPath); err == nil { 55 55 writeHeartbeat = false 56 + } 57 + toolsPath := filepath.Join(dir, "TOOLS.md") 58 + writeTools := true 59 + if _, err := os.Stat(toolsPath); err == nil { 60 + writeTools = false 56 61 } 57 62 58 63 identityPath := filepath.Join(workspaceRoot, "IDENTITY.md") ··· 90 95 Path: hbPath, 91 96 Write: writeHeartbeat, 92 97 Loader: loadHeartbeatTemplate, 98 + }, 99 + { 100 + Name: "TOOLS.md", 101 + Path: toolsPath, 102 + Write: writeTools, 103 + Loader: loadToolsTemplate, 93 104 }, 94 105 { 95 106 Name: "IDENTITY.md", ··· 181 192 data, err := assets.ConfigFS.ReadFile("config/IDENTITY.md") 182 193 if err != nil { 183 194 return "", fmt.Errorf("read embedded IDENTITY.md: %w", err) 195 + } 196 + return string(data), nil 197 + } 198 + 199 + func loadToolsTemplate() (string, error) { 200 + data, err := assets.ConfigFS.ReadFile("config/TOOLS.md") 201 + if err != nil { 202 + return "", fmt.Errorf("read embedded TOOLS.md: %w", err) 184 203 } 185 204 return string(data), nil 186 205 }
+13
cmd/mistermorph/install_test.go
··· 102 102 t.Fatalf("SOUL template seems invalid") 103 103 } 104 104 } 105 + 106 + func TestLoadToolsTemplate(t *testing.T) { 107 + body, err := loadToolsTemplate() 108 + if err != nil { 109 + t.Fatalf("loadToolsTemplate() error = %v", err) 110 + } 111 + if body == "" { 112 + t.Fatalf("expected non-empty TOOLS template") 113 + } 114 + if !strings.Contains(body, "# TOOLS.md - Local Tool Notes") { 115 + t.Fatalf("TOOLS template seems invalid") 116 + } 117 + }
-2
cmd/mistermorph/registry.go
··· 164 164 DefaultLimit: viper.GetInt("contacts.proactive.max_targets"), 165 165 DefaultFreshnessWindow: contactsDefaultFreshnessWindow(), 166 166 DefaultMaxLinkedHistoryItems: 4, 167 - DefaultHumanEnabled: viper.GetBool("contacts.human.enabled"), 168 167 DefaultHumanPublicSend: viper.GetBool("contacts.human.send.public_enabled"), 169 168 DefaultLLMProvider: llmutil.ProviderFromViper(), 170 169 DefaultLLMEndpoint: llmutil.EndpointFromViper(), ··· 182 181 AllowHumanPublicSend: viper.GetBool("contacts.human.send.public_enabled"), 183 182 FailureCooldown: contactsFailureCooldown(), 184 183 })) 185 - r.Register(builtin.NewContactsFeedbackUpdateTool(true, statepaths.ContactsDir())) 186 184 } 187 185 188 186 return r
+1
cmd/mistermorph/runcmd/run.go
··· 156 156 return err 157 157 } 158 158 promptprofile.ApplyPersonaIdentity(&promptSpec, logger) 159 + promptprofile.AppendLocalToolNotesBlock(&promptSpec, logger) 159 160 160 161 var memManager *memory.Manager 161 162 var memIdentity memory.Identity
+169 -26
cmd/mistermorph/telegramcmd/command.go
··· 85 85 maepInterestStopThreshold = 0.30 86 86 maepInterestLowRoundsLimit = 2 87 87 maepWrapUpConfidenceThreshold = 0.70 88 + maepFeedbackNegativeThreshold = 0.55 89 + maepFeedbackPositiveThreshold = 0.60 88 90 ) 89 91 90 92 type maepFeedbackClassification struct { ··· 334 336 "reactions_enabled", reactionCfg.Enabled, 335 337 "reactions_allow_count", len(reactionCfg.Allow), 336 338 "group_trigger_mode", groupTriggerMode, 339 + "group_reply_policy", "humanlike", 337 340 "smart_addressing_max_chars", smartAddressingMaxChars, 338 341 "smart_addressing_confidence", smartAddressingConfidence, 339 342 ) ··· 718 721 feedback = classified 719 722 } 720 723 724 + now := time.Now().UTC() 725 + if err := applyMAEPInboundFeedback(context.Background(), contactsSvc, maepSvc, peerID, event.Topic, sessionID, feedback, now); err != nil { 726 + logger.Warn("contacts_feedback_maep_error", "peer_id", peerID, "topic", event.Topic, "error", err.Error()) 727 + } 721 728 maepMu.Lock() 722 - now := time.Now().UTC() 723 729 sessionState := maepSessions[sessionKey] 724 730 sessionState = applyMAEPFeedback(sessionState, feedback) 725 731 nextSessionState, blockedByFeedback, blockedReason := maybeLimitMAEPSessionByFeedback(now, sessionState, feedback, maepSessionCooldown) ··· 936 942 } else { 937 943 typingStop := startTypingTicker(context.Background(), api, chatID, "typing", 4*time.Second) 938 944 initCtx, cancel := context.WithTimeout(context.Background(), initFlowTimeout(requestTimeout)) 939 - questions, err := buildInitQuestions(initCtx, client, model, draft, text) 945 + questions, questionMsg, err := buildInitQuestions(initCtx, client, model, draft, text) 940 946 cancel() 941 947 typingStop() 942 948 if err != nil { ··· 944 950 } 945 951 if len(questions) == 0 { 946 952 questions = defaultInitQuestions(text) 953 + } 954 + if strings.TrimSpace(questionMsg) == "" { 955 + questionMsg = fallbackInitQuestionMessage(questions, text) 947 956 } 948 957 mu.Lock() 949 958 initSessions[chatID] = telegramInitSession{ ··· 951 960 StartedAt: time.Now().UTC(), 952 961 } 953 962 mu.Unlock() 954 - typingStop2 := startTypingTicker(context.Background(), api, chatID, "typing", 4*time.Second) 955 - msgCtx, msgCancel := context.WithTimeout(context.Background(), initFlowTimeout(requestTimeout)) 956 - questionMsg, msgErr := generateInitQuestionMessage(msgCtx, client, model, questions, text) 957 - msgCancel() 958 - typingStop2() 959 - if msgErr != nil { 960 - logger.Warn("telegram_init_question_message_error", "error", msgErr.Error()) 961 - } 962 963 _ = api.sendMessage(context.Background(), chatID, questionMsg, true) 963 964 continue 964 965 } ··· 1210 1211 } 1211 1212 } 1212 1213 if fromUserID > 0 { 1213 - if err := observeTelegramContact(context.Background(), contactsSvc, chatID, chatType, fromUserID, fromUsername, fromFirst, fromLast, fromDisplay, time.Now().UTC()); err != nil { 1214 + observedAt := time.Now().UTC() 1215 + if err := observeTelegramContact(context.Background(), contactsSvc, chatID, chatType, fromUserID, fromUsername, fromFirst, fromLast, fromDisplay, observedAt); err != nil { 1214 1216 logger.Warn("contacts_observe_telegram_error", "chat_id", chatID, "user_id", fromUserID, "error", err.Error()) 1217 + } else if err := applyTelegramInboundFeedback(context.Background(), contactsSvc, chatID, chatType, fromUserID, fromUsername, observedAt); err != nil { 1218 + logger.Warn("contacts_feedback_telegram_error", "chat_id", chatID, "user_id", fromUserID, "error", err.Error()) 1215 1219 } 1216 1220 } 1217 1221 ··· 1364 1368 return nil, nil, nil, nil, err 1365 1369 } 1366 1370 promptprofile.ApplyPersonaIdentity(&promptSpec, logger) 1371 + promptprofile.AppendLocalToolNotesBlock(&promptSpec, logger) 1367 1372 applyChatPersonaRules(&promptSpec) 1368 1373 1369 1374 // Telegram replies are rendered using Telegram Markdown (MarkdownV2 first; fallback to Markdown/plain). ··· 1372 1377 promptSpec.Rules = append(promptSpec.Rules, 1373 1378 "In your final.output string, write for Telegram MarkdownV2 with LIMITED syntax only: *bold*, _italic_, __underline__, ~strikethrough~, ||spoiler||. Avoid inline code, code blocks, or any other Markdown features. If unsure, output plain text. Escape underscores in identifiers (e.g., new\\_york) instead of using backticks.", 1374 1379 ) 1375 - if isGroupChat(job.ChatType) { 1376 - if len(job.MentionUsers) > 0 { 1377 - promptSpec.Blocks = append(promptSpec.Blocks, agent.PromptBlock{ 1378 - Title: "Group Usernames", 1379 - Content: strings.Join(job.MentionUsers, "\n"), 1380 - }) 1381 - promptSpec.Rules = append(promptSpec.Rules, 1382 - "When replying in a group chat and you need to address someone directly, mention their username with @ using only the usernames listed in Group Usernames. Do not invent usernames.", 1383 - ) 1384 - } else { 1385 - promptSpec.Rules = append(promptSpec.Rules, 1386 - "When replying in a group chat, only @mention someone if you are confident of their username from the conversation; otherwise avoid @mentions.", 1387 - ) 1388 - } 1389 - } 1380 + applyTelegramGroupRuntimePromptRules(&promptSpec, job.ChatType, job.MentionUsers, reactionCfg.Enabled) 1390 1381 promptSpec.Rules = append(promptSpec.Rules, 1391 1382 "If you need to send a Telegram voice message: call telegram_send_voice. If you do not already have a voice file path, do NOT ask the user for one; instead call telegram_send_voice without path and provide a short `text` to synthesize from the current context.", 1392 1383 ) ··· 1517 1508 return final, agentCtx, loadedSkills, reaction, nil 1518 1509 } 1519 1510 1511 + func applyTelegramGroupRuntimePromptRules(spec *agent.PromptSpec, chatType string, mentionUsers []string, reactionsEnabled bool) { 1512 + if spec == nil || !isGroupChat(chatType) { 1513 + return 1514 + } 1515 + if len(mentionUsers) > 0 { 1516 + spec.Blocks = append(spec.Blocks, agent.PromptBlock{ 1517 + Title: "Group Usernames", 1518 + Content: strings.Join(mentionUsers, "\n"), 1519 + }) 1520 + spec.Rules = append(spec.Rules, 1521 + "When replying in a group chat and you need to address someone directly, mention their username with @ using only the usernames listed in Group Usernames. Do not invent usernames.", 1522 + ) 1523 + } else { 1524 + spec.Rules = append(spec.Rules, 1525 + "When replying in a group chat, only @mention someone if you are confident of their username from the conversation; otherwise avoid @mentions.", 1526 + ) 1527 + } 1528 + 1529 + notes := []string{ 1530 + "Mode: humanlike", 1531 + "- Participate, but do not dominate the group thread.", 1532 + "- Send text only when it adds clear incremental value beyond prior context.", 1533 + "- If no incremental value, prefer lightweight acknowledgement instead of text.", 1534 + "- Avoid triple-tap: never split one thought across multiple short follow-up messages.", 1535 + } 1536 + spec.Rules = append(spec.Rules, 1537 + "In group chats (humanlike mode), send text only when it adds clear incremental value; otherwise prefer a lightweight acknowledgement.", 1538 + "Never send multiple fragmented follow-up messages for one incoming group message; combine into one concise reply (anti triple-tap).", 1539 + ) 1540 + if reactionsEnabled { 1541 + spec.Rules = append(spec.Rules, 1542 + "In group chats, when there is no clear incremental value in text, prefer telegram_react and do not send extra text.", 1543 + ) 1544 + } else { 1545 + spec.Rules = append(spec.Rules, 1546 + "In group chats, when there is no clear incremental value in text, keep acknowledgement minimal and avoid extra chatter.", 1547 + ) 1548 + } 1549 + spec.Blocks = append(spec.Blocks, agent.PromptBlock{ 1550 + Title: "Group Reply Policy", 1551 + Content: strings.Join(notes, "\n"), 1552 + }) 1553 + } 1554 + 1520 1555 func runMAEPTask(ctx context.Context, logger *slog.Logger, logOpts agent.LogOptions, client llm.Client, baseReg *tools.Registry, sharedGuard *guard.Guard, cfg agent.Config, model string, peerID string, memManager *memory.Manager, history []llm.Message, stickySkills []string, task string) (*agent.Final, *agent.Context, []string, error) { 1521 1556 if strings.TrimSpace(task) == "" { 1522 1557 return nil, nil, nil, fmt.Errorf("empty maep task") ··· 1532 1567 return nil, nil, nil, err 1533 1568 } 1534 1569 promptprofile.ApplyPersonaIdentity(&promptSpec, logger) 1570 + promptprofile.AppendLocalToolNotesBlock(&promptSpec, logger) 1535 1571 applyChatPersonaRules(&promptSpec) 1536 1572 // applyMAEPReplyPromptRules(&promptSpec) 1537 1573 if memManager != nil && viper.GetBool("memory.injection.enabled") { ··· 2955 2991 MatchedAliasKeyword string 2956 2992 } 2957 2993 2994 + // groupTriggerDecision belongs to the trigger layer. 2995 + // It decides only whether this group message should enter an agent run and what task text to pass. 2996 + // It must not decide output modality (text reply vs reaction), which is handled in the generation layer. 2958 2997 func groupTriggerDecision(msg *telegramMessage, botUser string, botID int64, aliases []string, mode string, aliasPrefixMaxChars int) (telegramGroupTriggerDecision, bool) { 2959 2998 if msg == nil { 2960 2999 return telegramGroupTriggerDecision{}, false ··· 3393 3432 } 3394 3433 state.UpdatedAt = now 3395 3434 return state, true 3435 + } 3436 + 3437 + func applyMAEPInboundFeedback( 3438 + ctx context.Context, 3439 + contactsSvc *contacts.Service, 3440 + maepSvc *maep.Service, 3441 + peerID string, 3442 + inboundTopic string, 3443 + sessionID string, 3444 + feedback maepFeedbackClassification, 3445 + now time.Time, 3446 + ) error { 3447 + if contactsSvc == nil { 3448 + return nil 3449 + } 3450 + contact, found, err := lookupMAEPBusinessContact(ctx, maepSvc, contactsSvc, peerID) 3451 + if err != nil { 3452 + return err 3453 + } 3454 + if !found { 3455 + return nil 3456 + } 3457 + signal, endSession, reason := maepFeedbackToContactSignal(feedback) 3458 + sessionID = strings.TrimSpace(sessionID) 3459 + if sessionID == "" { 3460 + sessionID = strings.TrimSpace(peerID) 3461 + } 3462 + if sessionID == "" { 3463 + sessionID = strings.TrimSpace(contact.ContactID) 3464 + } 3465 + if now.IsZero() { 3466 + now = time.Now().UTC() 3467 + } else { 3468 + now = now.UTC() 3469 + } 3470 + _, _, err = contactsSvc.UpdateFeedback(ctx, now, contacts.FeedbackUpdateInput{ 3471 + ContactID: contact.ContactID, 3472 + SessionID: sessionID, 3473 + Signal: signal, 3474 + Topic: normalizeInboundFeedbackTopic(inboundTopic, "maep.inbound"), 3475 + Reason: reason, 3476 + EndSession: endSession, 3477 + }) 3478 + return err 3479 + } 3480 + 3481 + func maepFeedbackToContactSignal(feedback maepFeedbackClassification) (contacts.FeedbackSignal, bool, string) { 3482 + feedback = normalizeMAEPFeedback(feedback) 3483 + if feedback.NextAction == "wrap_up" && feedback.Confidence >= maepWrapUpConfidenceThreshold { 3484 + return contacts.FeedbackNegative, true, "maep_feedback_wrap_up" 3485 + } 3486 + if feedback.SignalNegative >= maepFeedbackNegativeThreshold || feedback.SignalBored >= maepFeedbackNegativeThreshold { 3487 + return contacts.FeedbackNegative, false, "maep_feedback_negative" 3488 + } 3489 + if feedback.SignalPositive >= maepFeedbackPositiveThreshold && feedback.SignalPositive > feedback.SignalNegative { 3490 + return contacts.FeedbackPositive, false, "maep_feedback_positive" 3491 + } 3492 + return contacts.FeedbackNeutral, false, "maep_feedback_neutral" 3493 + } 3494 + 3495 + func applyTelegramInboundFeedback( 3496 + ctx context.Context, 3497 + svc *contacts.Service, 3498 + chatID int64, 3499 + chatType string, 3500 + userID int64, 3501 + username string, 3502 + now time.Time, 3503 + ) error { 3504 + if svc == nil || userID <= 0 || chatID == 0 { 3505 + return nil 3506 + } 3507 + contactID := telegramMemoryContactID(username, userID) 3508 + if strings.TrimSpace(contactID) == "" { 3509 + return nil 3510 + } 3511 + if now.IsZero() { 3512 + now = time.Now().UTC() 3513 + } else { 3514 + now = now.UTC() 3515 + } 3516 + _, _, err := svc.UpdateFeedback(ctx, now, contacts.FeedbackUpdateInput{ 3517 + ContactID: contactID, 3518 + SessionID: fmt.Sprintf("telegram:%d", chatID), 3519 + Signal: contacts.FeedbackPositive, 3520 + Topic: normalizeInboundFeedbackTopic("telegram."+strings.ToLower(strings.TrimSpace(chatType))+".inbound", "telegram.inbound"), 3521 + Reason: "telegram_inbound_message", 3522 + }) 3523 + return err 3524 + } 3525 + 3526 + func normalizeInboundFeedbackTopic(raw string, fallback string) string { 3527 + value := strings.ToLower(strings.TrimSpace(raw)) 3528 + if value == "" { 3529 + return strings.ToLower(strings.TrimSpace(fallback)) 3530 + } 3531 + for strings.Contains(value, "..") { 3532 + value = strings.ReplaceAll(value, "..", ".") 3533 + } 3534 + value = strings.Trim(value, ".") 3535 + if value == "" { 3536 + return strings.ToLower(strings.TrimSpace(fallback)) 3537 + } 3538 + return value 3396 3539 } 3397 3540 3398 3541 func clampUnit(v float64) float64 {
+185
cmd/mistermorph/telegramcmd/group_policy_test.go
··· 1 + package telegramcmd 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "strings" 8 + "testing" 9 + "time" 10 + 11 + "github.com/quailyquaily/mistermorph/agent" 12 + "github.com/quailyquaily/mistermorph/llm" 13 + "github.com/quailyquaily/mistermorph/tools" 14 + ) 15 + 16 + func TestGroupTriggerDecision_ReplyPath(t *testing.T) { 17 + msg := &telegramMessage{ 18 + Text: "please continue", 19 + ReplyTo: &telegramMessage{ 20 + From: &telegramUser{ID: 42}, 21 + }, 22 + } 23 + dec, ok := groupTriggerDecision(msg, "morphbot", 42, nil, "smart", 24) 24 + if !ok { 25 + t.Fatalf("expected trigger for reply-to-bot") 26 + } 27 + if dec.Reason != "reply" { 28 + t.Fatalf("unexpected reason: %q", dec.Reason) 29 + } 30 + if strings.TrimSpace(dec.TaskText) != "please continue" { 31 + t.Fatalf("unexpected task_text: %q", dec.TaskText) 32 + } 33 + } 34 + 35 + func TestGroupTriggerDecision_StrictIgnoresAlias(t *testing.T) { 36 + msg := &telegramMessage{ 37 + Text: "morph can you check this", 38 + } 39 + _, ok := groupTriggerDecision(msg, "morphbot", 42, []string{"morph"}, "strict", 24) 40 + if ok { 41 + t.Fatalf("strict mode should ignore alias-only trigger") 42 + } 43 + } 44 + 45 + func TestGroupTriggerDecision_MentionEntityTriggers(t *testing.T) { 46 + msg := &telegramMessage{ 47 + Text: "@morphbot please check", 48 + Entities: []telegramEntity{ 49 + {Type: "mention", Offset: 0, Length: 9}, 50 + }, 51 + } 52 + dec, ok := groupTriggerDecision(msg, "morphbot", 42, nil, "strict", 24) 53 + if !ok { 54 + t.Fatalf("mention entity should trigger") 55 + } 56 + if dec.Reason != "mention_entity" { 57 + t.Fatalf("unexpected reason: %q", dec.Reason) 58 + } 59 + if strings.Contains(strings.ToLower(dec.TaskText), "@morphbot") { 60 + t.Fatalf("task_text should strip bot mention: %q", dec.TaskText) 61 + } 62 + } 63 + 64 + func TestGroupTriggerDecision_SmartAliasUncertainHintsAddressingLLM(t *testing.T) { 65 + msg := &telegramMessage{ 66 + Text: "let us use morphism to describe this", 67 + } 68 + dec, ok := groupTriggerDecision(msg, "morphbot", 42, []string{"morph"}, "smart", 24) 69 + if ok { 70 + t.Fatalf("uncertain alias should not trigger directly") 71 + } 72 + if !dec.NeedsAddressingLLM { 73 + t.Fatalf("expected NeedsAddressingLLM for uncertain alias hit") 74 + } 75 + if !strings.HasPrefix(dec.Reason, "alias_uncertain:") { 76 + t.Fatalf("unexpected reason: %q", dec.Reason) 77 + } 78 + } 79 + 80 + func TestApplyTelegramGroupRuntimePromptRules_GroupOnly(t *testing.T) { 81 + groupSpec := agent.PromptSpec{} 82 + applyTelegramGroupRuntimePromptRules(&groupSpec, "group", []string{"@alice"}, true) 83 + if !hasPromptBlockTitle(groupSpec.Blocks, "Group Reply Policy") { 84 + t.Fatalf("group chat should include Group Reply Policy block") 85 + } 86 + if !hasRuleContaining(groupSpec.Rules, "anti triple-tap") { 87 + t.Fatalf("group chat should include anti triple-tap rule") 88 + } 89 + if !hasRuleContaining(groupSpec.Rules, "prefer telegram_react") { 90 + t.Fatalf("group chat should include reaction preference rule") 91 + } 92 + 93 + privateSpec := agent.PromptSpec{} 94 + applyTelegramGroupRuntimePromptRules(&privateSpec, "private", []string{"@alice"}, true) 95 + if len(privateSpec.Blocks) != 0 || len(privateSpec.Rules) != 0 { 96 + t.Fatalf("non-group chat must not inject group policy rules") 97 + } 98 + } 99 + 100 + func TestRunTelegramTask_PreflightReactionNoTextReply(t *testing.T) { 101 + var reactionCalls int 102 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 103 + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/setMessageReaction") { 104 + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) 105 + } 106 + reactionCalls++ 107 + w.Header().Set("Content-Type", "application/json") 108 + _, _ = w.Write([]byte(`{"ok":true}`)) 109 + })) 110 + defer srv.Close() 111 + 112 + api := newTelegramAPI(srv.Client(), srv.URL, "TOKEN") 113 + cfg := agent.Config{ 114 + IntentEnabled: true, 115 + IntentTimeout: 5 * time.Second, 116 + IntentMaxHistory: 3, 117 + } 118 + final, _, _, reaction, err := runTelegramTask( 119 + context.Background(), 120 + nil, 121 + agent.LogOptions{}, 122 + &staticIntentClient{}, 123 + tools.NewRegistry(), 124 + api, 125 + false, 126 + t.TempDir(), 127 + 0, 128 + nil, 129 + cfg, 130 + telegramReactionConfig{Enabled: true, Allow: defaultReactionAllowList()}, 131 + nil, 132 + telegramJob{ 133 + ChatID: 1001, 134 + MessageID: 2002, 135 + ChatType: "group", 136 + Text: "ok", 137 + }, 138 + "test-model", 139 + nil, 140 + nil, 141 + 5*time.Second, 142 + ) 143 + if err != nil { 144 + t.Fatalf("runTelegramTask() error = %v", err) 145 + } 146 + if final != nil { 147 + t.Fatalf("expected no text final when preflight reaction succeeds") 148 + } 149 + if reaction == nil { 150 + t.Fatalf("expected reaction result") 151 + } 152 + if reaction.Source != "preflight" { 153 + t.Fatalf("unexpected reaction source: %q", reaction.Source) 154 + } 155 + if reactionCalls != 1 { 156 + t.Fatalf("expected exactly one reaction API call, got %d", reactionCalls) 157 + } 158 + } 159 + 160 + type staticIntentClient struct{} 161 + 162 + func (c *staticIntentClient) Chat(ctx context.Context, req llm.Request) (llm.Result, error) { 163 + return llm.Result{ 164 + Text: `{"goal":"acknowledge","deliverable":"确认","constraints":[],"ambiguities":[],"question":false,"request":false,"ask":false}`, 165 + }, nil 166 + } 167 + 168 + func hasPromptBlockTitle(blocks []agent.PromptBlock, want string) bool { 169 + for _, block := range blocks { 170 + if strings.EqualFold(strings.TrimSpace(block.Title), strings.TrimSpace(want)) { 171 + return true 172 + } 173 + } 174 + return false 175 + } 176 + 177 + func hasRuleContaining(rules []string, snippet string) bool { 178 + snippet = strings.ToLower(strings.TrimSpace(snippet)) 179 + for _, rule := range rules { 180 + if strings.Contains(strings.ToLower(rule), snippet) { 181 + return true 182 + } 183 + } 184 + return false 185 + }
+14 -47
cmd/mistermorph/telegramcmd/init_flow.go
··· 34 34 35 35 type initQuestionsOutput struct { 36 36 Questions []string `json:"questions"` 37 + Message string `json:"message"` 37 38 } 38 39 39 40 type initFillOutput struct { ··· 117 118 return nil 118 119 } 119 120 120 - func buildInitQuestions(ctx context.Context, client llm.Client, model string, draft initProfileDraft, userText string) ([]string, error) { 121 + func buildInitQuestions(ctx context.Context, client llm.Client, model string, draft initProfileDraft, userText string) ([]string, string, error) { 122 + defaultQuestions := defaultInitQuestions(userText) 123 + defaultMessage := fallbackInitQuestionMessage(defaultQuestions, userText) 121 124 if client == nil || strings.TrimSpace(model) == "" { 122 - return defaultInitQuestions(userText), nil 125 + return defaultQuestions, defaultMessage, nil 123 126 } 124 127 payload := map[string]any{ 125 128 "identity_markdown": draft.IdentityRaw, ··· 133 136 } 134 137 systemPrompt, userPrompt, err := renderInitQuestionsPrompts(payload) 135 138 if err != nil { 136 - return defaultInitQuestions(userText), err 139 + return defaultQuestions, defaultMessage, err 137 140 } 138 141 139 142 res, err := client.Chat(ctx, llm.Request{ ··· 149 152 }, 150 153 }) 151 154 if err != nil { 152 - return defaultInitQuestions(userText), err 155 + return defaultQuestions, defaultMessage, err 153 156 } 154 157 155 158 var out initQuestionsOutput 156 159 if err := jsonutil.DecodeWithFallback(strings.TrimSpace(res.Text), &out); err != nil { 157 - return defaultInitQuestions(userText), err 160 + return defaultQuestions, defaultMessage, err 158 161 } 159 162 questions := normalizeInitQuestions(out.Questions) 160 163 if len(questions) == 0 { 161 - questions = defaultInitQuestions(userText) 164 + questions = defaultQuestions 165 + } 166 + message := strings.TrimSpace(out.Message) 167 + if message == "" { 168 + message = fallbackInitQuestionMessage(questions, userText) 162 169 } 163 - return questions, nil 170 + return questions, message, nil 164 171 } 165 172 166 173 func applyInitFromAnswer(ctx context.Context, client llm.Client, model string, draft initProfileDraft, session telegramInitSession, answer string, username string, displayName string) (initApplyResult, error) { ··· 248 255 return fmt.Sprintf("Hi, I’m %s. Great to meet you. Let’s keep going.", name) 249 256 } 250 257 return "Hi. Great to meet you. Let’s keep going." 251 - } 252 - 253 - func generateInitQuestionMessage(ctx context.Context, client llm.Client, model string, questions []string, userText string) (string, error) { 254 - normalized := normalizeInitQuestions(questions) 255 - if len(normalized) == 0 { 256 - normalized = defaultInitQuestions(userText) 257 - } 258 - if client == nil || strings.TrimSpace(model) == "" { 259 - return fallbackInitQuestionMessage(normalized, userText), nil 260 - } 261 - 262 - payload := map[string]any{ 263 - "user_text": strings.TrimSpace(userText), 264 - "questions": normalized, 265 - } 266 - systemPrompt, userPrompt, err := renderInitQuestionMessagePrompts(payload) 267 - if err != nil { 268 - return fallbackInitQuestionMessage(normalized, userText), err 269 - } 270 - 271 - res, err := client.Chat(ctx, llm.Request{ 272 - Model: strings.TrimSpace(model), 273 - ForceJSON: false, 274 - Messages: []llm.Message{ 275 - {Role: "system", Content: systemPrompt}, 276 - {Role: "user", Content: userPrompt}, 277 - }, 278 - Parameters: map[string]any{ 279 - "temperature": 0.7, 280 - "max_tokens": 280, 281 - }, 282 - }) 283 - if err != nil { 284 - return fallbackInitQuestionMessage(normalized, userText), err 285 - } 286 - text := strings.TrimSpace(res.Text) 287 - if text == "" { 288 - return fallbackInitQuestionMessage(normalized, userText), nil 289 - } 290 - return text, nil 291 258 } 292 259 293 260 func fallbackInitQuestionMessage(questions []string, userText string) string {
-20
cmd/mistermorph/telegramcmd/init_prompts.go
··· 20 20 //go:embed prompts/init_fill_user.tmpl 21 21 var initFillUserPromptTemplateSource string 22 22 23 - //go:embed prompts/init_question_message_system.tmpl 24 - var initQuestionMessageSystemPromptTemplateSource string 25 - 26 - //go:embed prompts/init_question_message_user.tmpl 27 - var initQuestionMessageUserPromptTemplateSource string 28 - 29 23 //go:embed prompts/init_post_greeting_system.tmpl 30 24 var initPostGreetingSystemPromptTemplateSource string 31 25 ··· 46 40 var initQuestionsUserPromptTemplate = prompttmpl.MustParse("telegram_init_questions_user", initQuestionsUserPromptTemplateSource, initPromptTemplateFuncs) 47 41 var initFillSystemPromptTemplate = prompttmpl.MustParse("telegram_init_fill_system", initFillSystemPromptTemplateSource, nil) 48 42 var initFillUserPromptTemplate = prompttmpl.MustParse("telegram_init_fill_user", initFillUserPromptTemplateSource, initPromptTemplateFuncs) 49 - var initQuestionMessageSystemPromptTemplate = prompttmpl.MustParse("telegram_init_question_message_system", initQuestionMessageSystemPromptTemplateSource, nil) 50 - var initQuestionMessageUserPromptTemplate = prompttmpl.MustParse("telegram_init_question_message_user", initQuestionMessageUserPromptTemplateSource, initPromptTemplateFuncs) 51 43 var initPostGreetingSystemPromptTemplate = prompttmpl.MustParse("telegram_init_post_greeting_system", initPostGreetingSystemPromptTemplateSource, nil) 52 44 var initPostGreetingUserPromptTemplate = prompttmpl.MustParse("telegram_init_post_greeting_user", initPostGreetingUserPromptTemplateSource, initPromptTemplateFuncs) 53 45 ··· 69 61 return "", "", err 70 62 } 71 63 userPrompt, err := prompttmpl.Render(initFillUserPromptTemplate, payload) 72 - if err != nil { 73 - return "", "", err 74 - } 75 - return systemPrompt, userPrompt, nil 76 - } 77 - 78 - func renderInitQuestionMessagePrompts(payload map[string]any) (string, string, error) { 79 - systemPrompt, err := prompttmpl.Render(initQuestionMessageSystemPromptTemplate, struct{}{}) 80 - if err != nil { 81 - return "", "", err 82 - } 83 - userPrompt, err := prompttmpl.Render(initQuestionMessageUserPromptTemplate, payload) 84 64 if err != nil { 85 65 return "", "", err 86 66 }
+3 -17
cmd/mistermorph/telegramcmd/init_prompts_test.go
··· 20 20 if !strings.Contains(sys, "Return JSON only") { 21 21 t.Fatalf("system prompt missing contract: %q", sys) 22 22 } 23 + if !strings.Contains(sys, "\"message\"") { 24 + t.Fatalf("system prompt missing message contract: %q", sys) 25 + } 23 26 if !strings.Contains(user, "\"user_text\":\"hello\"") { 24 27 t.Fatalf("user prompt missing payload: %q", user) 25 28 } ··· 38 41 } 39 42 if !strings.Contains(user, "\"user_answer\":\"I want you to be concise.\"") { 40 43 t.Fatalf("user prompt missing payload: %q", user) 41 - } 42 - } 43 - 44 - func TestRenderInitQuestionMessagePrompts(t *testing.T) { 45 - payload := map[string]any{ 46 - "user_text": "Hi", 47 - "questions": []string{"Q1", "Q2"}, 48 - } 49 - sys, user, err := renderInitQuestionMessagePrompts(payload) 50 - if err != nil { 51 - t.Fatalf("renderInitQuestionMessagePrompts() error = %v", err) 52 - } 53 - if !strings.Contains(sys, "persona-setup questions") { 54 - t.Fatalf("system prompt missing intent: %q", sys) 55 - } 56 - if !strings.Contains(user, "\"questions\":[\"Q1\",\"Q2\"]") { 57 - t.Fatalf("user prompt missing questions: %q", user) 58 44 } 59 45 } 60 46
-5
cmd/mistermorph/telegramcmd/prompts/init_question_message_system.tmpl
··· 1 - You are sending a Telegram private message that asks persona-setup questions. 2 - Use the same language as user_text. 3 - Write naturally and conversationally, not as a workflow/status message. 4 - Do not mention initialization, files, status fields, or internal process. 5 - Ask the listed questions clearly and invite user to answer in one reply.
-1
cmd/mistermorph/telegramcmd/prompts/init_question_message_user.tmpl
··· 1 - {{ toJSON . }}
+9 -4
cmd/mistermorph/telegramcmd/prompts/init_questions_system.tmpl
··· 1 1 You design onboarding questions for an assistant persona bootstrap. 2 - Return JSON only: {"questions":[string,...]}. 2 + Return JSON only: {"questions":[string,...],"message":"string"}. 3 3 Use the same language as user_text for all questions. 4 - Questions must collect enough info to fill identity Name/Creature/Vibe/Emoji and soul Core Truths/Boundaries/Vibe. 5 - If info is likely missing, ask preference-oriented questions that allow reasonable inference. 6 - Do not include explanations, only concise questions. 4 + Generate natural, human-sounding questions as if one person is chatting with another. 5 + Avoid form-like phrasing, field labels, or checklist wording. 6 + Questions must still collect enough info to fill identity Name/Creature/Vibe/Emoji and soul Core Truths/Boundaries/Vibe. 7 + Prefer open, preference-oriented wording that enables reasonable inference when direct answers are missing. 8 + Each question should focus on one idea, be concise, and avoid repetitive sentence patterns. 9 + Keep the questions list within question_count.min..question_count.max. 10 + Also write a single Telegram-ready message that asks these questions naturally and invites the user to answer in one reply. 11 + Do not mention initialization, files, status fields, or internal process.
+3
docs/feat/feat_20260205_telegram_reactions.md
··· 93 93 - 若输出为 reaction,则不发送文本。 94 94 - 若 reaction 失败,自动 fallback 为文本。 95 95 - 先跑 intent 推断,再判断是否适合轻回复(reaction)。不适合则走正常文本输出。 96 + - 职责边界(Telegram 群聊): 97 + - 触发层(`groupTriggerDecision`)只决定“是否进入 agent run”。 98 + - 生成层(`runTelegramTask` prompt + tool)只决定“文本回复 vs reaction”。 96 99 97 100 ### 5.3 工具与规则 98 101 在系统 prompt 里新增规则:
+49 -51
docs/feat/feat_20260208_workspace_persona_and_contacts_profile.md
··· 45 45 46 46 ### A.2 任务拆解 47 47 48 - - [ ] 新增模板 `assets/config/TOOLS.md` 48 + - [x] 新增模板 `assets/config/TOOLS.md` 49 49 - 内容定位:本地环境映射、示例、与 skills 分离原则。 50 - - [ ] 安装流程补充 `TOOLS.md` 50 + - [x] 安装流程补充 `TOOLS.md` 51 51 - 文件:`cmd/mistermorph/install.go` 52 52 - 补充 loader:`loadToolsTemplate()` 53 53 - 在 `filePlans` 中新增 `TOOLS.md`(存在则跳过)。 54 - - [ ] prompt 组装链路补充 `TOOLS.md` 注入 54 + - [x] prompt 组装链路补充 `TOOLS.md` 注入 55 55 - 建议方式:新增 `internal/promptprofile/context.go`(或在现有 `identity.go` 扩展),读取 `TOOLS.md` 后以 `PromptBlock` 注入,而不是覆盖 `spec.Identity`。 56 56 - block title 建议:`Local Tool Notes`。 57 - - [ ] 文档更新 57 + - [x] 文档更新 58 58 - `docs/prompt.md`:增加 “TOOLS.md context block” 的来源与注入时机。 59 59 60 60 ### A.3 关键决策点 61 61 62 - - [ ] 决定 `TOOLS.md` 读取路径基准(`file_state_dir` 或 workspace root)并统一到代码和安装行为。 63 - - [ ] 约束最大注入长度(例如 4KB/8KB)避免 prompt 膨胀。 62 + - [x] 决定 `TOOLS.md` 读取路径基准(`file_state_dir` 或 workspace root)并统一到代码和安装行为。 63 + - [x] 约束最大注入长度(例如 4KB/8KB)避免 prompt 膨胀。 64 64 65 65 ### A.4 验收标准 66 66 ··· 71 71 72 72 ### A.5 测试清单 73 73 74 - - [ ] `cmd/mistermorph/install_test.go`:新增 `loadToolsTemplate` 相关测试。 75 - - [ ] `internal/promptprofile/*_test.go`:验证 `TOOLS.md` 缺失/空/有效内容注入行为。 74 + - [x] `cmd/mistermorph/install_test.go`:新增 `loadToolsTemplate` 相关测试。 75 + - [x] `internal/promptprofile/*_test.go`:验证 `TOOLS.md` 缺失/空/有效内容注入行为。 76 76 77 77 ## 4) 工作流 B: 群聊发言规则增强 78 78 ··· 82 82 83 83 ### B.2 任务拆解 84 84 85 - - [ ] 提炼群聊规则并注入 Telegram prompt 85 + - [x] 提炼群聊规则并注入 Telegram prompt 86 86 - 文件:`cmd/mistermorph/telegramcmd/command.go` 87 87 - 注入点:`runTelegramTask(...)` 的群聊分支(现有 `isGroupChat(...)` 附近)。 88 88 - 规则覆盖: 89 89 - 仅在被点名/被提问/确有增量价值时发文本; 90 90 - 无增量价值时优先 emoji reaction; 91 91 - 同一条消息不多次碎片化回复(anti triple-tap)。 92 - - [ ] humanlike 应该为 telegram 群聊下的默认策略 93 - - [ ] 触发层与生成层职责分离 92 + - [x] humanlike 应该为 telegram 群聊下的默认策略 93 + - [x] 触发层与生成层职责分离 94 94 - 触发层继续决定“是否进入 agent run”(现有 `groupTriggerDecision`)。 95 95 - 生成层决定“文本回复 vs reaction”。 96 - - [ ] 更新文档 96 + - [x] 更新文档 97 97 - `docs/prompt.md`:补充 Telegram 群聊行为规则来源。 98 98 - `docs/feat/feat_20260205_telegram_reactions.md`:补充与 reaction 策略的关系。 99 99 ··· 105 105 106 106 ### B.4 测试清单 107 107 108 - - [ ] `groupTriggerDecision` 相关单测补充(点名/回复/普通闲聊路径)。 109 - - [ ] prompt rules 单测:群聊时规则存在,私聊时不注入群聊规则。 110 - - [ ] reaction 相关回归:可 reaction 时不发送冗余文本。 108 + - [x] `groupTriggerDecision` 相关单测补充(点名/回复/普通闲聊路径)。 109 + - [x] prompt rules 单测:群聊时规则存在,私聊时不注入群聊规则。 110 + - [x] reaction 相关回归:可 reaction 时不发送冗余文本。 111 111 112 112 ## 5) 工作流 C: contacts 画像字段扩展 113 113 ··· 237 237 238 238 ## 8) 开放问题(实现前确认) 239 239 240 - - [ ] `TOOLS.md` 与 `IDENTITY/SOUL` 的统一路径基准最终选哪一个? 240 + - [x] `TOOLS.md` 与 `IDENTITY/SOUL` 的统一路径基准最终选哪一个?(已定:统一按 `file_state_dir` 读取) 241 241 - [ ] `timezone` 非法值策略:拒绝写入 vs 容忍写入并打告警? 242 242 - [ ] `preference_context` 是否需要单独“摘要字段”供公共场景安全注入? 243 243 ··· 245 245 246 246 ### PR-1: `TOOLS.md` 模板 + 安装 + Prompt 注入 247 247 248 - - [ ] 新增文件 `assets/config/TOOLS.md`,写入本地环境笔记模板(含示例与边界说明)。 249 - - [ ] 更新 `cmd/mistermorph/install.go`: 250 - - [ ] 增加 `loadToolsTemplate()`。 251 - - [ ] 在 `filePlans` 中增加 `TOOLS.md`。 252 - - [ ] 保持“已存在即跳过”行为与现有模板一致。 253 - - [ ] 更新 `cmd/mistermorph/install_test.go`: 254 - - [ ] 增加 `loadToolsTemplate()` 返回非空与标题校验。 255 - - [ ] 更新 prompt 组装逻辑(建议新建 `internal/promptprofile/context.go`): 256 - - [ ] 读取 `TOOLS.md`(与 `IDENTITY/SOUL` 同路径基准)。 257 - - [ ] 当文件非空时注入 `PromptBlock{Title: "Local Tool Notes"}`。 258 - - [ ] 增加注入内容长度上限(避免 prompt 膨胀)。 259 - - [ ] 更新 `docs/prompt.md`: 260 - - [ ] 增加 `TOOLS.md` 注入来源与时机说明。 261 - - [ ] 验证: 262 - - [ ] `go test ./cmd/mistermorph/... ./internal/promptprofile/...` 248 + - [x] 新增文件 `assets/config/TOOLS.md`,写入本地环境笔记模板(含示例与边界说明)。 249 + - [x] 更新 `cmd/mistermorph/install.go`: 250 + - [x] 增加 `loadToolsTemplate()`。 251 + - [x] 在 `filePlans` 中增加 `TOOLS.md`。 252 + - [x] 保持“已存在即跳过”行为与现有模板一致。 253 + - [x] 更新 `cmd/mistermorph/install_test.go`: 254 + - [x] 增加 `loadToolsTemplate()` 返回非空与标题校验。 255 + - [x] 更新 prompt 组装逻辑(建议新建 `internal/promptprofile/context.go`): 256 + - [x] 读取 `TOOLS.md`(与 `IDENTITY/SOUL` 同路径基准)。 257 + - [x] 当文件非空时注入 `PromptBlock{Title: "Local Tool Notes"}`。 258 + - [x] 增加注入内容长度上限(避免 prompt 膨胀)。 259 + - [x] 更新 `docs/prompt.md`: 260 + - [x] 增加 `TOOLS.md` 注入来源与时机说明。 261 + - [x] 验证: 262 + - [x] `go test ./cmd/mistermorph/... ./internal/promptprofile/...` 263 263 264 264 ### PR-2: 群聊规则增强(触发层/生成层分离) 265 265 266 - - [ ] 明确触发层职责(只决定是否 run): 267 - - [ ] 在 `cmd/mistermorph/telegramcmd/command.go` 注释/文档化 `groupTriggerDecision(...)`。 268 - - [ ] 禁止触发层承载“文本还是 reaction”的策略。 269 - - [ ] 明确生成层职责(只决定响应形式): 270 - - [ ] 在 `runTelegramTask(...)` 增加群聊规则 prompt 条款: 271 - - [ ] 仅在有增量价值时发文本。 272 - - [ ] 无增量价值优先 reaction。 273 - - [ ] 禁止同消息多次碎片化回复(anti triple-tap)。 274 - - [ ] 增加配置项: 275 - - [ ] `telegram.group.reply_policy`(建议:`strict|humanlike`)。 276 - - [ ] 默认设为 `humanlike`(群聊)。 277 - - [ ] 更新文档: 278 - - [ ] `docs/prompt.md` 补充群聊行为规则注入点。 279 - - [ ] `docs/feat/feat_20260205_telegram_reactions.md` 补充与 reaction 的职责边界。 280 - - [ ] 测试: 281 - - [ ] 补充 `groupTriggerDecision` 触发路径测试。 282 - - [ ] 补充“群聊规则只在群聊注入”的测试。 283 - - [ ] 回归 reaction 场景,确保不发送冗余文本。 284 - - [ ] 验证: 285 - - [ ] `go test ./cmd/mistermorph/telegramcmd/...` 266 + - [x] 明确触发层职责(只决定是否 run): 267 + - [x] 在 `cmd/mistermorph/telegramcmd/command.go` 注释/文档化 `groupTriggerDecision(...)`。 268 + - [x] 禁止触发层承载“文本还是 reaction”的策略。 269 + - [x] 明确生成层职责(只决定响应形式): 270 + - [x] 在 `runTelegramTask(...)` 增加群聊规则 prompt 条款: 271 + - [x] 仅在有增量价值时发文本。 272 + - [x] 无增量价值优先 reaction。 273 + - [x] 禁止同消息多次碎片化回复(anti triple-tap)。 274 + - [x] 群聊 reply policy 固定为 `humanlike`(不开放配置项)。 275 + - [x] 更新文档: 276 + - [x] `docs/prompt.md` 补充群聊行为规则注入点。 277 + - [x] `docs/feat/feat_20260205_telegram_reactions.md` 补充与 reaction 的职责边界。 278 + - [x] 测试: 279 + - [x] 补充 `groupTriggerDecision` 触发路径测试。 280 + - [x] 补充“群聊规则只在群聊注入”的测试。 281 + - [x] 回归 reaction 场景,确保不发送冗余文本。 282 + - [x] 验证: 283 + - [x] `go test ./cmd/mistermorph/telegramcmd/...` 286 284 287 285 ### PR-3: `contacts` 字段扩展(`pronouns/timezone/preference_context`) 288 286
+26 -25
docs/prompt.md
··· 7 7 - Base spec starts from `agent.DefaultPromptSpec()` in `agent/prompt.go`. 8 8 - Persona identity is optionally **overridden** by `promptprofile.ApplyPersonaIdentity(...)` in `internal/promptprofile/identity.go`. 9 9 - If local `IDENTITY.md` / `SOUL.md` are loaded and not `status: draft`, `spec.Identity` is replaced. 10 + - Local tool/workspace notes are optionally appended by `promptprofile.AppendLocalToolNotesBlock(...)` in `internal/promptprofile/context.go`. 11 + - If local `TOOLS.md` (under `file_state_dir`) is non-empty, it is injected as `PromptBlock{Title: "Local Tool Notes"}`. 12 + - Injection is size-limited to 8192 bytes (fixed constant). 10 13 - Runtime prompt blocks/rules are then appended: 11 14 - URL/task-specific rules (`agent/prompt_rules.go`) 12 15 - Registry-aware rules (`agent/prompt_rules.go`, e.g. `plan_create` rules only when tool exists) ··· 35 38 - Definition: 36 39 - `ApplyPersonaIdentity(...)` loads local persona docs and may replace `spec.Identity` 37 40 41 + ### 2.5) Local tool-notes injection 42 + 43 + - File: `internal/promptprofile/context.go` 44 + - Definition: 45 + - `AppendLocalToolNotesBlock(...)` loads local `TOOLS.md` from `file_state_dir` 46 + - When non-empty, appends `PromptBlock{Title: "Local Tool Notes"}` 47 + - Content is truncated by configured byte limit before injection 48 + 38 49 ### 3) Task-based dynamic rules (URL-aware) 39 50 40 51 - File: `agent/prompt_rules.go` ··· 62 73 - File: `cmd/mistermorph/telegramcmd/command.go` 63 74 - Definition: 64 75 - Appends Telegram-specific rules (MarkdownV2 style limits, mention behavior, voice/reaction guidance, optional memory/group blocks) 76 + - In group chats, injects fixed `humanlike` policy rules: 77 + - text only when there is incremental value 78 + - prefer reaction for lightweight acknowledgement 79 + - avoid fragmented multi-message "triple-tap" replies 65 80 66 81 ### 7) Heartbeat task prompt template 67 82 ··· 98 113 | `telegramcmd/prompts/init_questions_user.tmpl` | user | Carries draft identity/soul context, user text, and required target fields for init question generation. | 99 114 | `telegramcmd/prompts/init_fill_system.tmpl` | system | Defines output contract for Telegram persona field filling. | 100 115 | `telegramcmd/prompts/init_fill_user.tmpl` | user | Carries draft identity/soul content, user answers, and Telegram context for persona field filling. | 101 - | `telegramcmd/prompts/init_question_message_system.tmpl` | system | Defines style/constraints for natural Telegram init question messages. | 102 - | `telegramcmd/prompts/init_question_message_user.tmpl` | user | Carries user text plus normalized question list for init question message generation. | 103 116 | `telegramcmd/prompts/init_post_greeting_system.tmpl` | system | Defines style/constraints for immediate post-init Telegram greeting generation. | 104 117 | `telegramcmd/prompts/init_post_greeting_user.tmpl` | user | Carries finalized identity/soul markdown plus init context for post-init greeting generation. | 105 118 | `telegramcmd/prompts/plan_progress_system.tmpl` | system | Defines style/constraints for Telegram plan-progress rewrite messages. | ··· 186 199 - `cmd/mistermorph/telegramcmd/prompts/init_questions_system.tmpl` 187 200 - `cmd/mistermorph/telegramcmd/prompts/init_questions_user.tmpl` 188 201 - Renderer: `cmd/mistermorph/telegramcmd/init_prompts.go` 189 - - Purpose: generate onboarding questions for persona bootstrap 202 + - Purpose: generate onboarding questions and a natural Telegram question message for persona bootstrap 190 203 - Primary input: draft `IDENTITY.md`, draft `SOUL.md`, user text, required target fields 191 - - Output: `{"questions": [...]}` 204 + - Output: `{"questions": [...], "message": "..."}` (message is sent directly; fallback text is used when empty) 192 205 - JSON required: **Yes** (`ForceJSON=true`) 193 206 194 207 ### 9) Telegram init field filling ··· 203 216 - Output: `initFillOutput` (identity + soul field values) 204 217 - JSON required: **Yes** (`ForceJSON=true`) 205 218 206 - ### 10) Telegram init question message rewriting 207 - 208 - - File/Function: `cmd/mistermorph/telegramcmd/init_flow.go` / `generateInitQuestionMessage(...)` 209 - - Templates: 210 - - `cmd/mistermorph/telegramcmd/prompts/init_question_message_system.tmpl` 211 - - `cmd/mistermorph/telegramcmd/prompts/init_question_message_user.tmpl` 212 - - Renderer: `cmd/mistermorph/telegramcmd/init_prompts.go` 213 - - Purpose: rewrite question list into natural conversational Telegram text 214 - - Primary input: user text + normalized questions 215 - - Output: plain text message 216 - - JSON required: **No** (`ForceJSON=false`) 217 - 218 - ### 11) Telegram post-init greeting generation 219 + ### 10) Telegram post-init greeting generation 219 220 220 221 - File/Function: `cmd/mistermorph/telegramcmd/init_flow.go` / `generatePostInitGreeting(...)` 221 222 - Templates: ··· 227 228 - Output: plain text message 228 229 - JSON required: **No** (`ForceJSON=false`) 229 230 230 - ### 12) Telegram memory draft generation 231 + ### 11) Telegram memory draft generation 231 232 232 233 - File/Function: `cmd/mistermorph/telegramcmd/command.go` / `BuildMemoryDraft(...)` 233 234 - Templates: ··· 239 240 - Output: `memory.SessionDraft` 240 241 - JSON required: **Yes** (`ForceJSON=true`) 241 242 242 - ### 13) Telegram semantic merge for short-term memory 243 + ### 12) Telegram semantic merge for short-term memory 243 244 244 245 - File/Function: `cmd/mistermorph/telegramcmd/command.go` / `SemanticMergeShortTerm(...)` 245 246 - Templates: ··· 251 252 - Output: merged `memory.ShortTermContent` + summary string 252 253 - JSON required: **Yes** (`ForceJSON=true`) 253 254 254 - ### 14) Telegram semantic task matching 255 + ### 13) Telegram semantic task matching 255 256 256 257 - File/Function: `cmd/mistermorph/telegramcmd/command.go` / `semanticMatchTasks(...)` 257 258 - Templates: ··· 263 264 - Output: `[]taskMatch{update_index, match_index}` 264 265 - JSON required: **Yes** (`ForceJSON=true`) 265 266 266 - ### 15) Telegram semantic task deduplication 267 + ### 14) Telegram semantic task deduplication 267 268 268 269 - File/Function: `cmd/mistermorph/telegramcmd/command.go` / `semanticDedupTaskItems(...)` 269 270 - Templates: ··· 275 276 - Output: deduplicated `[]memory.TaskItem` 276 277 - JSON required: **Yes** (`ForceJSON=true`) 277 278 278 - ### 16) Telegram plan-progress message rewriting 279 + ### 15) Telegram plan-progress message rewriting 279 280 280 281 - File/Function: `cmd/mistermorph/telegramcmd/command.go` / `generateTelegramPlanProgressMessage(...)` 281 282 - Templates: ··· 287 288 - Output: plain text message 288 289 - JSON required: **No** (`ForceJSON=false`) 289 290 290 - ### 17) MAEP feedback classifier 291 + ### 16) MAEP feedback classifier 291 292 292 293 - File/Function: `cmd/mistermorph/telegramcmd/command.go` / `classifyMAEPFeedback(...)` 293 294 - Templates: ··· 299 300 - Output: `maepFeedbackClassification{signal_positive, signal_negative, signal_bored, next_action, confidence}` 300 301 - JSON required: **Yes** (`ForceJSON=true`) 301 302 302 - ### 18) Telegram addressing classifier 303 + ### 17) Telegram addressing classifier 303 304 304 305 - File/Function: `cmd/mistermorph/telegramcmd/command.go` / `addressingDecisionViaLLM(...)` 305 306 - Templates: ··· 311 312 - Output: `telegramAddressingLLMDecision{addressed, confidence, task_text, reason}` 312 313 - JSON required: **Yes** (`ForceJSON=true`) 313 314 314 - ### 19) Telegram reaction-category classifier 315 + ### 18) Telegram reaction-category classifier 315 316 316 317 - File/Function: `cmd/mistermorph/telegramcmd/reactions.go` / `classifyReactionCategoryViaIntent(...)` 317 318 - Templates:
+1 -18
docs/tools.md
··· 16 16 - `contacts_list` 17 17 - `contacts_candidate_rank` 18 18 - `contacts_send` 19 - - `contacts_feedback_update` 20 19 - 条件注册 21 20 - `plan_create`(在 `run` / `telegram` / `daemon serve` 模式通过 `internal/toolsutil.RegisterPlanTool` 注入) 22 21 ··· 190 189 |---|---|---|---|---| 191 190 | `limit` | `integer` | 否 | 配置默认 | 返回决策上限。 | 192 191 | `freshness_window` | `string` | 否 | 配置默认 | 时长字符串,如 `72h`。 | 193 - | `freshness_window_hours` | `number` | 否 | 无 | 按小时指定 freshness。 | 194 192 | `max_linked_history_items` | `integer` | 否 | `4` | 每条决策关联历史上限。 | 195 - | `human_enabled` | `boolean` | 否 | 配置默认 | 是否纳入人类联系人。 | 196 193 | `human_public_send_enabled` | `boolean` | 否 | 配置默认 | 是否允许面向公开聊天目标。 | 197 194 | `push_topic` | `string` | 否 | `share.proactive.v1` | 决策里填充的推送 topic。 | 198 195 199 196 说明: 200 197 201 198 - 本工具默认启用 LLM 特征抽取,使用全局 `llm.*` 配置。 199 + - 人类联系人候选默认始终参与排序(等效 `human_enabled=true`)。 202 200 203 201 ## `contacts_send` 204 202 ··· 225 223 - 若提供 `payload_base64`,其解码结果必须是 envelope JSON,并包含 `message_id` / `text` / `sent_at(RFC3339)`。 226 224 - 对话类 topic(`share.proactive.v1` / `dm.checkin.v1` / `dm.reply.v1` / `chat.message`)必须携带 `session_id`(UUIDv7)。 227 225 - 人类联系人发送受 `contacts.human.send.*` 策略约束。 228 - 229 - ## `contacts_feedback_update` 230 - 231 - 用途:根据反馈信号更新联系人偏好、会话兴趣和关系深度。 232 - 233 - 参数: 234 - 235 - | 参数 | 类型 | 必填 | 默认值 | 说明 | 236 - |---|---|---|---|---| 237 - | `contact_id` | `string` | 是 | 无 | 目标联系人 ID。 | 238 - | `signal` | `string` | 是 | 无 | `positive` / `neutral` / `negative`。 | 239 - | `topic` | `string` | 否 | 空 | 用于 topic 偏好更新。 | 240 - | `session_id` | `string` | 否 | `contact_id` | 会话 ID。 | 241 - | `reason` | `string` | 否 | 空 | 审计原因描述。 | 242 - | `end_session` | `boolean` | 否 | `false` | 是否结束会话。 | 243 226 244 227 ## `plan_create` 245 228
+82
internal/promptprofile/context.go
··· 1 + package promptprofile 2 + 3 + import ( 4 + "log/slog" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + "unicode/utf8" 9 + 10 + "github.com/quailyquaily/mistermorph/agent" 11 + "github.com/quailyquaily/mistermorph/internal/statepaths" 12 + ) 13 + 14 + const ( 15 + localToolNotesDefaultMaxBytes = 8 * 1024 16 + ) 17 + 18 + func AppendLocalToolNotesBlock(spec *agent.PromptSpec, log *slog.Logger) { 19 + if spec == nil { 20 + return 21 + } 22 + if log == nil { 23 + log = slog.Default() 24 + } 25 + 26 + path := toolsPath() 27 + raw, err := os.ReadFile(path) 28 + if err != nil { 29 + if !os.IsNotExist(err) { 30 + log.Warn("prompt_local_tool_notes_load_failed", "path", path, "error", err.Error()) 31 + } 32 + return 33 + } 34 + 35 + content := strings.TrimSpace(string(raw)) 36 + if content == "" { 37 + log.Debug("prompt_local_tool_notes_skipped", "path", path, "reason", "empty") 38 + return 39 + } 40 + 41 + maxBytes := localToolNotesMaxBytes() 42 + content, truncated := truncateUTF8Bytes(content, maxBytes) 43 + if content == "" { 44 + log.Debug("prompt_local_tool_notes_skipped", "path", path, "reason", "empty_after_truncate") 45 + return 46 + } 47 + 48 + spec.Blocks = append(spec.Blocks, agent.PromptBlock{ 49 + Title: "Local Tool Notes", 50 + Content: content, 51 + }) 52 + log.Info("prompt_local_tool_notes_applied", "path", path, "size", len(content), "max_bytes", maxBytes, "truncated", truncated) 53 + } 54 + 55 + func toolsPath() string { 56 + return filepath.Join(statepaths.FileStateDir(), "TOOLS.md") 57 + } 58 + 59 + func localToolNotesMaxBytes() int { 60 + return localToolNotesDefaultMaxBytes 61 + } 62 + 63 + func truncateUTF8Bytes(input string, maxBytes int) (string, bool) { 64 + input = strings.TrimSpace(input) 65 + if input == "" { 66 + return "", false 67 + } 68 + if maxBytes <= 0 { 69 + return "", len(input) > 0 70 + } 71 + 72 + raw := []byte(input) 73 + if len(raw) <= maxBytes { 74 + return input, false 75 + } 76 + 77 + clipped := raw[:maxBytes] 78 + for len(clipped) > 0 && !utf8.Valid(clipped) { 79 + clipped = clipped[:len(clipped)-1] 80 + } 81 + return strings.TrimSpace(string(clipped)), true 82 + }
+94
internal/promptprofile/context_test.go
··· 1 + package promptprofile 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "strings" 7 + "testing" 8 + 9 + "github.com/quailyquaily/mistermorph/agent" 10 + "github.com/spf13/viper" 11 + ) 12 + 13 + func TestAppendLocalToolNotesBlock_MissingFileNoChange(t *testing.T) { 14 + stateDir := t.TempDir() 15 + prevStateDir := viper.GetString("file_state_dir") 16 + viper.Set("file_state_dir", stateDir) 17 + t.Cleanup(func() { 18 + viper.Set("file_state_dir", prevStateDir) 19 + }) 20 + 21 + spec := agent.DefaultPromptSpec() 22 + AppendLocalToolNotesBlock(&spec, nil) 23 + if len(spec.Blocks) != 0 { 24 + t.Fatalf("expected no blocks when TOOLS.md is missing") 25 + } 26 + } 27 + 28 + func TestAppendLocalToolNotesBlock_EmptyFileSkipped(t *testing.T) { 29 + stateDir := t.TempDir() 30 + prevStateDir := viper.GetString("file_state_dir") 31 + viper.Set("file_state_dir", stateDir) 32 + t.Cleanup(func() { 33 + viper.Set("file_state_dir", prevStateDir) 34 + }) 35 + 36 + if err := os.WriteFile(filepath.Join(stateDir, "TOOLS.md"), []byte(" \n\t "), 0o644); err != nil { 37 + t.Fatalf("write TOOLS.md: %v", err) 38 + } 39 + 40 + spec := agent.DefaultPromptSpec() 41 + AppendLocalToolNotesBlock(&spec, nil) 42 + if len(spec.Blocks) != 0 { 43 + t.Fatalf("expected no blocks for empty TOOLS.md") 44 + } 45 + } 46 + 47 + func TestAppendLocalToolNotesBlock_AppendsContent(t *testing.T) { 48 + stateDir := t.TempDir() 49 + prevStateDir := viper.GetString("file_state_dir") 50 + viper.Set("file_state_dir", stateDir) 51 + t.Cleanup(func() { 52 + viper.Set("file_state_dir", prevStateDir) 53 + }) 54 + 55 + content := "- run: go test ./...\n- cache: ./tmp\n" 56 + if err := os.WriteFile(filepath.Join(stateDir, "TOOLS.md"), []byte(content), 0o644); err != nil { 57 + t.Fatalf("write TOOLS.md: %v", err) 58 + } 59 + 60 + spec := agent.DefaultPromptSpec() 61 + AppendLocalToolNotesBlock(&spec, nil) 62 + if len(spec.Blocks) != 1 { 63 + t.Fatalf("expected one block, got %d", len(spec.Blocks)) 64 + } 65 + if spec.Blocks[0].Title != "Local Tool Notes" { 66 + t.Fatalf("unexpected block title: %q", spec.Blocks[0].Title) 67 + } 68 + if !strings.Contains(spec.Blocks[0].Content, "go test ./...") { 69 + t.Fatalf("unexpected block content: %q", spec.Blocks[0].Content) 70 + } 71 + } 72 + 73 + func TestAppendLocalToolNotesBlock_TruncatesByFixedMaxBytes(t *testing.T) { 74 + stateDir := t.TempDir() 75 + prevStateDir := viper.GetString("file_state_dir") 76 + viper.Set("file_state_dir", stateDir) 77 + t.Cleanup(func() { 78 + viper.Set("file_state_dir", prevStateDir) 79 + }) 80 + 81 + content := strings.Repeat("a", localToolNotesDefaultMaxBytes+100) 82 + if err := os.WriteFile(filepath.Join(stateDir, "TOOLS.md"), []byte(content), 0o644); err != nil { 83 + t.Fatalf("write TOOLS.md: %v", err) 84 + } 85 + 86 + spec := agent.DefaultPromptSpec() 87 + AppendLocalToolNotesBlock(&spec, nil) 88 + if len(spec.Blocks) != 1 { 89 + t.Fatalf("expected one block, got %d", len(spec.Blocks)) 90 + } 91 + if got := spec.Blocks[0].Content; got != strings.Repeat("a", localToolNotesDefaultMaxBytes) { 92 + t.Fatalf("unexpected truncated content: %q", got) 93 + } 94 + }
+1 -13
tools/builtin/contacts_candidate_rank.go
··· 20 20 DefaultLimit int 21 21 DefaultFreshnessWindow time.Duration 22 22 DefaultMaxLinkedHistoryItems int 23 - DefaultHumanEnabled bool 24 23 DefaultHumanPublicSend bool 25 24 DefaultLLMProvider string 26 25 DefaultLLMEndpoint string ··· 67 66 "type": "string", 68 67 "description": "Freshness window duration, e.g. 72h.", 69 68 }, 70 - "freshness_window_hours": map[string]any{ 71 - "type": "number", 72 - "description": "Alternative freshness window in hours.", 73 - }, 74 69 "max_linked_history_items": map[string]any{ 75 70 "type": "integer", 76 71 "description": "Max linked history ids per decision.", 77 72 }, 78 - "human_enabled": map[string]any{ 79 - "type": "boolean", 80 - "description": "Enable human contacts in ranking.", 81 - }, 82 73 "human_public_send_enabled": map[string]any{ 83 74 "type": "boolean", 84 75 "description": "If false, rankings skip candidates targeting public human chats.", ··· 105 96 106 97 limit := parseIntDefault(params["limit"], t.opts.DefaultLimit) 107 98 maxLinkedHistoryItems := parseIntDefault(params["max_linked_history_items"], t.opts.DefaultMaxLinkedHistoryItems) 108 - humanEnabled := parseBoolDefault(params["human_enabled"], t.opts.DefaultHumanEnabled) 99 + humanEnabled := true 109 100 humanPublicEnabled := parseBoolDefault(params["human_public_send_enabled"], t.opts.DefaultHumanPublicSend) 110 101 pushTopic, _ := params["push_topic"].(string) 111 102 112 103 freshnessWindow := t.opts.DefaultFreshnessWindow 113 - if hours := parseFloatDefault(params["freshness_window_hours"], 0); hours > 0 { 114 - freshnessWindow = time.Duration(hours * float64(time.Hour)) 115 - } 116 104 if d, err := parseDuration(params["freshness_window"], freshnessWindow); err != nil { 117 105 return "", fmt.Errorf("invalid freshness_window: %w", err) 118 106 } else if d > 0 {
-108
tools/builtin/contacts_feedback_update.go
··· 1 - package builtin 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "strings" 8 - "time" 9 - 10 - "github.com/quailyquaily/mistermorph/contacts" 11 - "github.com/quailyquaily/mistermorph/internal/pathutil" 12 - ) 13 - 14 - type ContactsFeedbackUpdateTool struct { 15 - Enabled bool 16 - ContactsDir string 17 - } 18 - 19 - func NewContactsFeedbackUpdateTool(enabled bool, contactsDir string) *ContactsFeedbackUpdateTool { 20 - return &ContactsFeedbackUpdateTool{ 21 - Enabled: enabled, 22 - ContactsDir: strings.TrimSpace(contactsDir), 23 - } 24 - } 25 - 26 - func (t *ContactsFeedbackUpdateTool) Name() string { return "contacts_feedback_update" } 27 - 28 - func (t *ContactsFeedbackUpdateTool) Description() string { 29 - return "Applies one interaction feedback signal to a contact and session (interest, reciprocity, depth, topic preference)." 30 - } 31 - 32 - func (t *ContactsFeedbackUpdateTool) ParameterSchema() string { 33 - s := map[string]any{ 34 - "type": "object", 35 - "properties": map[string]any{ 36 - "contact_id": map[string]any{ 37 - "type": "string", 38 - "description": "Target contact_id.", 39 - }, 40 - "signal": map[string]any{ 41 - "type": "string", 42 - "description": "Feedback signal: positive|neutral|negative.", 43 - }, 44 - "topic": map[string]any{ 45 - "type": "string", 46 - "description": "Optional topic for preference update.", 47 - }, 48 - "session_id": map[string]any{ 49 - "type": "string", 50 - "description": "Optional session id. Default contact_id.", 51 - }, 52 - "reason": map[string]any{ 53 - "type": "string", 54 - "description": "Optional reason for audit.", 55 - }, 56 - "end_session": map[string]any{ 57 - "type": "boolean", 58 - "description": "Mark session as ended.", 59 - }, 60 - }, 61 - "required": []string{"contact_id", "signal"}, 62 - } 63 - b, _ := json.MarshalIndent(s, "", " ") 64 - return string(b) 65 - } 66 - 67 - func (t *ContactsFeedbackUpdateTool) Execute(ctx context.Context, params map[string]any) (string, error) { 68 - if t == nil || !t.Enabled { 69 - return "", fmt.Errorf("contacts_feedback_update tool is disabled") 70 - } 71 - contactsDir := pathutil.ExpandHomePath(strings.TrimSpace(t.ContactsDir)) 72 - if contactsDir == "" { 73 - return "", fmt.Errorf("contacts dir is not configured") 74 - } 75 - contactID, _ := params["contact_id"].(string) 76 - contactID = strings.TrimSpace(contactID) 77 - if contactID == "" { 78 - return "", fmt.Errorf("missing required param: contact_id") 79 - } 80 - signalRaw, _ := params["signal"].(string) 81 - signalRaw = strings.ToLower(strings.TrimSpace(signalRaw)) 82 - if signalRaw == "" { 83 - return "", fmt.Errorf("missing required param: signal") 84 - } 85 - 86 - sessionID, _ := params["session_id"].(string) 87 - topic, _ := params["topic"].(string) 88 - reason, _ := params["reason"].(string) 89 - endSession := parseBoolDefault(params["end_session"], false) 90 - 91 - svc := contacts.NewService(contacts.NewFileStore(contactsDir)) 92 - contact, session, err := svc.UpdateFeedback(ctx, time.Now().UTC(), contacts.FeedbackUpdateInput{ 93 - ContactID: contactID, 94 - SessionID: strings.TrimSpace(sessionID), 95 - Signal: contacts.FeedbackSignal(signalRaw), 96 - Topic: strings.TrimSpace(topic), 97 - Reason: strings.TrimSpace(reason), 98 - EndSession: endSession, 99 - }) 100 - if err != nil { 101 - return "", err 102 - } 103 - out, _ := json.MarshalIndent(map[string]any{ 104 - "contact": contact, 105 - "session": session, 106 - }, "", " ") 107 - return string(out), nil 108 - }