Files
AskaEth 08b3a01752
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(runtime,ai): session_read — 跨会话只读「主动读取」能力(design/22 §8-3/§8.1)
主人 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 侧成功路径钩子需先定口径。
2026-10-03 21:33:40 +08:00

219 lines
8.1 KiB
Go
Raw Permalink 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 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)
}