feat(runtime,ai): session_read — 跨会话只读「主动读取」能力(design/22 §8-3/§8.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人 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 侧成功路径钩子需先定口径。
This commit is contained in:
AskaEth committed 2026-10-03 21:33:40 +08:00
1 parent 05d34853f0
commit 08b3a01752
11 files changed
+1723

No files matched your search

+218
View File
@@ -0,0 +1,218 @@
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)
}