主人 2026-10-03 过目 design/22 §8.1 草案并点头后实现。
- 新包 internal/runtime/sessionread:工具壳 + 只读取数 + 归属排版(按职责拆 8 文件)
- 只读由类型保证:注入的 HistoryReader 只声明 List/ListSessions —— 本包结构上无法写入
任何会话、也推进不了目标会话的 read_at
- fail-closed 授权,顺序 = 环境 → 白名单 → 存在性:
非 owner 环境拒(ErrNotOwner)→ 白名单只许 web / web:<uuid>(ErrSessionNotAllowed)
→ 会话不存在是硬错误(ErrSessionNotFound,绝不返回空材料,防"读不到"被当成"对方没说")
- 排版:头部三行恒定(外部会话材料 · 只读 · 不属于你的记忆 / 来源角色 / 来源会话)
+ 时间跨度与翻页游标 + 逐条「[时间] 说话人:」归属
- 说话人取消息自身 persona_id;'' → 全局默认角色;未知 id 原样显示;user → SenderName(空=主人)
- Execute 返回 string:loop 的 formatToolResult 对 string 原样透传,换行不被 json 转义
- 接线 cmd/cyrene/wire.go 一行 + wireSessionReadTool():能力默认标记(双 false)= owner-only,
不新增任何 bypass 路径;审计仍走 loop 的 gate
- 日志 source=runtime/sessionread,只记 actor/target/count/limit/before/has_older,不含正文
验证:go build/vet ./... 绿;internal/runtime/sessionread(25 测试)+ cmd/cyrene 绿;
linux/amd64 + darwin/arm64 交叉编译绿;
TestWireSessionReadToolRegistersOwnerOnly 断言 owner 放行 / group_member / stranger 拒绝
遗留(登记,未解决):§8.1 约束 4 要求"每次调用落审计",但 loop 的 gate 只在拒绝时发
AuditEvent ⇒ 成功读取目前只有日志可追溯,未进审计库;补 loop 侧成功路径钩子需先定口径。
219 lines
8.1 KiB
Go
219 lines
8.1 KiB
Go
package sessionread
|
||
|
||
import (
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
)
|
||
|
||
// Layout of the material (design/22 §8.1 返回文本). The first three lines are the
|
||
// hard attribution header: they are present on *every* non-error result, including
|
||
// the empty-session one, because their whole purpose is to keep the reading role
|
||
// from treating someone else's words as her own memory or her own voice.
|
||
const (
|
||
// headerLine is the constant first line of every material.
|
||
headerLine = "[外部会话材料 · 只读 · 不属于你的记忆]"
|
||
// roleLabelPrefix, sessionLabelPrefix and spanLabelPrefix open the attribution
|
||
// header's role line, session line and time-span line.
|
||
roleLabelPrefix = "来源角色:"
|
||
sessionLabelPrefix = "来源会话:"
|
||
spanLabelPrefix = "时间跨度:"
|
||
// separatorLine closes the header and opens the transcript.
|
||
separatorLine = "————"
|
||
// unknownRoleLabel stands in for a role id this runtime cannot name and has no
|
||
// default for, so the attribution is never silently dropped.
|
||
unknownRoleLabel = "(未设定角色)"
|
||
// ownerLabel labels a user message whose sender name is blank — the owner's own
|
||
// private chat never carries one (design/16 §4.6).
|
||
ownerLabel = "主人"
|
||
// systemLabel labels a stored system message, which is neither a role utterance
|
||
// nor a user utterance.
|
||
systemLabel = "系统"
|
||
)
|
||
|
||
// Message-line and header timestamp layouts. They mirror the loop's model-facing
|
||
// clock (internal/runtime/loop/time.go renders the per-round "当前时间" with the same
|
||
// wall-clock layout), so the material reads in the same shape as the runtime's own
|
||
// time injection.
|
||
const (
|
||
timestampLayout = "2006-01-02 15:04"
|
||
timeOnlyLayout = "15:04"
|
||
)
|
||
|
||
// Layout fragments of the material.
|
||
const (
|
||
titleOpen = "「"
|
||
titleClose = "」"
|
||
roleIDOpen = "("
|
||
roleIDClose = ")"
|
||
spanArrow = " → "
|
||
noteOpen = "("
|
||
noteClose = ")"
|
||
cursorKey = "before="
|
||
cursorInfix = ";更早用 " + cursorKey
|
||
earliestNote = ";已到最早"
|
||
recentInfix = "最近 "
|
||
countSuffix = " 条"
|
||
lineOpen = "["
|
||
lineClose = "] "
|
||
speakerColon = ":"
|
||
// emptyNoMsg is the body of a session that never received a message.
|
||
emptyNoMsg = "该会话没有消息。"
|
||
// emptyOlderSuffix is the body of a page that came back empty because the
|
||
// cursor ran past the oldest message of a session that does have history.
|
||
emptyOlderSuffix = " 之前已没有更早的消息。"
|
||
)
|
||
|
||
// material is one fetched page of an external session, ready to render. msgs is
|
||
// newest-first, exactly as the store returns it; the transcript is rendered
|
||
// oldest-first.
|
||
type material struct {
|
||
sessionID string
|
||
title string
|
||
personaID string
|
||
before int64
|
||
msgs []history.Message
|
||
// hasOlder reports whether the page filled up, i.e. whether an older page may
|
||
// still exist. It is a heuristic (no look-ahead query is issued): a session with
|
||
// exactly limit messages also reports true, and the follow-up call then answers
|
||
// "no older messages" instead of a transcript.
|
||
hasOlder bool
|
||
}
|
||
|
||
// render builds the material text: the constant attribution header, the time-span
|
||
// line when there are messages, and one line per message.
|
||
func (r *Reader) render(m material) string {
|
||
lines := []string{
|
||
headerLine,
|
||
roleLabelPrefix + r.roleLabel(m.personaID),
|
||
sessionLabelPrefix + sessionLabel(m.sessionID, m.title),
|
||
}
|
||
if len(m.msgs) == 0 {
|
||
lines = append(lines, separatorLine, emptyBody(m))
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
lines = append(lines, r.spanLine(m), separatorLine)
|
||
for i := len(m.msgs) - 1; i >= 0; i-- {
|
||
lines = append(lines, r.messageLine(m.msgs[i]))
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
// emptyBody is the body of an empty material. A session that never received a
|
||
// message says so; a page that came back empty because the cursor ran past the
|
||
// oldest message says that instead, so the two are never confused.
|
||
func emptyBody(m material) string {
|
||
if m.before > 0 {
|
||
return cursorKey + strconv.FormatInt(m.before, 10) + emptyOlderSuffix
|
||
}
|
||
return emptyNoMsg
|
||
}
|
||
|
||
// spanLine renders the time-span line: the oldest and the newest message of the
|
||
// page, how many messages the page carries, and the cursor for the next older page
|
||
// (or an explicit "already the earliest"). The second timestamp drops the date when
|
||
// both ends fall on the same local day.
|
||
func (r *Reader) spanLine(m material) string {
|
||
oldest, newest := m.msgs[len(m.msgs)-1], m.msgs[0]
|
||
span := r.formatAt(oldest.CreatedAt) + spanArrow + r.formatEnd(newest.CreatedAt, oldest.CreatedAt)
|
||
return spanLabelPrefix + span + noteOpen + m.scopeNote() + noteClose
|
||
}
|
||
|
||
// scopeNote is the parenthesized tail of the time-span line: the page size plus
|
||
// either the next page's cursor or the explicit "nothing older" marker.
|
||
func (m material) scopeNote() string {
|
||
note := recentInfix + strconv.Itoa(len(m.msgs)) + countSuffix
|
||
if !m.hasOlder {
|
||
return note + earliestNote
|
||
}
|
||
return note + cursorInfix + strconv.FormatInt(m.msgs[len(m.msgs)-1].CreatedAt, 10)
|
||
}
|
||
|
||
// messageLine renders one transcript line: the message's own timestamp and the
|
||
// speaker it is attributed to. The body is copied verbatim — a multi-line message
|
||
// therefore keeps its own newlines and is never rewritten or collapsed.
|
||
func (r *Reader) messageLine(msg history.Message) string {
|
||
return lineOpen + r.formatAt(msg.CreatedAt) + lineClose + r.speakerLabel(msg) + speakerColon + msg.Content
|
||
}
|
||
|
||
// speakerLabel decides who a message is attributed to. An assistant message is
|
||
// attributed by the message's OWN persona_id (design/22 §8.1 约束 5) — never by the
|
||
// reading session's role — so a greeting in the middle of a transcript is still
|
||
// credited to the role that actually said it. An empty persona id means the global
|
||
// default role. A user message uses its stored sender name, falling back to 主人.
|
||
func (r *Reader) speakerLabel(msg history.Message) string {
|
||
switch msg.Role {
|
||
case history.RoleAssistant:
|
||
return r.roleName(msg.PersonaID)
|
||
case history.RoleSystem:
|
||
return systemLabel
|
||
default:
|
||
if name := strings.TrimSpace(msg.SenderName); name != "" {
|
||
return name
|
||
}
|
||
return ownerLabel
|
||
}
|
||
}
|
||
|
||
// roleName resolves a role id to its display name, mapping an empty id to the
|
||
// global default role. An id this runtime cannot name is shown as-is, so an
|
||
// unknown role is still visible as a distinct speaker.
|
||
func (r *Reader) roleName(id string) string {
|
||
if id == "" {
|
||
id = r.personas.DefaultPersonaID()
|
||
}
|
||
if id == "" {
|
||
return unknownRoleLabel
|
||
}
|
||
if name, ok := r.personas.PersonaName(id); ok {
|
||
if trimmed := strings.TrimSpace(name); trimmed != "" {
|
||
return trimmed
|
||
}
|
||
}
|
||
return id
|
||
}
|
||
|
||
// roleLabel renders the header's role line: the display name followed by the role
|
||
// id in parentheses (design/22 §8.1 "雪(yuki)"). When the id cannot be named, or
|
||
// when the name and the id coincide, the name alone is shown.
|
||
func (r *Reader) roleLabel(id string) string {
|
||
name := r.roleName(id)
|
||
resolved := id
|
||
if resolved == "" {
|
||
resolved = r.personas.DefaultPersonaID()
|
||
}
|
||
if resolved == "" || resolved == name {
|
||
return name
|
||
}
|
||
return name + roleIDOpen + resolved + roleIDClose
|
||
}
|
||
|
||
// sessionLabel renders the header's session line: the session id, followed by its
|
||
// title in corner brackets when it has one (design/22 §8.1 "web:5f1c…「调试日志」").
|
||
// The id is never truncated: it is what the model needs to page through the very
|
||
// same session.
|
||
func sessionLabel(sessionID, title string) string {
|
||
if trimmed := strings.TrimSpace(title); trimmed != "" {
|
||
return sessionID + titleOpen + trimmed + titleClose
|
||
}
|
||
return sessionID
|
||
}
|
||
|
||
// formatAt renders a millisecond timestamp in the reader's location.
|
||
func (r *Reader) formatAt(ms int64) string {
|
||
return time.UnixMilli(ms).In(r.loc).Format(timestampLayout)
|
||
}
|
||
|
||
// formatEnd renders the time-span line's closing timestamp: date and time when it
|
||
// falls on a different local day than the opening one, otherwise the time alone.
|
||
func (r *Reader) formatEnd(ms, opening int64) string {
|
||
end := time.UnixMilli(ms).In(r.loc)
|
||
start := time.UnixMilli(opening).In(r.loc)
|
||
if end.Year() == start.Year() && end.YearDay() == start.YearDay() {
|
||
return end.Format(timeOnlyLayout)
|
||
}
|
||
return end.Format(timestampLayout)
|
||
}
|