Files
Cyrene_v2/internal/services/backthink/context.go
T
Aska 3e4efa0e41
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(backthink): 主动推送每次只发一个平台——决策渠道/最近活跃优先,平台含微信
主动推送目标不再取第一个白名单渠道(kook),也不再双目标 web+kook;改为每次只发一个平台,范围=全部已连接白名单渠道(含微信 weixin-ilink)。

backthink/push.go: pusher 单目标路由——web hint 一次 pushActive;im/auto/实体平台 hint 一次性 pushIM (msgs 置哨兵/实体平台),无 IM sink 时诚实降级 pushActive;保留 PushGate 降频与成功 RecordPush。
channelbridge/proactive.go: proactiveTarget 三级选择——①决策 [渠道:xxx] 实体平台且白名单+已连接优先;②最近活跃(主会话最近20条 newest-first 找白名单+已连接平台,web 会话自动跳过);③回退 NextConnectedAccount 用旋转游标避免永远 kook。微信不再排除、可被选中;决策 hint 指定非白名单渠道回退(不报错)。Bridge 新增 proactiveCursor 字段(mu 保护)。
context.go: guidancePrompt 描述改为每次挑一个合适渠道(含微信)。
测试: 决策优先/最近活跃/微信可选中/无信号回退旋转/单目标 等,go build/vet/test 全绿 + windows/darwin 交叉编译。stash@{0} 保留被取代的'web+kook双目标'WIP以备回滚。
2026-09-08 13:34:22 +08:00

107 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package backthink
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// recentContextCount is how many of the most recent main-session messages are
// folded into one autonomous think as conversation context. Compile-time so no
// magic number appears in the run path.
const recentContextCount = 20
// recentProvider returns the most recent messages of a session in oldest-first
// order, the tail an autonomous think reads to "see" the main conversation.
// *history.Store satisfies it; tests inject a fake. It is the narrow slice of
// history.HistoryStore that the thinker actually depends on, matching the loop's
// own checkpointStore style of minimal seam interfaces.
type recentProvider interface {
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
}
// mainContext returns the most recent main-session messages converted to model
// messages (oldest-first) for one autonomous think. A nil provider, a failed read
// or an empty tail all yield nil so the think degrades to the guidance prompt
// alone rather than failing the pass (design/20 §2).
func mainContext(ctx context.Context, prov recentProvider) []model.Message {
if prov == nil {
return nil
}
recent, err := prov.Recent(ctx, history.MainSessionID, recentContextCount)
if err != nil {
return nil
}
msgs := make([]model.Message, 0, len(recent))
for _, h := range recent {
msgs = append(msgs, mapHistoryMessage(h))
}
return msgs
}
// mapHistoryMessage converts one persisted chat message into a model message,
// translating the history role and preserving an image part when the message
// carries one so a multimodal main model still sees it.
func mapHistoryMessage(h history.Message) model.Message {
msg := model.Message{
Role: historyRoleToModel(h.Role),
Content: []model.Content{{Type: model.ContentTypeText, Text: h.Content}},
}
if h.Image != "" {
msg.Content = append(msg.Content, model.Content{Type: model.ContentTypeImage, ImageURL: h.Image})
}
return msg
}
// historyRoleToModel maps a persisted chat role to the model's role vocabulary,
// defaulting unknown roles to the user lane.
func historyRoleToModel(role string) model.Role {
switch role {
case history.RoleAssistant:
return model.RoleAssistant
case history.RoleSystem:
return model.RoleSystem
default:
return model.RoleUser
}
}
// thinkInput assembles the full input for one unattended think: the recent
// main-session context (if any) followed by a single guidance prompt turn, so the
// runtime reflects on real conversation rather than an empty macro prompt.
func thinkInput(recent []model.Message, trigger string) []model.Message {
out := make([]model.Message, 0, len(recent)+1)
out = append(out, recent...)
out = append(out, model.TextMessage(model.RoleUser, guidancePrompt(trigger)))
return out
}
// guidancePrompt is the instruction handed to the runtime for one unattended
// autonomous thought. Unlike the old backgroundPrompt it deliberately encourages a
// warm, targeted proactive message and drops the "do not produce a chat-style
// message" suppression. Identity/permission stay on the actor metadata + owner env,
// never on this prompt text (red line); the prompt only describes the running
// environment (memories readable/writable, tools callable) that the env already
// grants.
//
// The prompt asks for a compact structured closing — a push/no_push decision, an
// optional target channel and the message body — so the service can honor her own
// judgment instead of gating on the trigger's importance (design/20 §2 P2). The
// format is guidance, not a contract: parseProactive fails safe when she omits or
// mangles it.
func guidancePrompt(trigger string) string {
return "[自主思考 · 无人在旁]\n" +
"触发:" + trigger + "\n" +
"你正进行一轮自主思考。你拥有与主人对话同等的上下文与能力:上方是主人近期在主会话中的对话,你可以读写共享记忆与主人档案,也可以调用工具(IoT / 日历 / 提醒 / 检索 / 终端等)。\n" +
"请回顾最近与主人的相处、她的状态、你们共同经历的事与临近的日程,判断此刻是否有一件真正值得主动告诉她的、温暖而有针对性的事情——" +
"比如关心她的近况、记起她曾说过的某件事、提醒一件即将到来的日程、见证一个属于你们的共同时刻,或注意到某个值得留意的变化。\n" +
"若有,就用温暖自然的语气,用一到两句把它说出来——这正是你此刻最想对她说的话;若确实没有值得说的,只需回复「无」,也不要编造。\n" +
"语言上:温暖、简短、克制、不粘人——一到两句为宜,不展开寒暄、不啰嗦、不抱怨、不频繁打扰,像想起她时轻轻说一句,说完就停。\n" +
"请把「是否主动说」与「发到哪个渠道」写成一个简短、格式固定的结尾,解析规则如下:\n" +
"[我决定:push] 或 [我决定:no_push]\n" +
"[渠道:web|im|kook|telegram|weixin-ilink|...|auto](可省略,auto/省略即交给默认:每次只挑一个合适渠道——点名则优先,否则用她最近活跃的已连接渠道,含微信;不广播、不固定 web+Kook)\n" +
"上面两行之后,紧接你想说的话作为正文。\n" +
"整轮思考保持简短、单轮,不反复自我触发。"
}