Author SHA1 Message Date
AskaEth 39ae883a4e test(loop): cover the speak tool and Result.Voice (M57)
speak_tool_test.go pins the leaf behaviour: budget formula, extension
whitelist, argument validation, and the success/timeout/no-audio/store-failed
paths, plus the model-surface advertisement and append-once rule.

voice_result_test.go pins the outbound chain end to end: captureVoice's three
states and its last-success-wins rule, a full turn where speak is advertised,
executed through the injected synth/store and lands on Result.Voice, the
degraded turn that still answers in text, the unwired turn that keeps the
surface byte-identical, and the store adapter the wiring will use.
2026-10-27 21:22:20 +08:00
AskaEth 1347a77d33 feat(loop): wire speak tool into the loop (M57)
- speak_tool.go: the speak tool itself — rune-derived timeout budget, structured
  failure codes, persona/emotion stamped on the synthesis ctx, audio persisted
  through the store seam, structured result for the model.
- voice_deps.go: the wiring seam (VoiceSynth / VoiceStore + VoiceStoreFunc
  adapter), voiceEnabled, withSpeakSpec (appended after every schema shrink),
  and captureVoice (success beats failure, last one wins).
- options.go / loop.go: VoiceSynth, VoiceStore, SpeakBudget injection points.
- request.go: advertise speak on both toolSchemas return paths.
- tools.go / tools_invoke.go: carry the batch voice outcome; split invokeOne /
  emitToolExecution / spanStatusForToolPhase / invokeParallel into their own
  file (tools.go was at the 500-line ceiling) and let speak run without a
  dispatcher.
- run.go: keep the voice outcome across tool batches (a later batch without
  speak must not erase it).

Known, deliberate: speak bypasses the capacity allow-set and skill-round
narrowing (it belongs to no built-in toolset), so it is also advertised in
quiet/awake; execution still passes the tool security gate, where speak is not
registered and non-owner actors are denied fail-closed.
2026-10-27 21:22:14 +08:00
AskaEth f84a358e30 feat(loop): add OutboundVoice result type for speak tool 2026-10-27 20:22:12 +08:00
AskaEth 07df06b86d Merge branch 'feat/b2a-browserexit-redact-browser-exception' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:27:21 +08:00
AskaEth b040f588b2 fix(browser): M53 追加 Chrome 输出预览打码
preview() 出镜前过 trace.RedactTextLimited(600 rune),覆盖失败日志的 chrome_output 与 withChromeOutput 追加给调用方的错误串;补凭据形状/原话可见/有界三条测试,删实现可打红。
2026-10-26 22:19:31 +08:00
AskaEth e8bc8727da Merge branch 'feat/b2c-hostredact-vision-hostof-and-tts-red' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:15:21 +08:00
AskaEth b626b3f5c0 Merge branch 'feat/b2d-weixinname-send-md-name-redact-and-l' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:11:58 +08:00
AskaEth 50cc60d019 Merge branch 'feat/b2b-judgetext-redact-judge-reason-sensit' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:11:05 +08:00
AskaEth a1c9da6e8a test(voice/tts): pin fragment-before-query in redactURL fallback
Review r1 P2-01: the only fragment case had '?' before '#', so degrading
stripURLQuery to cut '?' alone stayed green and the '#'-first branch was
unpinned. Add a scheme-less '#access_token=...' case (OAuth implicit
flow shape) and correct the mutation comment to state which case each
mutation actually kills.
2026-10-26 22:11:01 +08:00
AskaEth e39cc5d096 fix(weixin_ilink): drop user file name from media failure log; fix TestLogin_LogRedaction data race 2026-10-26 22:00:09 +08:00
AskaEth 5a69d6e0ff fix(automations): redact reminder message before logging 2026-10-26 21:58:31 +08:00
AskaEth 8058660537 fix(browser): M53 两条页面侧自由文本出口收敛脱敏
- Page.navigate 的 errorText 是**协议枚举**面(net error 码):先剥 `net::`
  命名空间再交给 trace.ErrorToken,像枚举就原样保留、散文一律丢弃只留字节数;
- Runtime.evaluate 的 exceptionDetails.text 是**页面自由文本**面:压平空白
  (防日志行伪造)+ trace.RedactTextLimited 按 200 rune 截断(档位理由见
  errtext.go:80 会截断真实异常消息,4096 等于允许页面塞 4KB 正文);
- 两类守卫不互换(口径见 trace/errortoken.go 的按字段类别判定),清洗实现
  仍只有 trace 那一份,本片只做「选哪一道 + 补形状前提」。

测试:出口 A/B 各一条经 RenderText 的真实路径断言(含 URL/凭据形状的假文本),
外加 net::ERR_* 正向保留、上限截断/换行压平、netErrorCode 形状表。三条泄露断言
已验证「退回原样直吐即红」。
2026-10-26 21:57:48 +08:00
AskaEth e0793debc3 fix(restrict): redact sensitive param summary before it reaches audit/panel 2026-10-26 21:55:25 +08:00
AskaEth 28a6cd7e53 fix(voice/tts): strip query from scheme-less URLs in redactURL
redactURL's fallback returned the raw string when url.Parse found no host.
A scheme-less BaseURL (127.0.0.1:9880/v1?token=...) fails to parse, so the
signed query reached the WARN log and the error chain verbatim. Strip at
? and # in that path, keeping the '@' full-redaction rule first.

Test pins the scheme-less query/fragment cases and goes red if the fix
is reverted.
2026-10-26 21:54:21 +08:00
AskaEth 5099c21520 fix(audit): redact judge reason before it reaches logs and the panel
判定理理由(verdict.Reason)是模型自由文本,判定器会把它在参数摘要里看到的
凭据原样抄进理由,而这条理由要进审计日志与前端小字两个读面。cleanReason
在收敛空白/截断之后统一过 trace.RedactText(80 rune 档),与其余出图文本
走同一套清洗;maxReasonRunes 退化为「进入清洗前」的上限,避免在无界文本上
跑凭据匹配。
2026-10-26 21:52:44 +08:00
AskaEth a5b3d40a74 fix(vision): report only URL origin when an image source is rejected
fetchURL echoed the whole raw URL (path, query, userinfo) in the
ErrUnsupportedSource message, so a rejected source carrying a signed
query leaked it into logs and tickets. Route the URL exit through
trace.HostOf and fall back to a placeholder instead of the raw string.

Test pins origin-only output and goes red if the fix is reverted.
2026-10-26 21:52:19 +08:00
AskaEth d47185feb8 docs(contract): v0.97 voice WS frames + TTS autolaunch + upstream error safety
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- voice 进 message_persisted/channel_persisted 首帧与握手回放(M44 fb55769,
  INSERT→写列→notify;两条实现前提进契约)
- emotionTTS 自启默认关(M47,voice-tts-launch.json,probeUrl 单向兜底)
- UpstreamError 构造即安全:code/type 固定令牌守卫、message 默认缺席
  (M49/M50,一处行为变更如实标注)
- U6 双凭据门明示(Bearer JWT 或 X-API-Key,既有能力非新增)
- 改正 14 处「WS 帧不带 voice」假话 + 旧横幅加注;尾注 v0.82→v0.97
2026-10-26 21:44:44 +08:00
AskaEth 656c35f1e8 Merge branch 'feat/m51-lastexits-close-remaining-upstream-f' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
# Conflicts:
#	internal/runtime/trace/errortoken.go
2026-10-26 21:25:48 +08:00
AskaEth 921938d084 Merge branch 'feat/m52-urlcausesweep-sweep-url-error-direct' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 20:03:29 +08:00
AskaEth 76263d42ed fix(upstream): M51 复审 p2 三条 —— light.go 同形出口 / MCP SDK 日志面 / ErrorToken 口径
① light.go 的 2 处 "light judge: " + d.Reason 与 engage.go 同形(model reason →
返回值 → coalesce_prepare 的 Debug)。把上一轮的 engageReason 提成包内共用的
judgeReason(judge_reason.go,RedactTextLimited + strings.Fields,60 rune),
engage.go 7 处 + light.go 2 处 + notice.go 的 "notice review failed: "+err.Error()
统一走它。控制流零改动:只换「进去的那份文本」,判定条件与返回值拼接结构未动。

② MCP 的 Logger 面先探针后修:go-sdk v1.8.0 的 keepalive 会把对端 JSON-RPC error
原样打进日志(实测泄漏,见 sdklog_probe_test.go 的输出),tools/call 路径不打日志。
新增 sdklog.go:redactingLogger 交给 SDK,wire 错误(链上有 *jsonrpc.Error)只留
code + message_bytes(复用 upstreamError),本地错误保留原因,消息/属性压平+打码+300 rune。

③ errortoken.go 的口径注释纠正:从「包外不得调用」改为按字段类别判定(协议枚举可守、
自由文本默认不带),并把 cdp.go 这条 M51 用法作为例子写进去。
2026-10-26 20:00:57 +08:00
AskaEth 969857682f fix(runtime): M52 把 *url.Error 直打的 19 处换成 trace.URLCause
model / multimodal / tts / embedding 里所有「把 net/http 的 *url.Error 直接打进
错误串」的地方(M51 清单的 B1 组 17 处 + 清单外的同根因 2 处)统一改走
trace.URLCause:错误串只少一段地址,底层原因、哨兵分类、状态码、重试/降级判据
一字不动。

清单内(17):
- model/openai.go: Chat / ChatStream 的 NewRequest、doRequest
- model/anthropic.go: chatOnce / streamOnce 的 NewRequest、doRequest
- multimodal/vision/adapter.go: ask 的 NewRequest、client.Do
- multimodal/voice/cloud.go: postJSON 的 NewRequest、client.Do
- multimodal/voice/dashscope.go: download(预签名 OSS 地址)与 dashscopePost 各两处
- voice/tts/client.go: unreachable(返回值与同一行日志的 err 字段都只留原因,
  地址只由 redactURL(endpoint) 出现一次)
- embedding/api.go: embedChunk 的 client.Do、newEmbeddingRequest 的 NewRequest

清单外同根因(2,均为 %w 形态,B1 清单按 %v 搜时漏掉):
- voice/tts/client.go: speak 的 NewRequest 失败
- embedding/endpoint.go: normalizeEndpointURL 的 url.Parse 失败

测试:5 个新文件 18 个用例,逐处可变异打红(实测:把任一处的 URLCause 去掉 ⇒
对应用例红);dashscope 预签名地址单独一条用例,并另有一条钉住 403 状态码仍在。
2026-10-26 19:55:05 +08:00
AskaEth 604f9ebaa9 Merge branch 'feat/m50-envelopesafe-drop-envelope-message-o' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 19:51:33 +08:00
AskaEth bfbf5057bc refactor(weixin): runLogin 的守卫说明上移到函数头,回到 80 行以内
复审 r2:runLogin 70 → 82 行越过 AGENTS.md §10 的函数 ≤80 上限。代码行本来
只有 56,超的是 M50 加在循环体内的两段 🔴 说明。把 status/redirect_host 的
出镜口径整段上移到函数头注释(不参与计数),循环体内只留一行指向函数头;
**控制流与守卫代码零改动**。

改后 runLogin = 76 行(全 plugins/channels 最长 79,其余均 ≤80)。
2026-10-26 19:48:54 +08:00
AskaEth 7fbb8d6232 fix(channelbridge): M51 追加 —— 参与度判定 reason 出镜前打码压平
Debug 日志与返回值共 7 处(engage.go 222/227/228/231/250/256/257 原行号)此前把
判定模型回的 d.Reason 原样写出:模型可以把 Authorization / sk-… 或换行塞进 reason,
前者进日志就是凭据泄漏,后者能按行聚合的日志里伪造一条新记录。

新增 engageReason(trace.RedactTextLimited + strings.Fields,上限 60 rune):
- 控制流零改动:分支条件、状态迁移、返回值拼接结构一个字未动,
  只把「进去的那份文本」从 d.Reason 换成 engageReason(d.Reason);
- 上限 60 rune 的理由写在常量注释里(reason 是一句短语,超过即是在复述群聊原文)。

用例 engage_reason_test.go:PROBING / 跟进 / 回避+无兴趣三条出口,
断言日志与返回值都不含 sk-live…、都含 [redacted]、都不含换行。
变异实测:摘 RedactText ⇒ 三条红;摘压平 ⇒ 三条红。
2026-10-26 19:47:35 +08:00
AskaEth d6493d79f4 fix(upstream): M51 收敛剩余上游自由文本出口 - CDP/MCP/flowgate/channelbridge WARN
- channelbridge: 拨号失败的 WARN 不再带原始 dial 错误(含 ?access_token=),
  URLCause 剥壳 + 内嵌地址 MaskQuery 打码 + HostOf 保留目标来源;控制流零改动
- browser/cdp.go: CDP error.message 只有像协议枚举(ErrorToken)才出镜,其余只留字节数
- mcp: 第三方 server 的错误文本默认不带,只留 server/工具/JSON-RPC 码/message 长度,
  分类链(errors.Is/As、context.Canceled)经 Unwrap 保留
- channel/adapter/flowgate: 审查输出摘要由 40 字节改 40 rune(防切 UTF-8)并过 RedactText

四处均补了会随回退变红的用例(变异已实测)。
2026-10-26 19:44:22 +08:00
AskaEth 1b2576ecaf fix(weixin): 扫码上游字段过令牌守卫,errortoken 契约改写
复审 p2 两条:

① 第五个出口:get_qrcode_status 的 status 与 scaned_but_redirect 的
redirect_host 此前原样进 INFO;同一 status 经 setPhase 还从复用二维码那条
INFO(phase)二次出镜。三处一律过 trace.ErrorToken;**控制流仍用原值**
(状态机读 status.Status、路由读 status.RedirectHost、confirmingPhase 读
phase),用例同时钉住「日志变空串」与「路由/判定不变」两个方向。

② 顺带补扫出一处 URL 类:get_bot_qrcode 的 qrcode_img_content(上游给的
图片地址,可能是带签名的 URL 或 data: 内联图)原样进 INFO ⇒ 改走
trace.MaskQuery(凭据类 query 打码、保留路径、解析不出 host 回空串)。

③ errortoken.go 的契约改写:原「本包之外不应出现 ErrorToken 调用」已被
M50 的正当调用点证伪。新口径按**字段类别**判定:协议枚举字段(status/
code/type/request_id)可在渲染点调用;自由文本字段(message/description/
msg/wording)不得靠它出镜 —— 那类默认不带,要带必须 opt-in + RedactText。
dashscope 的 request_id 例外一并写入。

每条用例都做了变异验证(去掉守卫 / 把守卫挪进控制流 / 改回原串,均打红)。
2026-10-26 19:41:28 +08:00
AskaEth 2702777950 Merge branch 'feat/m45-extracttemporal-extractor-temporal-m' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 19:33:37 +08:00
AskaEth 82e9213804 test(memory): M45 复看 P2 —— 补「temporal 不许被判定读」的源码级断言 + 注释纠正
P2-1(真缺口):r1 复看的变异 C 证明「在异步审查侧拿 item.Temporal 改判」当时
**整个 memory 包全绿** —— 已落库的行在异步侧被改判这条口径没有守卫,而异步侧
(applyVerdict / setIngestRetention)恰恰最容易被人随手接线。

新增 TestTemporalIsNotWiredIntoJudgement:扫生产代码(非 _test.go、非注释行)里
所有含 `.Temporal` 的行,逐行要求命中 temporalFieldSites 白名单(校验 3 条 / 产出 1 条,
**片段级**而不是文件级 —— 按文件放行会让同一文件里新写的接线路过),违规时报
「文件:行号: 表达式片段」;同时校验白名单每一条确实命中过,防止它老化成空头许可证。

变异实测(复看给的写法,插在 preinsert.go 的 applyVerdict 入口):
  --- FAIL: TestTemporalIsNotWiredIntoJudgement
      temporal 被接进了判定(裁决 D:它只许被记录,不许被判定读)…:
          preinsert.go:476: if item.Temporal == TemporalOneOff {
  删掉接线后全绿。

P2-2(文案与 D 矛盾):store_temporal.go 的归属清单原写「判定在 resolveRetention
(唯一入口,第 4 步)」—— D 之后第 4 步是 N1~N7、没有 temporal 步,那句等于给后来者
指了接线入口。改为:temporal 只被记录、不参与判定;要把 temporal 接进判定必须先拿到
模型真实标签分布,并只能通过 temporalFieldSites 白名单(有意的接线决定)进来。

另按复看提醒:temporal.go 头注里那两条统计 SQL 标明「**运维分析用,不属于读侧代码
路径**」,并写明读侧(timegate 与任何读侧 SQL)明令不许对 created_at/expires_at 用
datetime() —— 那是定宽文本比较,抄进查询层会静默判错。

回归:go build ./... + go vet ./... 全绿;memory/db 包测试全绿;memory -race 38.0s 全绿。
2026-10-26 19:29:16 +08:00
AskaEth 996201c4ed fix(httpclient): 信封 message 默认不进错误,渠道上游自由文本一并收口
信封路径(非 2xx + 信封体、2xx + code!=0)此前把上游 message 经 RedactText
拼进 error,而上游会在 message 里回显被拒的请求正文 —— 她的原话因此进了错误
与调用方日志(M49 探针实测:code 1: invalid content: 她刚才说的那句话 ...)。

- envelope message 改为**默认不带**(新 opt-in WithEnvelopeMessage(),与
  trace.UpstreamError 的 message 同一口径);code 与上游 status 保留,
  errors.Is 的 ErrAPI / trace.ErrUpstream 链不变。
- opt-in 下统一走单一清洗 upstreamText:压平空白 + trace.RedactTextLimited,
  上限 40 rune(比 trace 的 128 短:错误里那句话是提示,不是摘要)。
- plugins/channels 同族出口一并收口(扫过全部,命中 4 处):
  telegram description、qq_obv11 msg(错误 + logEchoFailure 日志)、
  weixin_ilink CDN x-error-message;qq 的 status 字段改走 trace.ErrorToken
  令牌守卫。被剥掉的一律由 status / error_code / retcode 承担分类。

每条新增/改写用例都做过变异验证(把字段拼回去即变红)。
2026-10-26 19:28:15 +08:00
AskaEth c7c0294a16 refactor(memory): M45 裁决 D —— temporal 位撤出判定,只保留观测
按 r1 复审裁决 D:这一位先只做观测,`resolveRetention` 不许读它。理由(实测):
无条件 one_off ⇒ temp 会当天删 21 条长期约定;只放开 N6 位置判据能翻的 13 条
逐条是约定、而复审点名的三条目标行 0/3 救得了。在「模型真实标签分布」这份证据
缺位时不该做 7 天后物理删的不可逆决定。

撤接线(结论集逐字回到 M45 之前):
- retention.go:resolveRetention 不再读 item.Temporal;与之对比 dev base
  (af61b8c)本文件**只有注释差异**,规则与判定入口逐字未变。
- extract_retention.go:judgeExtractionRetention 签名回退单参;删除
  judgeRetentionTimepointWide 与 n6_timepoint_wide 规则码。与 dev base 对比
  **同样只有注释差异** ⇒ 「结论集 = 不含 temporal 的那个集合」是结构性的,
  不是靠调用点记得别传。
- 连带取消初版的 durable / recurring ⇒ long(零影响必须是双向的)。

保留观测面(一律未动):迁移 0027 的列、自举前滚、提取器的 temporal 产出、
prompt 枚举、闭集与 normalizeTemporal、store_temporal.go 的校验/编码。
另外把 prompt 里「标成 one_off 会让它进临时层、到期消失」这句**撤接线后已是假话**
改成中性标注说明 —— 否则会诱导模型避开 one_off,观测数据直接被污染。

测试:
- TestTemporalBitDoesNotAffectRetention —— 4 类语料 × 5 个取值,(档位,规则码)
  与未分型逐字相同(核心正断言);
- TestTemporalOneOffDoesNotMakeAnythingTemporary / ...DoesNotRescueRuleHitRows
  —— 零影响的双向点名;
- TestTemporalIsStillRecorded —— one_off 的约定行仍落 temporal='one_off' 而档位
  long、expires 空(观测在、判定不动);
- TestTemporalDoesNotTouchExplicitTierOrRestrictedSource —— 人工入口与受限来源不变。
变异实测:把 `if item.Temporal == TemporalOneOff { return temp }` 插回
resolveRetention ⇒ 上述四条用例全红(FAIL 输出已贴给塔),恢复后全绿。

IngestRuleVersion 保持 d3.5,注释改准为「记录 temporal 信号,但不参与判定」
(留痕面变了:库里多了「模型当时标了什么」这一维,回退会让新旧留痕混在一起)。

P2 三条保留:store.go 500 行(拆 store_temporal.go)、temporal.go 头注('' 的口径
+ 两次被否的接线方案 + 接线前必须看的那两条统计 SQL)、报告语境段。

回归:go build ./... + go vet ./... 全绿;memory/db 包测试全绿;memory -race
38.2s 全绿。
2026-10-26 19:21:24 +08:00
AskaEth 3a0cc64160 Merge branch 'feat/m49-upstreamsafe-move-token-guard-into-t' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 19:18:39 +08:00
AskaEth 3b9ae3f918 refactor(memory): M45 r1 复审返工 —— temporal 位只放开 N6 位置判据
按 r1 复审裁定(p1-3items / fix-then-merge)重做三处:

P1-1/P1-2:删掉自造的六条语义安全阀(temporalSanityVeto)与约定词表
(retentionStatesAgreement)。理由被复审用两条生产行否证:它们既把该救的行
杀了(「…睡得比平时早」被习惯词、「…以后不用再惦记」被永久化标记),又挡不住
同义异形的约定(说定 / 约了)。现在 `one_off` 的全部作用是**放开 N6 的位置判据**:
judgeExtractionRetention 新增 temporal 入参 → judgeRetentionTransient →
judgeRetentionTimepointWide(位置内命中仍返 n6_timepoint,只有靠放开位置才命中
才返新增的 n6_timepoint_wide,供观测区分)。N1~N5、N7、护栏①②、N6 的 1~7 条
语义安全阀一字不动;durable / recurring 仍直接判 long(fail-safe 方向)。

P1-3:上界口径改为「时点词存在 ∧ 语义安全阀全过 ∧ 当前 long」——不依赖任何
标注,可逐条列出。实测(生产库只读副本,快照 2026-10-26):最近 500 条 active
能翻 13 条、**逐条都是「约定 + 今天没走成」**;全量 3694 条能翻 26 条(13 条
约定 + 13 条状态行)。旧的「句首时点词」口径是同义反复,已删。

已知代价(写进 temporal.go 头注与 TestTemporalOneOffWidenedPositionTouchesAgreements,
不藏在词表后面):位置窗口今天正是那批约定行的唯一保护,放开后只要模型标
one_off 它们就会判 temp;而复审点名的两条目标行本片救不了(被语义阀挡,与位置
无关)。因此这一位在当前语料上**没有可测收益**,是否保留是产品裁决。

P2-1:store.go 503 → 497 行,拆出 store_temporal.go(哨兵错误 + 字段校验 +
列值编码)。
P2-2:temporal.go 头注改准 —— `''` 是「0027 之前的历史行 + 没做时间性判断的
写入路径(ManualExtractor / 网关手写 / 整理回填,它们由调用方显式给档位)」。
P2-3:报告补语境(retention_tier 非空仅 26 行、temp 1 行、temporal 列当时还不
存在 ⇒ 重放结论对新写入没有预测力)。

测试:验收点 ①~⑤ 按新语义重写;新增「位置放开可打红」的变异靶子与「N7 位置
不放宽」「无时点词时这一位不做事」「两条目标行救不了」「约定行会翻(已知风险)」
五条边界断言。变异实测:unlocksTimepointPosition 改恒 false ⇒ 三条用例红。
回归:go build ./... + go vet ./... 全绿;memory/db 包测试与 memory -race 全绿。
2026-10-26 19:15:08 +08:00
AskaEth fe3872ee28 fix(trace): 令牌守卫上移进 trace.UpstreamError,构造即安全
M48 复审的中危 finding:error.type 里的散文经 trace.UpstreamError 原样出镜,
因为守卫加在调用方(M46 的 model/voice 两处),构造函数自己不套守卫 ——
任何新调用点默认不安全,httpclient / vision / embedding / weixin 四条出口一直漏。

- trace.UpstreamError 构造时对 error.code / error.type 各走一次 ErrorToken
  ([A-Za-z0-9_.-]{<=64},不合规即空串):默认安全,不是可选加固。
- 构造后注入这条窄路收进 UpstreamErrorInfo.SetCode / SetType(同一道守卫),
  供 DashScope 顶层 code 与 Anthropic 流内 error.type 两处使用 —— 调用方不再
  各抄一份 ErrorToken。
- 删除因此冗余的调用方守卫:model.providerUpstreamError 与
  voice.dashscopeOKBodyError。保留 voice 的 request_id 守卫(它不是 code/type,
  构造函数看不见)。
- 四条漏的出口一行没改,只加用例证明守卫上移后自动安全;摘掉构造函数守卫时
  四条用例 + model/voice 旧用例同时变红。
2026-10-26 19:13:38 +08:00
AskaEth 4224fc2b12 Merge branch 'feat/m47-ttsspawn-detect-and-auto-launch-emot' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 19:08:45 +08:00
AskaEth 4e9cd17fbd Merge branch 'feat/m48-urlcauseharden-urlcause-depth-cap-an' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 19:06:50 +08:00
AskaEth fb55769cd7 Merge branch 'feat/m44-voicerealtime-voice-key-on-realtime' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 19:03:16 +08:00
AskaEth 67d7e4bd1b fix(voice): M47 r2 复审两条 P2 —— 关闭期取消不拉起、非法探活地址不启用
复审 p2-2items(P2-1 跨进程去重按裁决只报不修):

(a) **关闭期 ctx 取消 ≠ 没起**:后端关闭时 ctx 被取消,探活返回
`context.Canceled`;原实现把它判成"没起" ⇒ **在关闭过程中拉起 TTS**。
现在按 `errors.Is(err, context.Canceled)` 判为跳过:不记 markAttempt(不消耗
冷却)、不拉、也不 WARN 成故障。探活**自身超时**(DeadlineExceeded)仍是"没起",
照常拉 —— 两者用 errors.Is 区分,不按文案。新增 OutcomeCanceled。

(b) **探活地址必须合法**:写成 `127.0.0.1:9880`(漏 scheme)会让 http.NewRequest
每次都失败 ⇒「没起」永远成立 ⇒ 每次启动都拉一次进程。新增
`ttslaunch.ValidateProbeURL`(url.Parse + scheme ∈ {http,https} + host 非空):
- `New` 里校验 ⇒ 不合法即 WARN + 不启用(兜底闸,覆盖任何调用方);
- 组合根 `validateResolvedProbeURL` 里同一把闸再校验一次,WARN 带
  `probe_url_source` 指明该改哪个文件 —— **baseUrl 兜底那条路也被覆盖**。

顺带按复审记录项 1 修正 `Proxy: nil` 的注释:net/http 本来就不给回环 IP 走代理,
这行真正保护的是 probeUrl 被配成主机名的情况(原注释高估了它)。

用例(均变异验证打红):
- TestCheck_ParentCanceled_SkipsLaunch:取消 ⇒ OutcomeCanceled + 0 次拉起 + 不进冷却
  (变异:跳过改回"没起" ⇒ Outcome=launched、marker 落地)
- TestCheck_ProbeOwnTimeout_StillLaunches:探活自身超时仍照常拉
- TestNew_InvalidProbeURL_Disables:7 种非法形状 ⇒ 不接单、0 次拉起、有 WARN
- TestStartVoiceTTSLaunch_{ShutdownCancelDoesNotLaunch,InvalidProbeURLStaysOff,
  InvalidBaseURLFallbackStaysOff}:组合根三条,均用真 .bat marker 观察"有没有拉起"
2026-10-26 19:03:12 +08:00
AskaEth 98e1ae6aa9 fix(model): providerUpstreamError 的 error.type 也过令牌守卫
同一份上游报文,兄弟出口 streamEventError 守的正是 type,而 providerUpstreamError
只守了 error.code —— 网关/兼容层把回显塞进 type 时会被原样渲染进错误与日志。
两个字段现在用同一道 trace.ErrorToken:不像令牌的变空串,status/code/body_bytes
等诊断面不受影响。
2026-10-26 19:01:16 +08:00
AskaEth 92b0f89492 fix(trace): URLCause 用层数上限终止,去掉不可比较的 error 比较
手写终止条件只认「长度 1 的自引用」,于是:
- 两节点环(a.Err=b / b.Err=a)⇒ 死循环,打挂当前请求的 goroutine;
- uerr.Err == err 在两侧动态类型相同且不可比较时 ⇒ panic
  (comparing uncomparable type),同样打挂当前请求。

改为唯一终止条件 maxURLCauseDepth=8:超限返回只含 Op 的替身,不返回当前层
(当前层是 *url.Error,Error() 里就是整条地址,不能为防死循环把地址漏回去)。
正常单层/双层路径逐字不变。用例:两节点环 1s 内返回、不可比较类型不 panic、
自引用仍退化成替身、超限路径无 URL 片段、上限内(8 层)仍剥到内层原因。
2026-10-26 19:01:15 +08:00
AskaEth 91101dedfb feat(memory): M45 提取器时间性元数据位 temporal 驱动留存判定
背景(M37 复审的原话):「真正卡住上游的是提取器的改写:一次性事件被写成
30~80 字的第三人称长期陈述……这不是阈值问题,是判定链缺信息位。」本片补的
就是那一位,不动 N1~N7 的任何阈值。

产出侧
- temporal.go:TemporalKind 闭集(one_off / recurring / durable / unknown,
  空串 = 未分型),normalizeTemporal 只认四个字面值,其余一律归 unknown(绝不猜
  同义词);提取提示词追加该枚举(模型只负责标注,判定在 Go 闭集 switch 里)。
- llmextractor:候选解析后归一并随 MemoryItem 带出;非法/缺失值不报错、不拖累
  同批其他候选。

落库
- 迁移 0027_memory_temporal(up: ADD COLUMN temporal TEXT NOT NULL DEFAULT '';
  down: DROP COLUMN,只撤结构、不改写任何行数据)+ NewStore 的自举前滚
  (duplicate column 是预期路径);Store.Add 的同一笔 INSERT 写该列,判定与
  留痕不分叉。

判定(仍是 resolveRetention 一处收口,judgeIngest 一字未改)
- one_off ⇒ temp(+ expires_at),但先过 temporalSanityVeto 六条语义安全阀:
  显式「记住」/ 在陈述约定 / 习惯词 / 硬性长期事实词 / 永久化 / 状态改变 ——
  命中即不给结论、落回 N1~N7。依据是离线重放的实测:去掉「记住+约定」两条时
  21 条长期约定会被判 temp(4.80%),全开后在最近 500 条窗口内翻转条数为 0。
- durable / recurring ⇒ long 且不再跑规则;unknown / 未分型 ⇒ 落回 N1~N7
  (不许因为「正文看着像今天的事」就判 temp)。
- IngestRuleVersion d3.4 → d3.5(判定结论会变,留痕必须可分)。

验证
- go build ./... + go vet ./... 全绿;go test -count=1 ./internal/runtime/memory/...
  ./internal/db/... 全绿;memory -race 全绿。
- 新增 temporal_test.go(验收点 ①~⑤ + 两条交叉护栏 + 闭集/归一化)与
  temporal_migration_test.go(0027 up/down/同形/真实启动顺序)。变异实测:
  摘掉 one_off 支路 ⇒ ① 全红;关掉 temporalSanityVeto ⇒ 三条护栏用例全红。
- 迁移测试的「回滚 N 步」改为从 schema_migrations 现算(migrateDownToBefore),
  新加一条迁移不再打红 0025/0026 的用例。
2026-10-26 18:57:38 +08:00
AskaEth 0ec9cbe32b Merge branch 'feat/m46-urlcause-hoist-url-error-unwrapping' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 18:56:55 +08:00
AskaEth b955a27479 fix(channelbridge): M44 入站语音先写 voice 列再广播 ⇒ 首帧就带 voice
安卓 2026-10-26 回执关心的场景:新来一条语音消息时,**第一条**帧里有没有
`voice`。此前没有 —— `persistBatchUsers` 先 `persistToMain`/`persistIsolated`
(内部同步 notify 把帧发出去),之后才 `persistMessageVoice` 写列,帧发出那
一刻列还是空串。网关侧的帧投影(上一 commit)读的就是这一列,因此首帧必然
缺 voice,客户端要等重连或下一次 REST 拉取。

修法(A 路的唯一可行形态,理由见下):
- `persistBatchUsers` 的通知改成 `persistVoiceFirstNotify(ref)`:
  **INSERT → 写 voice 列 → notify**。语句数不变,只把 notify 从 INSERT 内部
  挪到最后;非语音消息(ref 零值)时该包装与 `notifyPersisted` 逐字等价。
- `persist.go` 抽出 `mainRow` / `isolatedRow`(字段清单只写一次)与
  `persistIsolatedWith`(可注入通知的变体),`persistToMain`/
  `persistIsolated` 的对外行为逐字不变。

为什么不走 B(给 `history.Message` 加 voice 随 INSERT 写):`persistMessageVoice`
是按 id 的 UPDATE,行还不存在时必然 `ErrMessageNotFound` ⇒ 写列**不可能**真正
挪到 INSERT 之前;可行的最小形态就是上面那次换序,无需动 domain 类型、列清单
与 7 条读语句。B 若要做,属于 design/24 §10.5 的独立切片(那时还应删掉这里的
两段式,避免同一事实两处写)。

整链验收(`inbound_voice_gateway_e2e_test.go`,真桥接器 + 真存储 + 真网关 WS):
- 主人私聊语音:第一帧就是 `message_persisted`,带 `voice`,且与
  `GET /sessions/{id}/messages` 同一行**逐字相等**;随后的非语音消息帧整键缺席。
- 群聊语音(被点名 ⇒ 走完整回合):第一帧 `channel_persisted` 带 `voice`,
  且与渠道日志读面同一行逐字相等。
- 变异实测(把顺序还原成「先广播、后写列」):两条用例都 FAIL,输出即报障症状
  (帧里 content 是转写文本,却没有 voice 键)。

顺带把网关侧两处描述旧时序的注释改成新事实。
2026-10-26 18:54:13 +08:00
AskaEth faf18afb91 feat(voice): M47 r1 探活地址单向回退到 voice-tts.json 的 baseUrl(组合根)
塔 2026-10-26 裁决:主人若把合成端口从 9880 改掉,不该还要记得两处一起改。
回退只做一向,且只发生在组合根(包内不互相读配置):

1. voice-tts-launch.json 显式写了 probeUrl ⇒ 以它为准,**永不**被别的键压过;
2. 没写 ⇒ 用 voice-tts.json 的 baseUrl(同一个服务);
3. 两者都空(或 voice-tts.json 坏掉)⇒ 默认 http://127.0.0.1:9880。

启动 Info 行同时打印 probe_url 与 probe_url_source(voice-tts-launch.json /
voice-tts.json / default),一眼看出用的是哪个源。反向(拿这里地址去改合成面)
一律不做。VoiceTTSTuning 坏掉时只回落默认地址并 WARN,**不**因此关掉自启。

新增三条用例(均已变异验证打红):
- ProbeURLInheritedFromVoiceTTS:真让探活打到自定义端口(hits>0),摘掉回退即红
- ProbeURLFallsBackToDefault:两者都空 ⇒ 9880 + source=default
- ExplicitProbeURLWins:显式压过 baseUrl,且 baseUrl 一次都不被探
2026-10-26 18:50:52 +08:00
AskaEth 5c6f4e50a0 refactor(voice): dashscopeErrorToken 收敛到 trace.ErrorToken
令牌守卫的最后一处副本:删掉 voice 本地的 dashscopeErrorToken 与
maxErrorTokenBytes,四个调用点(classifyDashscopeStatus 的 code / request_id、
dashscopeOKBodyError 的 code / type)改调 trace.ErrorToken。守卫实现与原先逐字
同形(TrimSpace、64 字节上限、字母数字与 _ . -),因此零语义变化。

同时修掉一处过期注释:原 dashscopeErrorToken 的文档指向「runtime/model 的
safeErrorToken」——那个符号在本切片里已被删除,注释指向不存在的符号会把下一个人
送去空目录。现在注释与调用点都指向 trace.ErrorToken。

顺带补一条变异可打红的用例:已有用例只断言「令牌形状的 code 被保留」(去掉守卫
照样通过,半张网),新用例断言 4xx 的 code 是散文时连 `code=` 这个键都不出现,
而 status=400 与 request_id 仍在。

验收:go build ./... + go vet ./... + go test -count=1
./internal/runtime/multimodal/voice/... 全绿;变异(code 改回不过守卫)⇒
TestDashscopeTTS_Non200DropsFreeTextCode 变红。
2026-10-26 18:48:01 +08:00
AskaEth 6906d274fc feat(voice): M47 后端启动后探活 emotionTTS,未起则异步拉起一键启动脚本
主人 2026-10-24:「tts启动脚本在这 ...\一键启动.bat,以后启动后端可以顺便检测
一下如果TTS没起就顺带启一下」。本片只做「启动时顺带探一下」,**默认关**(主人
早前说过「开机自启先不急 后面做」),未开开关时连探活都不发。

- 新包 internal/runtime/voice/ttslaunch:探 http://127.0.0.1:9880(毫秒级超时),
  已在跑什么都不做;没起则异步起脚本(cmd.exe /c 传参,不拼命令行字符串)
- 防重复:in-flight 单飞 + 一次尝试后的冷却(失败也算一次,重启风暴不会变成重试风暴)
- 失败只 WARN:探活超时/脚本不存在/起进程失败都只记带 source 的日志,
  组合根那条路径**没有 error 通道**
- 配置走面板文件 $DATA_DIR/config/voice-tts-launch.json(零新 .env 键)
- 接线在 cmd/cyrene 的网关 Listen 之后,整段在后台 goroutine 里,不占启动时长
  (用例以「挂载耗时 < 150ms 而探活超时 800ms」证明)
- 不新开端点、不加帧族;不碰 gateway 包
2026-10-26 18:47:36 +08:00
AskaEth 5717735da9 feat(gateway): M44 实时帧与握手回放挂上 voice(纯增量)
修实根:`message_persisted` / `channel_persisted` 与握手 `history` 回放
此前只在 `toHistoryMessage` 之后直接扇出,于是消息 DTO 的语音面在这三条
面上恒缺席,客户端只能等下一次 REST 拉取才知道那是一条语音。

- `history_voice.go`:把语音补齐抽成存储无关的唯一收口点 `attachVoiceTo`
  (Server 与 hub 共用),并加 `voiceColumnsOf` 把任意历史视图窄化成读面,
  未实现该视图时为真 nil 接口(字段缺席,不 panic、不 500)。
- `broadcast.go`:新增 `persistedWire` —— thinking 抹除 + 可选 voice,
  `broadcastPersisted` 改用它;两类帧因此天然同口径。
- `ws_history.go`:握手回放同样补一次批量读列(最多 50 个 id 一条 SQL)。

成本实测:一帧 = 1 次 `IN (?)` 批量读列(1 个 id);回放一页 = 1 次
(去重后的全部 id);存储未提供该视图时 0 次。`history.Message` 没有
voice 列字段(`messageColumns` 也不含),故无法「零查询直接填」——补它
要改 internal/runtime/history 与 INSERT 写路径,超出本切片范围。

纯增量:voice 是可选键,非语音消息整键缺席(不是 null、不是 {});
帧壳、帧类型、REST DTO 形状一律未动。

测试(广播_voice_test.go):帧序列化输出上断言 ①②③④,并逐字比对
「帧/回放里的 voice」与「REST 读面同一行」;摘掉任一挂载即打红。
2026-10-26 18:46:06 +08:00
AskaEth 3001fcc716 fix(model): providerUpstreamError 的 error.code 过令牌守卫
error.code 与 error.type 一样是**自由文本面**:主流上游里是枚举,但网关、代理与
兼容层可以往里塞任何东西(包括回显的用户原文)。M41 已给 type 加了守卫,
providerUpstreamError 的 code 一直原样带出,是这一族里唯一还漏的字段。

守卫本色从 runtime/model(safeErrorToken)与 runtime/multimodal/voice
(dashscopeErrorToken)两份逐字同形的实现上提到 trace.ErrorToken,model 侧改为
调用它(voice 侧仍是自己那份,不在本切片文件面内,见随附 finding)。

唯一的行为变化就是「不像令牌的 code 变空串」:status / type / body 体积与 sha256、
错误文案模板、双 %w 哨兵链(ErrUpstream、trace.ErrUpstream)都不变。

验收:go build ./... + go vet ./... + trace/model 包 test 全绿;新增用例覆盖
「带空格标点的 code 变空串且 status/type 仍在」与「令牌形状的 code 保留」。
2026-10-26 18:45:38 +08:00
AskaEth 4c187ac070 refactor(trace): 把剥 url.Error 壳的实现收敛成 trace.URLCause
五处逐字同形的三行(mediafetch.sanitizeErr / httpclient.transportCause /
vision.fetchCause / telegram.callCause / weixin_ilink.urlFreeCause)收敛为
trace 包里的唯一实现,13 个调用点改为直接调用;5 份 body 只剩 1 份,5 个
未导出符号与其文件内注释整份删除(不留薄封装)。

行为等价:只剥 *url.Error、不新增包装,内层哨兵链(context 取消/超时、
*net.DNSError 等)原样透传,因此重试/降级/熔断的分类判据不变。两处刻意的
收紧:
- 逐层剥:内层若还是 *url.Error 也一起剥(只剥一层时内层仍带同一段地址);
- *url.Error 的 Err 为 nil(或链自引用)时不再回落到原串,退化成只含 Op 的
  替身 —— 那种错误无法在不带地址的前提下原样返回,而地址里就是凭据。

顺带把 httpclient 的 user:pass@ 用例改成变异可打红:net/http 自己在
*url.Error 里就把密码打成 ***,「密码不出现」在剥壳与不剥壳下都成立(空跑),
用户名才是分水岭。

验收:go build ./... + go vet ./... + 指定包 go test -count=1 全绿;
变异验证(把 URLCause 改成透传)⇒ 五处「不含 URL」断言全红。
2026-10-26 18:45:34 +08:00
AskaEth af61b8ccfe docs(contract): v0.96 语音发音人绑生效人格(sessionId/两个错误码)+ 管理面脱敏投影与 props.omitted
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 14:10:40 +08:00
AskaEth 6be76f1665 Merge branch 'feat/m42-gatewayhygiene-admin-memory-tier-log' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 14:08:57 +08:00
AskaEth f37fe05aee Merge branch 'feat/m42-logchannel-channel-and-tool-url-plus' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 14:03:12 +08:00
AskaEth abbd79a3b1 Merge branch 'feat/m43-logupstreamadopt-adopt-reviewed-comm' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 14:02:22 +08:00
AskaEth 4b4173dd42 fix(channel): M41 r1 复审三条 —— telegram 构造分支、QQ 改 MaskQuery、删两处死上限
P2-1 telegram doJSON 的**请求构造**失败分支:http.NewRequestWithContext 解析失败的
err 同样是 *url.Error(里面是整条 /bot<token>/… 地址),而 poll.go 的 long poll
failed 会原样落日志。改成与传输层分支同形:trace.HostOf(endpoint) + callCause。

P2-2 qq_obv11 connectClient 的成功 INFO 从 maskAccessTokenQuery 换成
trace.MaskQuery:后者认得 access%5Ftoken / token / api_key / 签名参数 / userinfo,
且保留 EscapedPath(路径可读)与不截断。maskAccessTokenQuery 仍被
sanitizeConnectReason 使用,未成死代码。

P2-3 删掉 maxEnvelopeMessageRunes / maxDescriptionRunes(各 200):trace.RedactText
内部已 truncate 到 maxSummaryRunes=80,这两层永不命中,注释口径与实际不符。改为
直接 return trace.RedactText(压平空白),注释写明 80 rune 由 RedactText 负责。

用例(全部变异打红,逐条验证):telegram 构造分支 token、QQ 八形态凭据矩阵
(含四种 maskAccessTokenQuery 漏掉的形态 + 路径可读反向断言)、两处 80 rune 上限。
2026-10-26 13:58:19 +08:00
AskaEth 3157abcf3c fix(gateway): 日志页脱敏改用「可带上限」变体(4096),摘要侧仍是 80
塔 2026-10-26 复审裁定:复用 trace.RedactText 让面板日志页每条 msg 都被切到
80 rune,是实质性 UX 回归(日志页就是排障面)。修法不是网关侧另写脱敏,而是
在 trace 包给上限加一个参数。

- trace/redact.go:新增导出 `RedactTextLimited(s, limit)`,语义 = 同一套凭据形状
  清洗 + 按调用方给的上限截断;`RedactText` 改为它的 `maxSummaryRunes`(80)
  调用形态,既有调用方零改动,清洗逻辑仍只有一份(redactSecrets)。
  limit <= 0 = 不截断,留给体积已自己界定的调用方。
- observability_logs.go:新增常量 `maxLogMsgRunes = 4096`(含注释说明为什么不是
  80、也不是无上限:单条日志可能是 %v 的 MiB 级对象,1000 条会撑爆 JSON),
  adminLogOf 改用 `RedactTextLimited(e.Msg, maxLogMsgRunes)`。
- span props / 摘要侧不动,仍是 80(一个要摘要,一个要全文,两边不该被统一)。

测试(均验过变异打红):
- TestAdminObservabilityLogs_LongLineKeepsFullText:547 rune 的日志行(行首凭据 +
  行尾哨兵)⇒ 凭据打码、末尾哨兵仍在、rune 数只差「凭据→[redacted]」那一处;
  变异(改回 RedactText 80)⇒ 红:拿到 81 rune、末尾哨兵消失。
- TestAdminObservabilityTrace_PropsStayAtSummaryBound:200 rune 的 args_summary
  ⇒ 81 rune(80 + 省略号);变异(把 maxSummaryRunes 提到 4096)⇒ 红。

验收:go build ./... / go vet ./... 全绿;go test -count=1 -race
./internal/services/gateway/... ./internal/runtime/trace/... 全绿。未 push。
2026-10-26 13:58:02 +08:00
AskaEth fa5d3d23bf feat(gateway): 面板人工加记忆钉 long + skill 解析去回显 + 可观测读侧脱敏
三处遗留(M37/M38/M39)同落 internal/services/gateway:

① adminAddMemory 显式传 RetentionTier=long(主人 2026-10-26 裁定「人工动作
  默认 long」)。此前不传档位,面板典型输入(短寒暄/一次性祈使/时效查询/句首
  时点/进行中)会被 resolveRetention 判 temp 并写 7 天有效期 —— 主人在面板上
  亲手加的记忆会静默消失。只钉这一条入口,Store.Add 默认仍由规则判定
  (自动抽取路径不放宽)。

② adminInstallSkillPlugin 解析失败不再打 err.Error():新建
  admin_plugins_skill_error.go,把失败折叠成「类别(yaml_syntax /
  yaml_type_mismatch / skill_invalid / skill_source)+ 行列号」,日志与 400
  响应用同一份摘要。解码器会把出错值前 7 个字符嵌进错误串(M38 #17),
  原样日志等于把上传内容写进 viewer 可读的日志面。

③ 读侧脱敏走既有 helper,不另写一套:
  - adminLogOf 的 msg 过 trace.RedactText(与写侧同一函数);
  - adminSpanOf 先过 trace.SanitizeRecord(REST 尾部与 observability_trace
    推送帧在同一处收口)。⚠️ 有意改变 admin 响应内容:props 变白名单投影 +
    omitted 摘要;字段名与帧形状不变。行数/级别/时间戳/来源、span 的
    status/耗时/时间/名字一律保住。

测试(每条都验过「删掉实现 ⇒ FAIL」):
  - 面板加记忆 ⇒ 落库 (retention_tier='long', expires_at='');对照组证明同一句
    内容走 Store.Add 仍判 temp;
  - 非法 YAML 含哨兵 ⇒ 响应与日志均无哨兵、都有类别与 line 2(内联/文件/语法/
    来源四类);
  - 日志页 msg 含 sk- 形状凭据 ⇒ [redacted],level/source/time 与非敏感正文保留;
  - trace 响应 props 正文 ⇒ omitted Digest、args_summary 打码,排障字段原样。

验收:go build ./... / go vet ./... 全绿;go test -count=1 -race
./internal/services/gateway/... 全绿。未 push(按任务口径)。
2026-10-26 13:53:59 +08:00
AskaEth 9e88605cc8 fix(voice): CloneVoice 的 200-with-message 泄漏收口到 dashscopeOKBodyError
M41 同族的最后一条缺口:HTTP 200 的响应里用顶层 message 报业务失败,而这句话
是上游自由文本 —— create_voice 的 input.url 是参考音频的公开地址(可能是预签名
URL,带凭据),上游报错常把它原样回显。

- ERROR 只留「打到了、但 200 是个业务失败」这个形状事实 + 体积摘要;
  message 原文不进 error,code/type 若真的带回来也只以令牌形态出镜
- errors.Is(err, ErrProviderFailed) 不变(熔断口径不动)
- 用例断言失败仍被识别、且 error 文本里搜不到哨兵正文
2026-10-26 13:50:10 +08:00
AskaEth 0f9251b983 fix(channel): 渠道适配器与出站工具的上游响应体/带凭据 URL 不再进日志与错误(M41)
接 M38 的 18 条真泄漏里「渠道适配器 + 出站工具」这一族(#10 #11 #12 #13 #15 #16)。
只动日志与 error 文字:发送/接收、解析口径、重试次数一个都没改。

- httpclient.do:非 2xx 不再把 512B 响应体原文拼进 error,改 trace.UpstreamError
  (状态/体积/sha256 前缀)+ 信封 code/message(RedactText + 200 rune);error 里的
  target 改 trace.HostOf,并剥掉 *url.Error 外壳(它带整条地址)
- telegram doJSON:4xx 只解 error_code/description(清洗 + 截断),体其余字段不拼;
  传输错误只留来源 —— Bot API 把 token 写在地址路径里
- weixin_ilink doJSON:4096B 体 ⇒ trace.UpstreamError,rawURL ⇒ mediafetch.HostOf;
  uploadOnce 剥掉预签名上传地址(encrypted_query_param),重试日志补来源主机
- qq_obv11 connectClient 成功 INFO:改走同包已有的 maskAccessTokenQuery
  (OneBot 惯例把 ?access_token= 写进 remote_url)
- plugins/tools/io http 拦截日志:改记 trace.HostOf + 原因码,不记原 URL、也不记错误
  原文(解析失败时它包着 *url.Error)

每条都有能变异打红的用例:QQ/iLink 预签名地址的哨兵、三处 4xx 体哨兵、
user:pass@ 与 query 凭据;八处变异逐条验证变红。
2026-10-26 13:47:56 +08:00
AskaEth 66dda084cd fix(runtime): 上游错误体统一走 trace.UpstreamError,正文不进 error
M41 的「LLM / embedding / 视觉 上游错误体」一族(M38 #5 #6 #7 #8 #9 #14):
非 2xx 时把响应体截断后拼进 error,而 error 会被 chat run / loop step /
retrieve_fallback 打日志,等于把她的提问、记忆正文、要念的台词写进日志。

- model:openai/anthropic 两处 upstreamError 收口到 upstream.go 的
  providerUpstreamError;anthropic 流内 error 事件的 evt.Error.Message
  (原本无上限)整段丢弃,只保留令牌形状的 type
- embedding:readErrorBody 删除,改走 helper(input 就是她的记忆正文)
- vision:错误路径不再沿用取图的 50 MiB 上限,改为 4 KiB 有界读且不拼原文
- voice:classifyDashscopeStatus 只解 code/request_id,正文只留在内存里做
  分类判据;错误体读取同样改为 4 KiB 有界
- 测试:五个上游各一条「日志/error 里搜不到哨兵正文」用例,且能变异打红
2026-10-26 13:44:32 +08:00
AskaEth a697fa0a1e Merge branch 'feat/m35-ttsemo-emotiontts-provider-with-emot' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 13:44:00 +08:00
AskaEth 48b3dae5ed Merge branch 'feat/m37-memtier-retention-tier-column-and-de' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 13:42:20 +08:00
AskaEth b90811fae4 docs(voice): 收口「没会话/没绑定就走 default」的镜像注释(r4)
唯一 finding:audio.go 的 speechRequest.SessionID 注释原本写「省略它时本口没有会话
概念 ⇒ 走 emotionTTS 的 default 分支」——**反了**。真实链路是
effectivePersona(ctx,"") → sessionPersona("") 为空**不是终点** → Effective =
defaultPersonaID() → 常规安装必有全局默认人格 ⇒ personaId 非空 ⇒ 走 personas 映射。
照那句话改的人会写出 `if req.SessionID == "" { personaID = "" }`,正是 r2 修掉的病。
改为:省略时按**全局默认人格**选发音人(会话绑定 → 全局默认人格 → 两者都没有才落到
default 字符),并显式写了"不要写成 if SessionID == \"\" { personaID = \"\" }"。

顺手同根三处(不留第四轮):
- tts/config.go 的 Config.Default 注释:成因表「会话无绑定 / 读取失败 / 该出口本身
  没有会话」已过时 ⇒ 改为「空串的唯一合法成因是连全局默认人格都没有」,并写明不是
  "会话未绑定"、也不是"出站口没有会话"。
- runtimeconfig/voicetts.go 的 Default 字段注释与文件头同族措辞同步;
- selection.go 的 WithPersonaID / Resolve 分支表、service.go 的分支小标把
  「解析不到」统一改成「连全局默认人格都没有」;
- voice_emotiontts_test.go 的 TestSpeechEndpointNoSessionUsesDefault 注释同步(该用例
  没接人格库,所以确实落到 default —— 但成因写对了);
- wire_voice_tts_test.go nit:改成「已废弃的全局 `character` 不生效」(墓碑键仍在)。

纯注释:`git diff` 里非注释改动行为空。go build/vet 通过;四包 go test -count=1 全绿。
2026-10-26 13:40:00 +08:00
AskaEth 46a6da13e0 Merge branch 'feat/m40-logtrace-span-write-side-redaction-p' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 13:36:31 +08:00
AskaEth 066319f9c6 fix(memory): 复审 r2 返工 —— 真实「记住 X」路径钉 long,断言改为能打红
复审 r2 = p2-2items 全在「RememberManual 钉 long」那半,两条指向同一件事:
**我以为修好的人工路径实际没修**。逐条:

P2-1 上一版断言是**空断言**:夹具正文「记住她喜欢喝咖啡」含「记住」⇒
extract_retention.go 的护栏① 直接放行 ⇒ 默认落点本来就是 long,因此删掉
store.go 的 pin 仍然绿。
  → extract_test.go 的 TestStore_RememberManual_正常 改为一对语料:
    「我正在测通道」(N7,规则真会判 temp)+「记住她喜欢喝咖啡」(正文保留契约),
    并同时断言档位、`ExpiresAt` 零值、以及**落库列** `(retention_tier, expires_at)`。

P2-2 pin 钉在零生产调用方的方法上:真实路径是 ManualExtractor.Extract(无可用
LLM 出口时的生产回退)→ persistMemories → AddScoped → Add,而 rememberDirective
**先剥掉「记住」前缀** ⇒ 护栏① 永远看不到它。改前实测:记住今天下雨了→temp/n6、
记住我正在测通道→temp/n7、记住现在几点了→temp/n4(均 +7 天)。
  → extract.go 的 ManualExtractor.Extract 在**产出侧**显式置
    `RetentionTier: RetentionLong`(并在注释里写清「不能靠护栏①」的原因)。
    store.go 的 RememberManual pin 保留(两条独立的契约点)。
  → 新增 TestManualExtractorItemsStayLongTerm:四条正文过 Extract →
    复刻 persistMemories 的溯源覆盖 → AddScoped 落库 → 断言**落库列**
    `(long, 空串)`。

**变异证据(删掉两处 pin 后实测)**:
  --- FAIL: TestManualExtractorItemsStayLongTerm
      extract_test.go:167: 「记住今天下雨了」全链落库 =
        (tier="temp", expires_at="2026-11-02T05:34:20.844624200Z"), want (long, 空串)
  --- FAIL: TestStore_RememberManual_正常
      extract_test.go:99: RememberManual("我正在测通道") 的档位 = "temp", want "long"
恢复 pin 后两条均 PASS。

回归:go build ./... + go vet ./... + go test -count=1 ./internal/runtime/memory/...
./internal/db/... ./cmd/cyrene/ 全绿;go test -race ./internal/runtime/memory/ 绿。
2026-10-26 13:36:15 +08:00
AskaEth 1c2944fcf4 docs(voice): 收回 r2 修掉的旧语义注释 + 让关闭态变异真能打红(r3 P2-1/P2-2)
P2-1(两处注释在描述已被推翻的旧语义,会把 r2 的修复写回去):
- voice.go 的 voiceReplySink 文档:原写「personaID = env.PersonaID,会话无绑定时为空」
  ⇒ 实际传的是 turnPersona.Effective,**未绑定 ≠ 空**(有全局默认人格时非空)。
  改为写明「生效人格(会话绑定 → 全局默认人格;两者都没有才是空)」,并显式指出
  它不是 env.PersonaID、把空当"没有角色"会让主会话落到 default(正是 r2 修掉的)。
- voice.go 的 synthesizeVoiceReply:原写「空 personaID = 会话无绑定 ⇒ 走 default」
  ⇒ 反了。改为「空 = 连全局默认人格都没有 ⇒ 才走 voice-tts.json 的 default」。
- 同族 format 残留措辞:client_test.go 的「非法格式的回落」(该行为已不存在)、
  cmd/cyrene/wire_voice_tts_test.go 的「角色/格式/超时按文件来」都改写。
- nit:voice.go 的 `env, turnPersona := ...` 遮蔽同包类型名,改成 `tp`。

P2-2(我上轮那条「摘掉 PrimaryActive 闸 ⇒ 用例红」不成立,复审实测仍绿):
根因是 audioStubVoice 不实现 PrimaryActive,拦住 persona 读取的只有类型断言。
按塔选的第二种收口(把测试做强):
- audioStubVoice 补 `PrimaryActive() bool { return true }`(它是"生效中的 persona
  voice"替身),并在注释里说明它存在的理由;
- 关闭态用例改用新替身 inactivePersonaVoice(实现 personaVoice 但报 false),
  于是 PrimaryActive 成为关闭态唯一的闸;
- 重跑变异(摘掉 `&& pv.PrimaryActive()`)**实测打红**:
  「未生效的 personaVoice 却查了 1 次会话人格(关闭态行为变了)」。

未动:功能面、错误码、500 状态码、白名单、session 可读、隐私/format/Stats/redactURL。
go build ./... + go vet ./... + go test -count=1 -race 覆盖 tts/runtimeconfig/gateway/
cmd/cyrene 全绿。
2026-10-26 13:32:04 +08:00
AskaEth 542bac677d fix(trace): UpstreamError 默认不带 error.message,要带必须显式 opt-in
复审 P2 收口。上一条留的漏法(不是理论,是构造得出的):
`{"error":{"code":"invalid_request_error","message":"Invalid value: <用户原文>"}}`
—— 默认保留前 128 rune,那 128 rune 就会**原样**进 error 与日志。此前的用例只钉了
input/echo 这类 sibling 字段,没钉到 message 这一面;而 M41(embedding input 是她的
记忆正文、DashScope TTS input 是她要念的话)与 M42(渠道响应体)都把这个 helper 当
模板,默认带 message 等于把洞复制到十几处。

改法(默认安全,放宽必须写出来):

- `UpstreamError(upstream, status, contentType, body, opts ...UpstreamOption)`:
  最常用的四参调用**天然是最安全的那个**;默认只留 Upstream/Status/ContentType/
  BodyLen/BodySHA256/Code/Type;
- 新增 `WithUpstreamMessage()` 作为显式开关:带 message 时**必须**过 RedactText
  (凭据形状打码 + 压平空白)再截到 maxUpstreamMessageRunes,并在注释里写明
  「只有在你确认该上游不会回显用户内容时才可用」;
- 文件头写清取舍:**宁可少一条人话提示,也不让她的原话进日志**;排障靠
  status + code + type + bodyLen + sha256 通常足够分类(400 还是 429、是
  model_not_found 还是 context_length_exceeded、是不是同一份报文),细节要看时
  报文在调用点上下文里还在。

测试(可变异打红):
- 新增 TestUpstreamError_DefaultDropsMessage:message 里塞哨兵的 400 ⇒ 默认构造下
  error 与 slog 输出都搜不到哨兵,Message 为空,而 status/code/type/bodyLen/sha256
  仍可辨;Attrs() 也不旁路带出;
- 新增 TestUpstreamError_OptInMessageIsSanitized:opt-in 后 message 被截到 128 rune
  且凭据被打码(超长 + 带 token 的 message);
- 既有 TestUpstreamError_DropsEchoedBody 补一条「默认 Message 为空」;
- 变异验证:把默认构造改回带 message ⇒ 前两条变红;让 opt-in 跳过 RedactText ⇒
  opt-in 那条变红。

回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿(backthink 的两条
2s 定时用例在满并发下偶发超时,单元重跑与整仓重跑均绿,与本改动无关:该包不引用
trace);trace 包另跑 -race 绿。只动 upstream.go 与其测试,白名单/session/loop/
orchestration 未动。
2026-10-26 13:31:11 +08:00
AskaEth 765ef167bd fix(memory): 复审 p2 返工 —— SQL 空串字面量、两处过期注释、人工路径档位
复审 p2-1items(tip cc6904b)要求的三处,逐条:

1) SQL 双引号改单引号(唯一返工项):
   - preinsert_store.go 的 revertMergedSQL:`merged_into = ""` → `''`。今天功能等价,
     但靠的是 SQLite「双引号标识符解析不到 ⇒ 回退成字符串字面量」的 legacy 行为;
     这是一条**改数据**的语句,还被 RevertMerged 与整理侧回滚共用,不该依赖回退。
   - retention_migration_test.go 的 `WHERE retention_tier = ""` 一并统一成 `''`,
     并在原位留下「为什么」。
2) 两处过期注释:
   - retention_schema.go:「反过来(先自举、后迁移)也成立」是**假结论**,改为
     「生产顺序成立;反向顺序在 0019 处即失败(duplicate column name: status,既有问题)」。
   - store.go 的 ExpiresAt:「本字段目前只写不读」已半过期(Add 现在会把最终落库
     时刻回填到返回值),改成「读回语义是半开的」:Add 返回的条目有值,从库里读回的
     恒为零值。
3) 人工路径(塔裁定「人工动作默认应为 long」):包内的 `RememberManual`(「记住 X」)
   显式钉 `RetentionTier = long`,留存规则因此不许把人工明确要求记住的东西降成临时层
   (extract_test.go 加断言)。面板网关那条(adminAddMemory 直调 Add、不传档位)
   按塔的口径不在本片、留给后续切片 —— 本片对它的唯一影响见回报。

不动(按塔的裁定):preinsert.go 514 行(dev 既有 tech debt)、
design/03 §10.5 与 design/23 §18 的章节(塔的账)、gateway 人工路径。

回归:go build ./... + go vet ./... + go test -count=1 ./internal/runtime/memory/... ./internal/db/...
全绿,内存包 -race 亦绿。
2026-10-26 13:24:35 +08:00
AskaEth e684061e56 fix(voice): 主/子系统会话的发音人跟「生效人格」走(r2 P2-1)+ 错误码可区分
P2-1(实质):主会话(web)的 persona_id 恒为空且无重绑路径,而"空"的真实语义是
「继承全局默认人格」(loop/persona.go 回退 Options.PersonaID;前端 lib/persona.ts 的
bound ?? defaultPersona(personas) 是同一口径)。此前只有 TTS 把空读成"没有生效人格"
⇒ 落到 voice-tts.json 的 default:主会话聊天是 furina、声音却是 default 那个人,
换全局人格后也不跟声 —— 正是主人抱怨的那件事,且发生在唯一能给普通聊天出声的路径。

修法:
- turnEnvResolver 新增 resolvePersona/effectivePersona(绑定 → 全局默认人格 → ""),
  默认人格扫描抽成 defaultPersonaIDFrom 一处(*Server.defaultPersonaID 转调它);
- ownerWithPersona 复用**同一次**会话读取同时给出 loop 用的 binding 与呈现用的
  effective —— WS 语音回合为选音色不新增任何 I/O;
- REST:现有闸内改用 effectivePersona;WS:voice.go 那次 env 解析处取一次传给 sink。
因此 `default` 只剩"连默认人格都没有"这一种用途。
- service.go 那句"换人格后用老角色声音不可能发生"收回为限定说法:本包无缓存、无兜底
  角色、未映射即拒绝,但**调用方必须传生效人格**(网关两处已修)。

回归锁:voice_persona_inherit_test.go 复现复审用例(名字不同、两条口各一条)——
未绑定会话 + 全局默认 furina + default 指向 cyrene ⇒ 必须用 furina 的声音;
变异(effectivePersona 退回「空 = 无」)实测两条都红,报文与复审一致。

建议 3(顺手做):persona 未映射时状态码仍 500(服务端配置缺失),但错误码换成
tts_persona_unmapped / tts_no_character,不再与一般合成失败混成一个码;
用例 TestSpeechEndpointPersonaUnmappedErrorCode 钉住。

P2-2(nit):voicetts.go 开头删掉「格式、」两字(该键已删除)。

go build ./... + go vet ./... + go test(含 -race)全绿。
2026-10-26 13:21:44 +08:00
AskaEth 6d0df6a825 fix(trace): 白名单放行 session —— 归属标识可读,正文边界不动
主人 2026-10-26 裁定:session 是**业务标识**,不是正文。它是排障时把一条 stderr
日志对回某个会话的唯一线索;而 /admin/flow/graph 本来就按会话返回、连 sessionId
都给了,图里多一个 session 不构成新的暴露。因此把 session 加进共享白名单
(safePropKeys,走 redactValue 的普通清洗),不再被写侧折成 Digest。

「不放宽白名单」仍是原则:本次只放行这一个键,正文的边界一个字没动 ——

- trace 侧新增 TestJSONRecorder_KeepsSessionReadableDropsBody,**一条用例同时钉
  两件相反的事**:JSONL 里 `"session":"web#lane-2"` 原文可读;同一 span 里的
  result / text 哨兵正文仍然搜不到,且只在 omitted 里留下体积与指纹。
- loop 侧真循环用例补一句断言:落盘里 `"session":"sess"` 可读,而工具结果哨兵
  同时仍不可见。
- 变异验证:把 session 从白名单摘掉,上述两处都会变红(session 落到 omitted)。

回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
trace/loop/orchestration 另跑 -race 全绿。
2026-10-26 13:20:45 +08:00
AskaEth 003d12a712 feat(trace): M40 span 写侧脱敏 + 共享上游错误收敛 helper
stderr 的 JSONL 是无主人在场的落盘面,却在此前原样落工具结果(result)、
子代理回复(text/summary)与上游报文,等于一次 tail 就能拷走整段对话。
本切片交付两件地基:

1) 写侧脱敏(trace.JSONRecorder)
   - 新增 trace/span_props.go:safePropKeys 白名单与 projectSpanProps 投影,
     读侧(safeSpanProps)与写侧共用同一张表、同一个投影函数;
   - WriteSpan 落盘前经 trace.SanitizeRecord 投影:白名单键清洗,表外键只留
     Digest(bytes/runes/sha256 前缀,收进 omitted),事件先 RedactEvent 再把
     正文键折成 Digest。落到磁盘的只有元数据,正文一个字节都不留;
   - 新增 trace/digest.go:Digest/DigestOf/DigestOfValue,给「脱敏后仍要能诊断」
     一个构造上不含正文的取值形态(体积 + 指纹),两条投影都按类型放行它。

2) 共享上游错误体收敛 helper(给 M41/M42,禁止三家各写一份)
   - trace.UpstreamError(upstream, status, contentType, body):只保留 status /
     contentType / bodyLen / body sha256 前缀 / 上游 JSON 的 error.code /
     error.type / 截断 128 rune 的 error.message,其余一律丢弃,响应体原文绝不
     进 error 或日志;*UpstreamErrorInfo 提供 Error()/Attrs()/Unwrap()(errors.Is
     可判 ErrUpstream 走降级链);
   - trace.HostOf / trace.MaskQuery:日志里的 URL 只回来源,凭据类 query 与
     fragment 值打码为 ***,user:pass@ 整段打码。

3) 剥掉三处正文上 span
   - loop:tool span 不再放 result,改放 result_summary(与 tool_execution.end
     帧同源、已脱敏截断)与 result_digest;
   - orchestration:subagent span 不再放 text,subagent.done 事件不再放 summary,
     改记 status/steps/chars/sha256;
   - automations:AddRuleByName 不再 %v 整个 payload,改记 subject/type/bytes/sha256。

测试:以哨兵正文在落盘字节上断言(JSONL / slog 输出),并分别对「编排层收到的
原始 span」与「写侧投影」两层各钉一遍;UpstreamError 用回显 input 的 422 报文
与 HTML 错误页各钉一条;解析出/未解析出 JSON 都不回原文。以上均做过变异验证
(去掉保护即变红)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
trace/loop/orchestration 另跑 -race 全绿。
2026-10-26 13:17:42 +08:00
AskaEth cc6904b1b4 feat(memory): retention_tier 档位列 + defer 与档位合并为同一判定(M37)
主人 2026-10-26 对「记忆分型」裁定「两列」:kind(关于什么)保留,新增
retention_tier(留多久 / 干什么用)。本片把这条轴从列、判定、写入、读侧过滤
一路打通,并把「判第五态 defer」与「判 retention_tier=temp」合并成一个判定。

结构(迁移 0026 + 自举前滚):
- memory_items 新增 retention_tier TEXT NOT NULL DEFAULT ''(空串 = 尚未分型,
  历史行与受限来源行),索引 idx_memory_items_retention_tier;up/down 均实跑过
  (在隔离的生产库副本上 migrate → 建 Store → down → 再 up,5073 行零丢失)。
- NewStore 幂等前滚该列 + 建索引(ensureRetentionSchema),与迁移同形由测试钉住。

判定(retention.go,全仓唯一入口):
- RetentionTier 闭集 temp/daily/work/long/knowledge + 空串未分型;与 kind 正交。
- resolveRetention 的优先级:调用方显式档位 > 显式有效期(⇒ temp)> 受限来源
  不分型 > 留存规则 N1~N7 ⇒ temp > 默认 long。判据靠元数据,规则不许推翻显式档位。
- tempDeadline 是「档位 → expires_at」的唯一换算:temp ⇔ 有效期非空,两列同一笔
  INSERT 落下(M2 登记过的「INSERT 与补写之间的短窗口」就此关闭)。

合并(defer ⇔ temp):
- judgeIngest 不再跑留存规则,只读 Add 落定的 item.RetentionTier;规则调用点因此
  全仓唯一(源码级断言钉住)。
- 判定不再依赖同步闸是否开启:闸未接/关着时档位与有效期照样落库。
- 异步层对已是 temp 的行不重复改写(避免 TTL 随审查延迟漂移)。
- IngestRuleVersion d3.3 → d3.4(判据作用范围变了,按既有硬规矩升版)。

读侧:
- 四条列表读路径的 SELECT 与 scanItem 读出 retention_tier(14 列);Get 同样读回。
- 新增 ListByPersonaTiers / ListScopeTiers / ListTiers / ListProjectTiers(可选档位
  过滤,缺省不过滤 ⇒ 历史行照常返回;显式传空串只取未分型;非法档位报错)。
  原方法签名逐字不变 —— 网关 MemoryStore 接口钉着它们,可变参数会编译不过。
- store.go 的读路径拆到 store_list.go,守 500 行上限。

回归:go build ./... + go vet ./... + go test ./... 全绿(内存包另有 -race)。
2026-10-26 13:09:30 +08:00
AskaEth 8820b0b316 fix(voice): 修 r1 复审四条(正文进日志的隐私泄漏 / format 假开关 / Stats 无消费者 / redactURL 兜底)
上一版把 r1 的三条 finding 静默漏了(我在交付前没读 inbox),本提交逐条补齐:

1. 🔴 隐私:upstreamStatus 不再读也不记响应体
   FastAPI 的 422 会把请求体里的 input 整段回显,原来「错误体前 512 字节进 WARN」
   等于把她的原话写进日志。现在只记 status / content_type / response_bytes
   (字节数优先取 Content-Length,缺失时有界排空取上界,读到即丢)。
   新用例 TestSpeechRejectLogOmitsRequestText 用**回显正文**的假上游抓它;
   变异(把 body 塞回 WARN)实测打红。

2. format 键彻底删除(假开关)
   上游只拿 response_format 改自报 media_type,产物恒 .wav 无转码,我方只按字节
   嗅探裁决 ⇒ wav/mp3/ogg 交付字节与响应头完全相同。删 tts.Config.Format 与
   VoiceTTSTuning.Format,encodeSpeech 硬编码 requestFormat="wav"(不可配置)。
   用例钉住请求体恒为 wav;变异(改成 mp3)打红。旧文件里残留的 format 键被忽略。

3. Stats 接进 degrade 的 WARN(attempts/failures)
   否则统计在生产日志里没有消费者。TestServiceDegradeLogsCounters 钉住;
   变异(去掉两个字段)打红。

4. redactURL 兜底含 "@" 整体打码
   解析失败/无 host 时原来原样返回,user:pass@ 会漏凭据。TestRedactURLCredentials
   覆盖合法 URL 去 userinfo、非法含 @ 打码;变异(退回原样返回)打红。

另:关闭态零行为变化的**当前 tip 重跑**——
- handleSpeech 的 persona 解析改为只在语音服务实现 personaVoice 且 PrimaryActive
  时才查库;未接 emotionTTS(既有链/stub)时零额外 I/O、ctx 不改、音频字节相同。
  用例 TestSpeechEndpointDisabledVoiceDoesNotReadPersona(含对照组证明闸不是恒关);
  变异(去掉闸)打红。
- voice.go 在 dev 基线里本就有 env.PersonaID 解析(无人格解析新增 I/O),本次未改。

Config.Normalized 去掉未使用的 logger 参数。go build/vet/test(含 -race)全绿;
真机 live 复测:cyrene 264104 B、furina 221194 B。
2026-10-26 13:06:39 +08:00
AskaEth efc25c6a7f feat(voice): emotionTTS 发音人绑定会话人格(不再全局定死一个 character)
主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的角色的,不然会出现以后换了
别的角色(人格配置),还是用老角色的 TTS 角色」——上一版把 character 写在面板文件
里当全局值,换人格后声音不变,是真缺陷。

配置形状(单一真源 = $DATA_DIR/config/voice-tts.json):
  {"enabled":true,
   "personas":{"<personaId>":"<characterId>"},
   "default":"<characterId>",   // 只服务「解析不到 personaId」
   "format":"wav","timeoutSec":60}
- 旧的全局 `character` 键**废弃并被忽略**(启动 WARN 提示改用 personas);
  Config.Normalized 不给任何角色兜底(代码里写死默认角色=病害本身);
- configs/personas/*.yaml 不参与(塔裁定:避免启动期静态配置与面板两处漂移);
- 零新 .env 键。

选角四条分支(Config.Resolve,纯函数、无缓存):
  ① persona 有映射 ⇒ 用它;
  ② personaId 为空/解析不到 + 配了 default ⇒ 用 default;
  ③ personaId 为空 + 没配 default ⇒ 不合成(ErrNoCharacter,上游 0 请求);
  ④ persona 无映射(含映射值为空)⇒ 拒绝(ErrPersonaUnmapped),
     **绝不**拿 default 或别的角色顶 —— 主人担心的病害本身。

两条既有出站口各自按当次请求解析 persona(不在 SetVoice 时算一次缓存成全局):
- WS voice_output:用本回合的 env.PersonaID(runVoiceTurn/runVoiceTurnDirect),
  经 voiceReplySink 传给 synthesizeVoiceReply,再 WithPersonaID 交给语音面;
- POST /api/v1/audio/speech:请求体新增**可选** sessionId(纯增量),
  经 turnEnvResolver.sessionPersona 解析;省略 ⇒ 走 default 分支。

Client.Speech 改为**每次传入 character**(内部不缓存发音人);Provider 适配同样按
ctx 的 personaId 现算。选不到发音人时**不回落既有链**(回落=用另一个声音说话),
上游拒绝(404/500)仍按既有口径回落既有链+WARN。

测试:四条分支各自可变异打红,断言打在「上游请求体里的 voice」与帧/响应字节上;
含「同一进程先后两个 persona,第二次绝不沿用第一次 voice」的回归锁,
以及两条出站口的端到端 persona 切换。四条变异(未映射回落 default / 客户端缓存
voice / REST 不看会话 / 选不到时回落底链)均已实测打红后还原。
go build ./... / go vet ./... / go test -race 全绿;真机 live 证据:
cyrene(char_84fb88a3)=254630 B、furina(char_5314489e)=213392 B。
2026-10-26 12:57:35 +08:00
AskaEth 240b59708d docs(contract): 矩阵补 B28-B31 与计数改正(14+31+40=85,纠正 104 的重复计入)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 12:48:53 +08:00
AskaEth 3a2711a7e0 feat(voice): emotionTTS 接成一级发音人(UTF-8 请求体 + 失败回落既有链)
侦察结论(真机 + 上游源码):
- POST /api/analyze_emotion ⇒ {primary,complex,intensity};primary 闭集
  实测 平/喜/惊/哀/怒/厌/惧/急,intensity ∈ Low/Medium/High。
- POST /api/synthesize 收 emo_vector(float[])+emo_alpha,但还要 ref_audio_filename
  且只回 {"audio_url"}(服务端落盘),不适合做运行时直出。
- POST /v1/audio/speech 内部先 LLM 分析情绪 + 匹配参考音,再把情绪向量以
  [EMO:{vector}|{alpha}]base64:... 注入 voice 交给 indexTTS2 ⇒ **情绪确实影响
  语气,但由服务端从待念文本自推**,我方没有可映射的参数。
- 上游把下游载荷的 speed 硬编码 1.0(core/utils/llm_provider.py),客户端
  speed 是空转 ⇒ 本切片不暴露 speed,也不暴露 emotion/tone(不造假开关)。

实现:
- internal/runtime/voice/tts:Client(唯一 HTTP 出口,json.Encoder 产出 UTF-8
  请求体、超时/非 200/非音频/超限各自可分类)+ 字节嗅探庭裁 + Service(emotionTTS
  在前、失败回落既有链,永不上抛成回复失败)+ Provider 适配(可作链的一级)。
- internal/config/runtimeconfig/voicetts.go:$DATA_DIR/config/voice-tts.json
  (enabled/baseUrl/character/format/timeoutSec),零新 .env 键。
- cmd/cyrene/wire_voice_tts.go + wireFrontend 的唯一注入点:两条既有出站口
  (POST /api/v1/audio/speech 与 WS voice_output)同时用上;不新建端点/帧族。
- 严守 voice 入站闭集(不扩 Source/Status),出站音频不挂 voice DTO(塔裁决 B)。

测试:中文 UTF-8 请求体、非 200、超时、非音频、超限、未启用、空文本、嗅探表、
降级回落、未启用透传、两条出站口的字节级实测、面板配置解析、组合根映射;
三条变异(去中文 / 嗅探恒真 / 去掉回落)各自打红。真机 live 用例(build tag
emotiontts_live)实测 278,036 字节 WAV。
2026-10-26 12:46:57 +08:00
AskaEth 4c79728553 docs(contract): v0.94 MCP 管理面与工具 origin + v0.95 渠道静默窗口,含写门不对称与呈现隔离口径
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 12:46:25 +08:00
AskaEth a895754d55 Merge branch 'feat/m34-memdedup-dedupe-group-action-plus-re' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-25 00:52:28 +08:00
AskaEth 01aff71bc1 fix(memory): dedupe_group 数值门改三态(归一后同值可合 / 同量纲不同值冲突 / 量纲不可比保守拒),修 r3 复审两项
r3 复审指出我第一版中文数字门两个方向都反了(规格本身被复审自己收回重写):

- 项 2(过严):归一后**同值**、中文与 ASCII 混写的两条合不了
  (`一小时` vs `60 分钟`、`十五分钟` vs `15 分钟`)。根因是判定仍以 `sameNumbers`
  (只抽 ASCII token)为闸:`{}` vs `{60}` 不等 ⇒ 直接否决,归一化没机会生效。
- 项 3(过松):**量纲不可比**(`一小时` vs `一杯`)被当成"不算冲突"⇒ 会合并,
  与「比不了单位就不许合并」相反。

修法:数值门从「布尔冲突」改成**三态**,并取代 `sameNumbers` 作为 dedupe 的数值判据:

| 结果 | 含义 | 合并 |
|---|---|---|
| tidyNumberSame | 归一后同值(含中英混写、单位换算:一小时 ≡ 60 分钟) | ✅ |
| tidyNumberConflict | 同量纲、值不相等(含单位不可互换:两杯 vs 三瓶) | ❌ |
| tidyNumberIncomparable | 量纲不可比 / 存活行没有同量纲数量可比(一小时 vs 一杯) | ❌ 保守拒绝 |

配套:
- 数量抽取同时支持 **ASCII 与中文数字 + 单位**(`60 分钟` / `15分钟` / `十五分钟` /
  `半个小时` / `一万两千行`),并新增 `字符`/`字节` 两个文字量单位;
- **裸数字 token(无单位)**仍按 `sameNumbers` 的集合相等口径判(`0023` vs `0024`
  这类靠它拦住),并有 parity 用例钉住「与 extractTidyFacts 的 nums 逐项一致」;
- 三态里 `incomparable` 只**收紧**,不放宽任何既有门;`sameNumbers` 函数本身未改
  (judgePair/judgeIngest 共用,另处 blast radius,已登记建议)。

回归用例(三态各一条 + 修前红/修后绿,均实测):
- TestDedupeGroupNumberGateThreeStates 四个子用例(两条同值含混写、同量纲不同值、量纲不可比),
  同时断言判定与簇结果;
  · 变异 = 退回 `sameNumbers`(项 2 旧行为)⇒ 两条「同值」子用例红;
  · 变异 = 不可比当放行(项 3 旧行为)⇒ 「量纲不可比」子用例红。
- TestDedupeGroupRejectsConflictingChineseNumbers / RejectsNonConvertibleUnits /
  SameChineseNumberStillMerges(r2 三项保持绿)+ TestTidyQuantitiesNumberParsing +
  TestScanTidyNumbersBareTokensMatchExistingFactNums(裸 token 口径不变)。

语料实测(生产库只读 mode=ro,active 3667,999 对已过否定/取值/相似度门的成员对):
三态分布 same=942 / conflict=25 / incomparable=32;dedupe 产出
`250 簇/495 行` → **248 簇/489 行(13.3%)**。incomparable 那 32 对是「四行歌词 vs
一段歌词」「一个项目进度代号 vs 项目进度代号为「w49」」这类:按「比不了就不许合并」
一律拒绝,聚合上只少减 6 行(簇成员互有重叠)。

验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
2026-10-25 00:49:44 +08:00
AskaEth 7700a7eda0 fix(memory): dedupe_group 补中文数字门(量纲/单位归一),修 r2 复审 p2
r2 复审 p2:`sameNumbers` 只抽 ASCII 数字 token,而本语料的数字主要写成中文
(一小时 / 十五分钟 / 第二杯 / 三千次轮回)⇒「午睡一小时 vs 十五分钟」
「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**被当成同一事实合并。

修法(新文件 internal/runtime/memory/tidy_dedupe_numbers.go):
- 抽出「**中文数字 + 单位**」对(一~九/两/半/十/百/千/万,含十五、二十四、一万两千、
  半个小时),按**量纲归一**后比较:时间=秒(分钟 60 / 小时 3600 / 天 86400 /
  周 604800 / 个月 2592000 / 年 31536000)、长度=米、文字量=个、计数=个;
- 只认**后面紧跟已知单位**的数字:`一起 / 一直 / 一些 / 一样 / 第二天(序数位置)`
  这类含数字的习语一律不产生数量 —— 不做成「有中文数字就不合并」;
- 判据是**冲突**而非差异:存活行在同量纲里没有对应数量 ⇒ 不算冲突(被吸收行多出
  的细节);等值(含单位换算后等值,如「半小时」=1800 秒)⇒ 照常合并;
  值不等且单位换算不到一起 ⇒ 按冲突处理(复审口径:宁可不合并);
- 只**增加否决**,不放宽任何既有门;ASCII 路径仍由 sameNumbers 先判,一字未动;
  `sameNumbers` 本身也未改(judgePair/judgeIngest 共用它,属于另一处 blast radius,
  已在回报里登记为后续建议)。

语料实测(生产库只读 mode=ro,active 3667):999 对里中文数字门挡掉 **17 对**,
其中真冲突:午睡十五分钟 vs 一小时 ×3、咖啡一杯 vs 第二杯 ×4、
「三千次轮回」vs「三千万次轮回」×1(ASCII 路径完全看不见这条);
其余 9 对是「单位不可换算」的保守拒绝(半碗 vs 一个包/一杯、一次 vs 两圈)。
dedupe 产出 255 簇/505 行 → **250 簇/495 行(13.5%)**。

回归用例(去掉这道门即红,已实测):
- TestDedupeGroupRejectsConflictingChineseNumbers(复审给的两对真实语料,脱敏保留原意)
- TestDedupeGroupRejectsNonConvertibleUnits(两杯 vs 三瓶 ⇒ 保守不合并)
- TestDedupeGroupSameChineseNumberStillMerges(同值/换算后同值 ⇒ 照常合并,门没退化成「有数字就不合」)
- TestTidyQuantitiesChineseNumerals(解析口径:一小时=3600s、十五分钟=900s、半小时=1800s、
  一万两千行=12000、第二天/一起/一直 不产生数量)

验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
2026-10-25 00:41:16 +08:00
AskaEth 54e32304f2 fix(memory): dedupe_group 补取值门 + 临时层双击复核;闸补 3 类长期事实安全阀(M34 r1 复审)
r1 复审 p1-1(会毁数据):tag_set 簇的安全门只有数值与否定,**专有名词/引号取值不比**,
生产语料实测把 `用户的昵称是「叶酱」。` 并进 `主人的昵称是「喵喵七号」…` 的存活行,
而同库另有 4 行写着「喵喵七号已作废、应称叶酱」⇒ 一次 apply 丢掉当前事实。
修法(结构性,不是补丁):
- 新增取值门 tidy_dedupe_values.go:被吸收行的**引号取值**(「」『』“”‘’")与
  **拉丁 token**(≥2 字母,含 h3R3 这类混排)必须在存活行正文中一个不少;
  方向单向(被吸收 ⊆ 存活),与软合并语义同向(存活行多出的取值不会丢)。
  补齐 design/23 §5.1-3 要求的「专有名词 / 数值 / 否定词」三件套里缺的第一件。
- tag_set 簇也过相似度下限(dedupeGroupMinSimilarity 两档统一)。取值 0.10 而非更高:
  语料实测 tag_set 合法改写(`用户可能需要提醒吃早饭…` 0.047、`叶酱有工作…` 0.109)
  的余弦本来就在 0.05~0.4 —— 抬高它只会删掉合法去重,不会多挡住危险对。
  实测(503 对):0.10→保留 98%、0.20→86%、0.30→63%、0.40→40%、0.50→20%。
- 回归用例:TestDedupeGroupRejectsSameTagDifferentValue(脱敏真实语料 + 同值对照组)、
  TestDedupeGroupLatinTokenIsAlsoAValue、TestDedupeGroupNeverLosesAValue(簇级不变式)、
  TestDedupeGroupTagSetAlsoNeedsSimilarityFloor。去掉取值门 ⇒ 前三条红;把 tag 相似度
  归零 ⇒ 第四条红。

r1 复审 p2-1(数据安全):元信息查询失败时**该分区本轮不分簇**(旧实现降级成 nil map,
`temporary` 恒 false ⇒ 绕开「临时行绝不进簇」红线);apply 侧补 `expires_at` 复核
(候选久置/生成后变临时 ⇒ 整簇 skip,tidySkipTemporaryRow)。用例:
TestDedupeInPartitionSkipsWhenMetaUnavailable、TestTidyApplyDedupeGroupSkipsTemporaryRows。

r1 复审 p2-2(闸误杀被低报):补三类安全阀 ——「以后/从此/往后/今后」= 自此刻起永久成立、
「换成/变成/改成/搬进」= 状态已改变且新状态持续、N7 的进行中标记同样必须句首 12 字内;
「宿舍」进长期事实词表;顺手修 `<=` 的 off-by-one(改为 `<`)。

实测(生产库只读 mode=ro,active 3667):
- dedupe:281 簇/594 行 → **255 簇/505 行(13.8%)**;tag_set 240 / prefix12 15;
  取值门挡 76 对、相似度门挡 12 对;簇大小 2:155 3:48 4:26 5:8 6:7 7:4 8:2 9:2 18:1 20:2
  (两个 20 行簇与 18 行簇的取值集合均为单一「喵喵七号」⇒ 大簇=同一事实的重写)。
- 闸:改前 2 (0.05%) → 改后 **75 (2.05%)**(n6 53 / n7 20 / n1 2);含锚点词的 27 条
  逐条人读均为「约定明天中午见 / 下周留个午后」类到期即失效的安排。
- 达标代价:+目前/现在 当锚点 → 2.13%;再叠一次性事件动词表(曾/发来/分享…)→ 6.74%
  且新增 172 条里 56 条是长期身份/里程碑(「i love you 文件」17 条、「海报就是助手本人」
  10 条…)⇒ 仍到不了 8%,不许为凑区间放宽。

验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
2026-10-25 00:29:02 +08:00
AskaEth dfac06bbeb docs(agents): 记下 internal/webui/dist/.gitkeep 占位不可删(干净 checkout 可构建的前提)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-25 00:09:56 +08:00
AskaEth 6a3f3a329a feat(memory): dedupe_group 候选动作 + 留存闸组合口径与 N6/N7 时点规则(M34)
M27 体检(DevDocs/audit/记忆库价值体检-2026-10-24.md)两条根治项:

1) 新增 `dedupe_group` 候选动作(不放大 containNone):
   - 簇只认「同 scope+kind+完全相同 tag 集」或「同 scope+kind+前 12 字相同」
     (后者附加词项余弦 ≥0.70 与同 kind 收窄);
   - 每簇保留超集:内容最长 > merge_count 大 > created_at 早 > id;
   - 安全门与既有合并路径同源:受限来源/绝对时刻/时效状态/低置信一律不进簇,
     数值与否定与存活行不一致的成员被逐条剔出;临时层行(expires_at 非空)
     两个方向都不进簇(存活行被清扫会给被吸收行留悬空 merged_into);
   - 已被 archive/merge 候选覆盖的条目不重复出第二条待办(抑制);
   - 落库形态与 merge 同构:source_ids 多条 + target_id,应用为整簇原子的软合并,
     回滚复用 revertTidyMerge(逐条复原);
   - 批量应用按**行**计上限 200 行/批(maxTidyApplyRowsPerBatch),超限如实 truncated;
   - 不进自动档(人工/批量点);试算(DryRun)零写库给出簇数与可减少行数。

2) 留存闸改组合口径(治根)+ N6/N7:
   - 护栏②从「含习惯/约定词就一票放行」改为「锚点词 **且** 含具体约束
     (数字/禁忌/频次规律)」;无约束的锚点交给更窄的规则判;
   - N1/N3/N4/N5 这几条词项规则改为「无锚点时才开火」(`主人喜欢说晚安`
     这类偏好不再因寒暄词被判临时);
   - 新增 N6(指示性时点,句首前 12 字内)与 N7(进行中状态),安全阀:习惯词、
     硬性长期事实词、数值/约束、寒暄/情绪词一律按回去;
   - 命中即第五态 defer,`expires_at = 判定时刻 + tempTtlSec`(默认 7 天,
     已落在 $DATA_DIR/config/memory-tidy.json,零新 .env 键);**只开新增侧**,
     存量一条不补写(用例钉住)。

契约(加性,不改既有字段语义):tidy dry-run/run 回执加 dedupeGroups /
dedupeRowsReducible / dedupeTruncated;批量应用回执加 deduped / rowsAffected。

实测(生产库只读 mode=ro,active 3667):
- 闸:改前 2 条(0.05%) → 改后 89 条(2.43%):n6 58 / n7 29 / n1 2;
  89 条里 30 条含锚点词,逐条人读均为「约定明天中午见」「约定下周留个午后」
  这类到期即失效的安排(唯一可议:1 条把「中午互动的日常」一起带进临时层)。
- dedupe_group:281 簇 / 可减 594 行(16.2% of active);tag_set 265 / prefix12 16;
  簇大小 2:170 3:48 4:27 5:10 6:10 7:7 8:2 9:2 10:1。

验收:go build ./... + go vet ./... + go test ./... 全绿。
2026-10-25 00:08:46 +08:00
AskaEth 210c8b999f Merge branch 'feat/m33-testhealth-fix-test-double-races-and' into dev 2026-10-24 23:41:40 +08:00
AskaEth f56d74c363 test(channelbridge): 准入三条用例改按「这条消息续出的臂」断言(M33 r2 复审 p2)
TestCoalesceSystemNoticeDoesNotJoinBatch 仍在用位置索引 d[1]:waitTimerCalls 只保证
「至少 2 次请求」,不保证顺序 —— 加压下会拿到 m1/m2 那次 2s 的续臂(实测 -race
-count=200 有 2/200 假红,空载测不出来)。

改成与兄弟用例同一口径,但把相位做成确定的:新增 sendSettledAndDuration ——
投递后等 worker 把这条收进本批、**为它**续好臂(按请求序号认领),再返回那次续臂的
时长。仅用 latestDuration 仍有缝:worker 可能在帧/通知之后才补上一条消息的续臂,
最新那次就成了 2s。三条准入用例(action 帧 / 系统通知 / 聊天消息对照组)统一改用它。

断言口径未变:仍然断言「系统通知/action 帧不重置静默窗口(剩余 500ms)」、
「聊天消息重置成完整 2s」;waitTimerCalls 语义、请求次数均未改。

证据:-race -count=400 连跑 3 次(1200 次)全绿、DATA RACE=0;
兄弟两条 -count=200 全绿;整包 -race -count=3 全绿。
2026-10-24 23:36:41 +08:00
AskaEth 1ae9158250 test(channelbridge): 窗口夹具按截止时刻推钟 + 去掉无法证实的替身锁(M33 r1 复审)
P1-1(channelbridge 的 -race 门仍红,红在用例不确定性而不是竞态):
- coalesceTimers 改成**截止时刻**语义:每次 after(d) 记 at = 注入时钟 + d,
  推钟只把时钟推进到「当前最早的截止时刻」再触发它。旧写法按 FIFO 逐个触发并
  按请求时长累加,实测 12 条消息内攒到 5 个废弃定时器把时钟推 +10s(窗口 2s、
  绝对上限 6s)⇒ 循环头走「到点直接发车、不 takeAll」的分支,队列尾部落到下一批。
- 推钟前强制「车道排空」这条不变量(pump/advanceAndFire/waitIdle):
  时钟只表示时间过去,它一转就可能跳过 takeAll,因此先等 worker 把货收进本批。
- sendSettled:投递后按**序号**等「这次投递之后新挂的定时器」(旧的都已被 worker
  放弃、再没人监听),供「投递与推钟交替」的用例(每 500ms 一条的绝对上限用例)。
- fireLatest/advanceAndFire 带 done 谓词重试,避免「登记与触发之间续臂」导致
  推了钟却没人醒;waitIdle 一边等一边推窗口(worker 停在窗口里时永远走不到退休)。
- 用例侧只补相位与等待,不动断言:TwelveBurst 读历史前等 assistant 行落库;
  准入用例断言「最新一次续臂」(原来按下标取 d[1],隐含臂数假设);
  stale 用例两条都投完再推时钟(车道不会在两条之间退休)。

P2-1(替身锁无法用变异证实)选 A:删掉 stubChatter/stubSessions 的锁与访问器,
退回原字段访问,并写清理由 —— 21 个用例全走同步便捷路径
(b.process → processFrom → runBatch → Loop.Run),DEV HEAD 上 -race 数十次跑
(含把锁删掉的变异)DATA RACE 恒为 0;若将来从车道 worker 触碰这些字段必须加锁
并补 -race 用例。

证据:go test -race -count=3 ./internal/services/channelbridge/ 连跑 6 轮
18/18 全绿、DATA RACE=0;coalesce 子集 -count=80 全绿;整包 -race -count=20 全绿;
全树 go build/vet/test -count=1 全绿。
2026-10-24 23:18:45 +08:00
AskaEth 7d9b2132bb test(testhealth): 替身竞态加锁 + 墙钟断言硬度(M33)
四处测试健康债务,只改 *_test.go,不动生产代码、不降并发:

- channelbridge:stubChatter / stubSessions 的跨 goroutine 字段一律经 mu 访问
  (Run/GetOrCreate 在车道 worker 上写、测试 goroutine 读,两者无 happens-before;
  负向断言「等一会儿再看循环有没有被驱动」尤其如此)。读写走访问器,Messages()
  返回副本;t.Parallel 与并发度不变。
- plugins/tools/io:claimProbeTimeout 2s(真实时间预算)→ 60s,并由 claimProbeWait
  按 t.Deadline 收缩。探针只等 goroutine 被调度(无时间语义),放大余量把
  「机器快慢」从断言里去掉,死锁仍以断言失败暴露。
- backthink:TestSelfWakeGate_WindowExpiry 窗口 120ms → 6s(给 waitFor 的 4s 上界
  留 2s 余量),到期等待改为按首次受理时刻推算 window+250ms,不再盲睡 3×窗口。
  另修 TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot:worker 先 take 再进
  Think,卡 Think 并不占待跑槽 —— 改为「上一轮把 worker 占住再断言 Pending」。
- memory:墙钟实测用例显式给闸 5s 预算(measureGateBudget),不再因默认 150ms
  超时 fail-open 打炸「每轮都真扫真判」前置断言;并把 GateFailed 纳入该断言。
2026-10-24 22:24:14 +08:00
AskaEth 20cf3e0a93 Merge branch 'feat/m32-subagentbackfill-deliver-subagent-re' into dev 2026-10-24 22:11:16 +08:00
AskaEth da6a365222 tower: snapshot of uncommitted base checkout changes (worktree wt-33) 2026-10-24 22:03:13 +08:00
AskaEth 3acc81cec8 feat(gateway): 子代理结果回填父会话(系统注记)+ 记忆抽取排除(M32)
断点(M29 勘测定论):后台批次结算后 `subagent.result` 发了、hub 也把它中继成
WS 帧,但**没有任何代码把结果回填父会话** —— `chat_messages` 无行、模型下一轮
看不到,于是「她拍了个子代理出去,但没有下文」。

回填拆成互不冒充的两条面(人格=身份红线:子代理结论绝不写成她的人格发言):
- 可观测/历史面(新文件 subagent_backfill.go):独立订阅者(不塞进 hub)订阅
  `subagent.result`,写父会话一条 `role=system` 注记(内容以 `[子代理结果]` 开头
  并点名 `sub-<id>`),随后走既有 `message_persisted` 扇出 ⇒ 刷新/重连后仍在;
- 模型面:同一行经转录播种成为下一回合的**系统注记**(`model.RoleSystem`),
  不是「她说过的话」。

落库口径:角色恒为 system(不冒充 user/assistant)、PersonaID 与 TurnID 留空
(注记不属于任何角色、也不属于任何一路回合)。

记忆抽取排除(新文件 subagent_backfill_extract.go):`WithoutSubagentNotes` 装饰
`memory.Extractor`,判据是元数据(role=system + 注记标记,与 loop 的 summaryMarker
同一手法),组合根经 `loopMemoryExtractor` 套上 ⇒ 子代理结论绝不进记忆库;LLM
提取器的异步能力原样保留,压缩摘要(同样是 system)不被误剔。

可观测补齐(M29 §7):`SpawnTool.await` 发布成功补带 source 的 INFO,`b==nil` 与
「无结果」的静默 return 改为 WARN,发布失败 WARN 带上 count/session;回填订阅者
顺手读 `bus.Dropped()`,涨了就记一条 WARN(总线丢事件不再是黑洞)。

测试(均可变异打红):父会话恰一条 system 行且零伪造助手发言;注记/摘要/批次正文
有界且逐代理点名;不可用载荷与落库失败各留 WARN 且不广播;Start 真订阅总线;
结果不进记忆抽取(同步/异步/端到端接缝);组合根真装配(真 bus 发布 → 父会话多
一行)。回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿。

不做:不改 `subagent_result` 帧形状、不加进 frameReachesAll、不新开路由/帧族、
不注入人设、不改 Docs(契约归一建议报塔)。
2026-10-24 22:02:11 +08:00
AskaEth bd49080bd1 tower: snapshot of uncommitted base checkout changes (worktree wt-32) 2026-10-24 21:51:07 +08:00
AskaEth 0fe88a44c0 Merge branch 'feat/m23-coalesce-silent-window-batching-and' into dev 2026-10-24 21:32:29 +08:00
AskaEth 4c9cabce1b fix(channelbridge): 窗口准入落到车道头 + lane.go 按职责拆分(M23 复审 r1 P2)
P2-1(行为缺陷):窗口准入此前只在 prepareInbound 里算 `solo`,而 prepare 发生在
**窗口等待之后** —— 平台帧(action / 系统通知 / 我们自己的 message_sent 回执)照样
进窗、照样把滑动计时重置:一条回执就能把这一批的静默期一直续住(群友说了三句话,
她因为「撤回事件」迟迟不开口)。这与任务书「窗口只收 chat/image/voice/file」相反。

- `laneWindowAdmits(msg)` 成为**唯一准入判据**,只看结构化事实(ContentType +
  isSystemNoticeMessage,绝不解析正文);
- `splitWindowItems` 在窗口等待阶段把到达的条目分成「进窗口」与「不进窗口」两半;
- 非准入帧**不建窗口、不重置计时**(首条即到达即发车),窗口中途到达的只收进 solos,
  这一批发车后**原样回插队首**各自单跑(保序、不合并、不丢弃);
- prepareInbound 里的 `solo` 保留为第二道保险(适配器将来把平台事件标成 text 时,
  仍会在组批前被切出去)。

P2-2(硬约束):lane.go 682 行 → 按职责纯搬移成 5 个文件(最大 246 行):
lane.go(车道类型与注册表)/ lane_queue.go(入队与取队列)/
lane_worker.go(单消费者与回合槽)/ lane_drop.go(丢弃与停机诚实)/
lane_owner.go(主人私聊判据与车道键)。语义零改动:去注释后的声明集合
(34 个顶层声明,含函数体/字段/常量值)逐字节全等;既有用例一条未改。

顺带:把两处「IsOwner() && !IsGroupChat()」的注释改成代码真正用的判据
(ownerPrivateLane = !msg.IsGroup() && actor.IsOwner()),并记下这处 P3 分叉。

验证:全树 build/vet/test 全绿(73 包 ok);三包 -race 全绿;
新增 4 条准入用例在「准入失效」变异下三条必红、对照组保持绿。
2026-10-24 21:28:26 +08:00
AskaEth ca8577f05b feat(channelbridge): 群聊静默窗口合并与回合中续跑(M23)
主人 2026-10-23/24:「要让群聊聊天很自然,很流畅的做法」。群里「一鼓作气打三句」
此前会让她回三次;本片在**每会话车道头部**加一段静默等待,把这几句合成**一轮**回答
(批内每条仍是独立的一条消息,design/15 §2.1 不许拼成一条)。

四条不可走样:

- 🔴 主人私聊**结构性**无窗口:车道分 `newDirectLane`(无 batcher 字段、拿不到定时器
  工厂)与 `newBatchLane`,类型由 actor 元数据(IsOwner() && !IsGroupChat())决定,
  配置无权翻转;
- 🔴 窗口内零丢失:唯一容器是车道队列(takeAll 单消费者取出),合并阶段无新增丢弃点
  (CoalescedDroppedTotal 恒 0);超单批上限的**尾部回插队首**保序、锁内、发车前;
- 🔴 一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复;
- 🔴 不改「回什么」:闸门仍逐条跑(prepareInbound 逐字搬运 M22 的 processFrom),
  被静默的消息进不了批。

实现落点:
- lane.go:direct/batch 两种车道 + front(回插队首)+ takeAll + 停机诚实登记;
- coalesce.go:生效参数解析、滑动窗口 + 绝对上限等待、发车原因与计数;
- coalesce_lane.go:攒批 → 拆分 → 陈旧度/闸门 → 发车;
- coalesce_prepare.go / coalesce_batch.go:processFrom 拆成 prepareInbound + runBatch;
- runtimeconfig/channels_coalesce.go:$DATA_DIR/config/coalesce.json(零新 .env 键);
- gateway/admin_coalesce.go:GET/PUT /api/v1/admin/channels/coalesce(三态补丁 +
  writeAuth + 审计 channels.coalesce + 热生效),routes.go 两行注册(塔已批);
- 前端:渠道总览页一张卡(六个旋钮;主人私聊只给只读说明,不给开关)。

M22 既有用例仍在(lane_test.go 夹具显式关窗以钉住车道本身);stranger_test.go 的那条
按塔裁定 #4 改写:「已有回合在跑 ⇒ 只登记不回」变成排队回答(留 WARN,绝不静默)。

回归:go build/vet/test ./... 全绿;三包 -race 全绿;前端 tsc --noEmit + npm run build
+ 渲染测试 36 项通过。不 push、不重启后端、未改 .env 与 Docs/。
2026-10-24 21:11:22 +08:00
AskaEth 679c8abf1d Merge branch 'feat/m30-mcporigin-stamp-tool-origin-and-grou' into dev 2026-10-24 20:36:47 +08:00
AskaEth c608dd4afb fix(gateway): 卸载拒绝话术按接入路径分流(.env 接入的 server 不再指向 MCP 页)
复审 M30-p2-1(r1):`plugin_uninstall_scope` 的 400 一律让主人「去『MCP 服务器』页
卸载」,但 `.env` 的 MCP_SERVERS_JSON 启动期接入的 server **不经**插件管理器、
不在 plugins.json 里,MCP 页(= Manager.Statuses()/List())根本看不到它 ⇒ 那条指引
对该路径是假指引。

- 新增 `plugin.(Manager) MCPServerName`:`mcp:<server>` 注册键 → server 名的**唯一**
  解析口(前缀只在 manager.go 出现一次),网关不再自己拼前缀。
- `serverScopedUninstall` 改为 `*Server` 方法,文案经 `uninstallPointer` 分流:
  · 管理器托管(落在 plugins.json,判据 `Manager.List()`)⇒ 保留「请在『MCP 服务器』
    页启停或卸载该 server」;
  · 否则 ⇒ 明说该 server 由 .env 的 MCP_SERVERS_JSON 在启动时接入、面板不管理它,
    并给出可操作步骤(改 .env 里那一项:删掉或设 enabled=false,改完需重启生效)。
  接入路径判据来自**管理器持久清单**,不是名字前缀/transport 推断。
- `mcp:<server>` 登记条目同样分流(此前也一律指向 MCP 页)。

测试(gateway):plugins.json 路径断言含「MCP 服务器」页字样且不提 MCP_SERVERS_JSON;
.env 路径(工具与登记条目各一条)断言含 MCP_SERVERS_JSON 且**不含**「MCP 服务器」
字样;三条均断言 400 + `plugin_uninstall_scope` 不变、被拒后条目仍在。
修前红:两条 .env 用例(见回报);修后绿。回归 go build/vet/test 全绿。
2026-10-24 20:32:36 +08:00
AskaEth 86aef7b454 feat(capability): 工具按上游 MCP server 分节(来源章 + 呈现隔离)
主人 2026-10-24:「能否将每个 MCP 服务器所承载的插件和工具按上游(来源)服务器分类
并与其他插件相对隔离?」

归属信息此前后端根本没有(GET /admin/plugins 每条只有 name/kind/description/两个
布尔),而 `mcp:` 是每台 server 一条的 KindMCP 条目的注册键,**不是**工具的命名空间
(工具名由 server 原样给出,连前缀都没有)⇒ 只能靠注册时的元数据,不能解析名字。

后端(唯一汇点盖章):
- 新增 pkg/plugins/sdk/origin.go:Origin{Kind,Server,Transport} + 可选接口
  OriginAware(没实现 = 宿主内置,老工具零改动)+ OriginOf(零值视为无来源)。
- registry.Capability 加 Origin 字段;RegisterTool 盖章(覆盖热装与 .env
  MCP_SERVERS_JSON 两条接入路径的合流处),ReplaceTool **同样重新盖章**(它逐字段
  重建条目,漏了就是「热替换把来源抹掉」)。
- mcp.mcpTool 实现 Origin()(server 名与传输都在 cfg 上),transport 用新的
  transportNameOf 报**实际会用的**形态(stdio/sse/http),与 newTransport 同一推断。
- 网关 PluginSummary 加可选 origin(omitempty):零值 ⇒ 键缺席,不是空对象。
- 卸载守卫:对单把 MCP 工具或 `mcp:<server>` 登记条目直接 400
  (plugin_uninstall_scope)并指向「MCP 服务器」页 —— 原来会绕过插件管理器裸删,
  server 的 names 账本不同步、重连后工具复活。判据是来源章,不是名字前缀。

前端(呈现分组):
- admin/modules/pluginOrigin.ts:纯函数按 origin.server 分节(按 server 名排序),
  transport 未报告时标题省略括号,缺 server 名不冒充内置。
- Plugins.tsx:工具段两级(`来自 fs(stdio)· N 把工具` + 宿主内置),MCP 工具行去掉
  「卸载」改为指向 MCP 页;`kind:"mcp"` 登记条目与未知 kind(iot)各有段如实显示,
  不再静默丢弃(design/09 §5.2.1 红线③)。

零影响(有断言):tool_policies 表/策略写路径未改;ToolSchemas 不发来源(测试断言
模型面既无 origin 键也无 server 名);停用 server ⇒ 条目连来源一起消失,无孤儿。

测试:registry 盖章/保章(两条均已实测变异变红)/内置零值/改策略不清章;mcp 注册
路径盖章 + transport 推断表;网关读面缺席与存在、热装整链、卸载守卫;前端 esbuild+cjs
逻辑测试 12 项。验收:go build/vet/test 全绿,frontend `npm run build` 通过。
2026-10-24 20:21:22 +08:00
AskaEth fd8a62edf5 Merge branch 'feat/lanequeue-decouple-inbound-turns-and-add' into dev 2026-10-24 20:17:17 +08:00
AskaEth e3e8878ead fix(channelbridge): 车道键与会话键同源 + 主人私聊溢出区保序(M22 复审)
复审 r1 的 P1-1 被证实:车道键(a.Name()+ConversationKey,含频道与账号维度)与会话键
(platform \x00 channelID)是两套没有共同来源的判据,于是 kook 同一 guild 的两个 text
channel、以及同平台两个账号对同一对端,都会得到「一个 session、两条车道、两个回合并发」
—— 两条 run 共享 sessionID,checkpoint 行 id 互相覆盖 / flow 桶被先收尾者删掉。

- P1-1 车道键与会话键同源:`internal/channel/session` 导出 ChannelKey / Key(channelID
  与原缓存键派生**原样**导出,manager 内部改为走同一个 sessionKey,语义一字未改);
  `laneKey` 现在逐字是 `session.Key(msg)`(主人私聊仍是 `main|web` 这个目的地键),
  **去掉 a.Name() 维度**。同平台多账号对同一对端因此共用一条车道(它们是同一个 session),
  出站路由不受影响:回复取每条消息自己的 platform/ChannelID 与它自己的适配器。
- P2-1 溢出区保序:`spill` 非空后新条目一律追加 spill、不再回写 `ch`,修掉复审实测的
  `[m1 m2 m3 m5 m4]` 超车(现为到达顺序)。
- P2-2 口径修正:lane_test 文件头改指 `TestLaneRejectsNewestWhenFull`(语义是「门前拒收
  最新、队列不动」);laneKey 注释改写「多账号必须共用一条车道」并写明取舍。
- P2-3 生产可见性:丢弃 WARN 带上 `dropped_stale_total` / `dropped_overflow_total` 累计
  计数(不新开端点);主人私聊积压改成按倍率 16/32/64… 告警,不再只响一次。

断言:TestLaneKeyNeverCollidesAcrossSessions(表驱动,含 kook c1/c2@同 guild、
telegram conf0/conf1@同对端两行 + 不同群仍并行的控制行)、
TestLaneOwnerPrivateSpillKeepsArrivalOrder(逐条放行,钉住到达顺序)、
internal/channel/session/keys_test.go(键派生与会话缓存键形状)。
2026-10-24 20:08:28 +08:00
AskaEth 7ea2850f18 Merge branch 'feat/m24-flowgate-move-review-off-admission-p' into dev 2026-10-24 20:00:57 +08:00
AskaEth 187eb327d6 Merge branch 'feat/mcppanel-mcp-server-management-page-and' into dev 2026-10-24 19:58:37 +08:00
AskaEth cfb8700b61 Merge branch 'feat/m26-webuidist-make-go-build-work-in-clea' into dev 2026-10-24 19:54:10 +08:00
AskaEth 2142de92f2 fix(plugin): 拒收 env/args 的非字符串值 + 保留键收成内置 id 集合 + 启用落盘失败文案
r1 复审 P2-1 + 两条顺手件。

P2-1(必修):`actionStringMap` / `actionStringList` 之前对非字符串值 `continue`,于是
前端 kv 控件里最自然的写法 `{"PORT":8080}` 会被**静默丢掉**、安装仍报 `installed:true`。
现在非字符串值、空键、非数组一律 400 invalid_config,文案只点名**键与类型**(不回显值);
盘上零变更、注册表零工具、快照零行(测试同时断言)。

顺手件 1:`isReservedPanelKey` 从「只挡 mcp」改成内置提供者闭集(mcp / web-search /
browser,写成 switch,新增内置页时漏补一眼可见)。之前一台名为 web-search 的 server 被
卸载时,`forgetPanels → MarkUninstalled` 会把内置「搜索提供商」页在重启前整页摘掉。

顺手件 2:启用「已连上但启用态未落盘」的文案改成事实(原来那句「它仍处于停用状态」与
事实相反)。为此新增 `ErrPluginStatePersist` 哨兵,把「磁盘没记住」与「连不上」分开;
`state=connected` 的派生口径不动。停用失败文案同时补上「server 仍在运行」。

测试:三条新用例,均已验「修前红 → 修后绿」(见 review-request 的取证)。
2026-10-24 19:53:38 +08:00
AskaEth a25e0c8870 test(channelbridge): 锁住审查解析的接线(BOM 韧性必须在生产侧生效)
- 新增 TestParseKeepIndices_ToleratesBOMWiring:喂对端**原样字节**(\xef\xbb\xbf + JSON 数组)
  给生产路径的 parseKeepIndices,必须解析成功且升序;同时断言夹具真的以 BOM 开头、且裸
  json.Unmarshal 在它上面仍然失败 —— 把委派改回裸 json.Unmarshal 即红。
- 新增 TestParseKeepIndices_StillFiltersAfterDelegation:委派之后过滤口径不变(越界/负数/
  重复编号丢弃、结果升序)。
- 新增 TestNewLLMReviewer_ToleratesBOMOutput:走整条生产路径(NewLLMReviewer 产出的
  Reviewer),回带 BOM 时给出保留编号而不是退回 FIFO。
- 注释里写明 M24 已确认的语义代价:审查移出准入路径后 review_pruned 不再从门口丢消息
  (41–74 条/日 会进流水线),而 rate_limited 门口丢与 FIFO 上限修剪一字不改(由既有用例钉住)。

只加用例与必要注释(含一个为断言所需的 encoding/json import),未改动既有用例的任何断言。
2026-10-24 19:49:58 +08:00
AskaEth 960d7b46fd Merge branch 'feat/m25-proactiveweb-route-web-proactive-pus' into dev 2026-10-24 19:47:48 +08:00
AskaEth e91b868a5c feat(channelbridge): 入站投递与回水解耦 + 每会话回合车道(M22)
报障:「群聊消息速率限制似乎会让消息一直排队,导致后面才回复前面的话」。
病灶:`onMessage` 在适配器的读循环里**同步**抢全局 16 槽回合信号量,槽满即停止
读 socket(心跳不回、后续帧不解析),跑完一轮再按条重放。

- 解耦:`onMessage` 过完流控闸后只调用 `enqueueInbound` 就返回;全局回合槽
  (maxConcurrent=16)搬到车道 worker 里取,读循环不再等任何一个回合。
- 每会话一条车道(lane.go):键 = `<账号>|<platform>|<channel>|<会话桶>`
  (主人私聊恒为 `main|web`,因为它们都路由进同一个主会话);同会话串行 FIFO、
  不同会话并行;空闲车道退休,表大小只跟活跃会话数相关。
- 陈旧度闸:轮到时已等超 `LaneStaleAfter`(默认 30s,Options 可覆盖)⇒ 丢弃不补答,
  留 WARN + LaneStats 计数 + onRejected 审计。主人私聊与非聊天事件豁免
  (主人 2026-10-24 裁定:宁可晚回,不许不回)。
- 背压有界且诚实:群聊/陌生人满时在门前拒收这一条(队列内容不动,M23 的 takeAll
  依赖它);主人私聊永不拒收,溢到 spill 只排队,越过 ownerLaneBacklogWarn 记 WARN。
- 四条不变量(顺序/一致性/完整性/最新性)在 lane.go 头部写清,lane_test.go 逐条断言;
  TestLaneDeliveryNeverBlocksTheReadLoop 用「槽全满 + 心跳帧」复现旧病灶。

不改「回什么」的语义:flowgate 限流判据、engage/冷避/owner 闸、陌生人 AcquireTurn
全部原样保留;不做静默窗口合并与回合中续跑(M23)。
2026-10-24 19:46:20 +08:00
AskaEth ec6c98d26a fix(web): keep the webui dist placeholder across frontend builds
copy-dist.mjs rm -rf'd internal/webui/dist before copying the SPA in, so
every npm run build deleted the committed .gitkeep and a fresh checkout
went back to failing go:embed. Restore the placeholder after the copy, and
also on a failed copy (an empty dist/ still compiles; a missing dir does
not), so the wipe only ever clears build output.
2026-10-24 19:44:34 +08:00
AskaEth 76d972f0f2 fix(channel/flowgate): 审查移出准入路径、节流覆盖失败路径、BOM 解析韧性
- Admit 不再同步调用审查模型:触发时只把审查**派发**到后台 goroutine,准入只读
  当前已有结论。三重上界:同一适配器至多一次在飞(reviewInFlight)、同一会话每窗口
  至多一次(lastReview)、整个适配器每窗口至多 defaultReviewPerWindow=6 次(reviewHits,
  写成可测断言)。
- st.lastReview 改为在**派发时**置位 ⇒ 成功与失败两条路径同样被节流。修复前它只在
  err == nil 分支置位,而实测 68% 的审查因对端回带 BOM 解析失败 ⇒ 缓冲满后每条入站帧
  都付一次 ~6s 的模型调用(qq-ws/kook-ws/weixin-poll 三路单读 goroutine 全被钉住)。
- 新增 adapter.ParseReviewIndices:容忍 UTF-8 BOM、首尾空白/换行、Markdown 代码围栏、
  数组外解说文字;越界/重复编号照旧丢弃。channelbridge.parseKeepIndices 改为委派它
  (仅函数体 + 注释,未动限流/丢弃/审查触发语义)。
- 语义差异(已写进 flowgate_review.go 文件头):rate_limited 的门口丢与 FIFO 上限
  一字不改;审查修剪降级为后台积压去噪,只修剪它当时看到的那批历史条目 ——
  review_pruned 不再从门口丢消息(实测 41–74 条/日),完整性↑、噪声↑,是否开口仍由
  engage_judge/冷避/owner 闸判(三处判据未动)。
- cmd/cyrene:逐账号启动日志如实报出生效的入口流控(塔裁决 A:零行为改动、不翻默认),
  让「没配流控键的账号闸门仍开着」一眼可见。
- 测试:4s 慢审查替身证明入站帧不被阻塞(变异回同步 ⇒ 红)、节流在失败路径生效
  (摘掉派发时置位 ⇒ 红)、每适配器窗口预算上界(摘掉预算 ⇒ 红)、同适配器单飞
  (摘掉 in-flight ⇒ 红)、在飞期间到达的消息不被连带修剪、带 BOM 字节的夹具
  (夹具去掉 BOM ⇒ 红)。闸门用例 -race 通过。
2026-10-24 19:43:57 +08:00
AskaEth a1d162febf fix(webui): commit dist placeholder so go:embed compiles in clean checkouts
internal/webui/webui.go uses //go:embed all:dist, which fails to compile
when the directory holds zero files. dist/ is gitignored (it is npm build
output), so every fresh clone or worktree could not run go build ./...
until the frontend had been built once.

Track an empty internal/webui/dist/.gitkeep so the embed always has one
matching file. The all: prefix is what makes a dotfile count. Real build
output (index.html, assets/**) stays ignored and is never committed.

The placeholder is forced into the index because .gitignore's dist/ rule
excludes the parent directory with no way to re-include a child.
2026-10-24 19:42:58 +08:00
AskaEth 1d3965aa80 fix(gateway): Web 活跃客户端主动推送走 turnreply 规整(先规整再落库,推帧与落库逐字相同)
M19 复审抓到的真缺口:internal/services/gateway/proactive.go 的
SendProactive -> persistProactiveToMain 这条 Web 活跃客户端主动推送路由
完全绕过 turnreply.NormalizeParagraphs —— 同一条主动消息走 IM 是规整过的、
走 Web 实时推还是原样(半包 <p> 在客户端按「存在完整 <p> 就不再按空行回退」
的契约下整条塌成一坨)。

- 接线:SendProactive 在落库之前先 msg.Content = turnreply.NormalizeParagraphs(msg.Content),
  落库与 proactive_message 帧取同一个字符串 ⇒ 两端逐字相同;不在 gateway 另写实现。
- 新增 proactive_normalize_test.go:修前红(web 原样 / im 已规整),修后断言
  ① 落库是规整后文本 ② 推帧 == 落库行 ③ 两条路由同源同文本 ④ <br>/<action> 语义不动。
- 既有两条断言按新口径更新(纯散文现在也被包成一个 <p>,与其它三处一致)。
2026-10-24 19:40:30 +08:00
AskaEth 2421a8dac9 feat(plugin): 组合根装配「MCP 服务器」页(声明 + 动作处理器成对)
- wirePanelHub 在 AttachPanels / declareBuiltinPanels 之后调 AttachMCPPanel:
  一处完成「声明页面」+「登记 7 个动作处理器」,缺任一步就是面板在、动作全 501。
- wire_panels_test 的两条「内置面板恰好 1 条」断言改成按 pluginID 定位
  (web-search/providers),并加钉区块形状(form+table;viewer 只剩 table)。
  接线落地后目录里确实是两条(mcp/10 + web-search/20),旧断言是事实过期而非回归。
2026-10-24 19:38:38 +08:00
AskaEth 4518385f44 Merge branch 'feat/literalnewline-render-literal-backslash' into dev 2026-10-24 19:38:27 +08:00
AskaEth 6b0af81136 fix(turnreply): 围栏闭合串不得短于开启串(复审 p2 收口)
复审 review-m19-r1 的 p2:`codeProtectedEnd` 的围栏分支只判「下一个 ≥3 反引号
的串」,没判「闭合不短于开启」。CommonMark 要求闭合串**至少与开启串等长**,
因此 ```` 开启的围栏不能被内层 ``` 关闭。复现形态(````markdown 里嵌 ```go,
示例里写 `print("a\nb")`):旧实现把内层 ``` 当闭合 ⇒ 之后的示例正文被当普通
正文渲染,代码里的 `\n` 变成真换行;而这一步跑在**落库之前**,改坏的代码会永久进
历史 —— 反向踩了本文件「宁可少渲染也不改坏代码」的自己的红线。

- literal_breaks.go:围栏分支改为 findBacktickRun(text, body, n, unboundedRun)
  (closing ≥ opening);注释写明 CommonMark 口径与「改了会落库」的后果。
- 新增 TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun:嵌套围栏逐字保留、
  围栏外仍照常渲染(保护不得溢出)。摘掉这一行 ⇒ 该用例红,还原即绿。
- 新增 TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak:钉住 `\n\n` ⇒ 空行 ⇒
  两段 <p> ⇒ 两端各 2 条气泡(复审观察②,语义对但此前无用例覆盖)。
- 措辞收口:样本 455 字符 / 907 字节;IM 侧主动推送的注释补明「Web 活跃客户端那条
  主动推送不经 NormalizeParagraphs,另计」。

行内代码那条等长分支未动。全树 go build ./... + go vet ./... + go test -count=1 ./...
全绿(73 包,RC 0)。
2026-10-24 19:33:33 +08:00
AskaEth 9654ce8861 feat(plugin): MCP 服务器管理页 + 按 server 独立启停与状态快照
design/09 §5.2.1 / §5.6.1:补上「看不见连没连上 / 暴露了哪些工具 / 为什么挂了」的洞。

- runtimeconfig:PluginConfig 新增 `enabled`(*bool,缺省=启用 ⇒ 既有 plugins.json
  向后兼容);新增 RedactPluginSecrets(整把凭据 + 任意 ≥6 字符片段一并抹除)。
- mcp:新增 status.go 的只读状态读面(ToolSummaries:工具名 + 截断描述,取自能力
  注册表本身,因此与模型可见的工具集合同源)。
- plugin 管理器:
  · Install/Restore 尊重 enabled(停用=落盘不连接,重启后仍是停用,且一次都不连);
  · SetEnabled 落盘 enabled(停用先落盘再下线;启用失败时 enabled 保持 false);
  · 新增 Reconnect(对已停用者拒绝,不替用户启用);
  · 新增 Statuses(三态:connected 缺席=未知,工具数/明细/时间只在知道时给出,
    lastError 在记录那一刻即脱敏截断);
  · 内置「MCP 服务器」页声明 + 7 个已声明动作(list/install/reconnect/enable/
    disable/uninstall/transports),走既有通用动作端点(owner-admin + 审计);
  · 护栏:热装插件的生命周期不改动内置提供者(mcp)的目录条目。
- gateway:已安装列表与 install 响应加 enabled,install 支持 config.enabled。
2026-10-24 19:31:31 +08:00
AskaEth fa573663c1 fix(turnreply): 字面 \n 渲染成真换行(Web 镜像与 IM 出站同一份)
主人 2026-10-24 报障:QQ 群最新一条 assistant 消息里 6 处字面 `\n`
(反斜杠 + n,不是真换行)原样发出去,用户看到「F\n多利亚」。

落点选在**写入侧共享层** `turnreply.NormalizeParagraphs`(gateway 的
persistTurnReply 与 channelbridge 的 turnVisibleReply 都调它):
IM 的 deliverReply 拿同一个字符串既落库(= Web 主会话镜像)又发气泡,
Web chat 气泡是 whitespace-pre-wrap ⇒ 在共享层渲染,落库/WS/REST/IM
出站与历史一次性对齐;只在 channelbridge 清洗层改会留下「面板上仍是字面 \n」。

- 新增 internal/runtime/turnreply/literal_breaks.go 的 RenderLiteralLineBreaks:
  逐字节扫描,围栏(```)与行内代码(`…` / ``…``)内原样保留,`\n` 与
  `\t`/`\r`/`\uXXXX`、行尾孤立 `\` 一律不动;未闭合围栏保守保护到末尾;幂等。
- paragraph.go 在**围栏 skip 之前**调用它 ⇒ 含围栏的消息里围栏外的 `\n` 也渲染。
- 🔴 未改既有语义:只产出换行字符,绝不产出 <br>/<p> —— <br> 仍是气泡分隔符,
  字面 \n 只做气泡内换行(一条列表不会被拆成 7 条消息)。
- channelbridge 源码零改动。

验证:真机原文(6 处 \n / 5 个 <p>)修前红(摘掉钩子 3 个测试变红)→ 修后绿;
三处变异(去掉围栏保护 / 去掉 \ 配对 / 把渲染挪到围栏 skip 之后)各自变红后还原复绿。
全树 go build ./... + go vet ./... + go test -count=1 ./... 全绿(73 包)。
2026-10-24 19:21:50 +08:00
AskaEth 4ed5925296 Merge branch 'feat/memberquiet-cold-shoulder-applies-before' into dev 2026-10-15 19:14:00 +08:00
AskaEth 28967d3435 refactor(channelbridge): 冷避例外②收窄为「真故障/求救」严格子集
塔 2026-10-15 改裁定:例外给了命中词「穿透 30 分钟冷避」的权力,噪声不再只是多回
一句话,而是直接削弱冷避本身 —— 而实测全表 14 条在 598 条真实 user 消息里只命中
1 次,且那次是假阳性(QQ 机器人卡片文案里的 `bug`,而该群本来就在放这种卡片)。

- 新增 coldShoulderPierceKeywords(具名常量,注释写明为什么比全表严、依据即那条
  卡片假阳性)与 hasColdShoulderPierceKeyword:保留 报错/出错/故障/紧急/救命/求助/
  帮帮我/urgent,剔出 ASCII `bug`/`error`/`help`(会命中结构化载荷/URL/卡片)与
  情绪歧义的 `崩溃`/`坏了`/`坏掉`。
- 例外② 改用该子集;hasHighValueKeyword(基线,ruleLightJudge 用)**一字未改**,
  两张表的分工写进注释,并有断言钉住「基线不许被这次收窄波及」。
- 例外矩阵新增三行:机器人卡片含 `bug` ⇒ 不穿透;英文 error/help ⇒ 不穿透;
  情绪「我崩溃了」⇒ 不穿透(三行对上一提交均为红)。第 1/2 行继续用保留词 报错,
  第 3/4/5/6 行原样保留;另加子集单元护栏(正例六条 / 反例六条)。
- 按项目「文件 ≤500 行」把冷避规则簇拆到 engage_coldshoulder.go(engage.go 528→376)。

红前绿证据:把例外判据临时还原成上一提交的 hasHighValueKeyword 全表(与该提交逐字
等价)⇒ 恰好上述三行变红、其余行与护栏保持绿;已还原(无残留)。
已知代价如实登记在案:用英文写的真求救不再穿透 —— 塔裁定以「宁可少穿透」为准;
全表(highValueKeywords)本身的收窄由塔另排切片,不在本片。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿(73 包);
channelbridge / channel / cmd/cyrene 单独跑绿;darwin/arm64 交叉编译通过。
2026-10-15 19:04:48 +08:00
AskaEth 13a0519115 feat(channelbridge): 冷避期唯一的放行口——高价值关键词穿透(不解除冷却)
塔 2026-10-15 例外口径定稿落地。M18 主体(冷避优先于「@她必回」)保持不动,
本提交只补冷却期内的例外与它的守卫。

- engage.go:`coldShoulderLocked` 在冷却期内放行**高价值关键词**这一条
  (真故障不该被「让她少闲聊」一并压掉),且**不解除冷却**(不动 state / cooldownUntil);
  同句既有负面态度又有高价值关键词时**负面态度优先**(仍进冷却、仍静默);
  例外与顺序写进函数注释,并写明 owner 车道为什么不需要单独写例外(它在
  shouldRespond 第一步就分岔,写在这里是死代码)。文档面 design/18 §5/§9 由塔同步。
- light.go:shouldRespond 的判定顺序与冷避闸注释同步该口径。
- 测试:新增 engage_coldshoulder_exception_test.go(冷却期例外矩阵:真故障放行、
  @她/闲聊不穿透、穿透不解除冷却、同句负面态度优先;以及「冷却之外行为与基线一致」
  的护栏),并按文件 ≤500 行拆出该文件;engage_coldshoulder_test.go 增共享夹具。

红前绿证据(参照 = 本提交的父 M18 提交):例外矩阵两条「真故障放行」行在我的改动
之前为红(冷避闸会把它们静默),其余三行与基线护栏在父提交上已绿,已如实标注为护栏;
「同句负面态度优先」一条另做变异自证(把例外插到负面态度判定之前 ⇒ 该行立刻变红)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
channelbridge / channel / cmd/cyrene 单独跑绿;darwin/arm64 交叉编译通过。
2026-10-15 19:01:02 +08:00
AskaEth fe53e029ea fix(channelbridge): 冷避优先于「@她必回」——群成员也能让她少说话
M18(主人 2026-10-14 裁决「给」)。病灶是**顺序**:`shouldRespond` 把
`ruleLightJudge`(指向她本人 / 高价值关键词)排在参与度引擎之前,@她的消息
在规则层就 return true,永远走不到 `engageDecision` 的冷避判定——群成员说
「少说点」只换来一句口头答应,行为零变化(2026-10-14 00:27 群 531082168 报障)。

- light.go:新增冷避闸位次 —— 非主人**群**消息在廉价规则之前先过
  `coldShoulderGate`,命中负面态度即进 COOLDOWN、冷却期内一律静默(仍登记)。
  边界:私聊不受影响(冷避是群机制)、owner 车道更早分岔不受影响、
  与是否装配参与度引擎无关。shouldRespond 的判定顺序写进函数注释。
- engage.go:把冷避判据抽成唯一实现 `coldShoulderLocked`(engageDecision 仍调用
  它,作纵深防御),命中时补一条带 source 的 INFO(群 / 发送者 / 冷却到何时)。
- engage.go 词表:依真机原话补「减少文本输出」(覆盖「减少文本输出量」)与
  「文本输出调到最少」;刻意不取「减少 / 输出 / 最少」等短泛词(子串匹配会误伤)。
- 测试:新增 engage_coldshoulder_test.go(主断言修前红/修后绿、登记进上下文的
  端到端守卫、私聊与 owner 边界护栏、词表误伤护栏、INFO 可见性);
  engage_test.go 更新旧口径「@ 打断冷却」为「@ 不得作废冷避」。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿(windows 本机
与 darwin/arm64 交叉编译均通过);channelbridge / channel / cmd/cyrene 单独跑亦绿。
2026-10-15 18:55:35 +08:00
AskaEth 6432088586 Merge branch 'feat/addrmember-member-lane-mention-judgment' into dev 2026-10-14 07:26:34 +08:00
AskaEth 5969e700e1 fix(channelbridge): 成员车道「指向她」判据改为 AddressedToMe(@ 别人不再必回)
ruleLightJudge 原判据是 len(msg.Mentions) > 0 —— 只要有任意 @ 就必回,
@ 的是谁不论。2026-10-14 真机病灶:群里 @3889001246、@867587435 两条
都不是 @ 她,她仍逐条回。

改为复用 owner 车道已验证的同一份判据 ownerAddressedToMe
(msg.AddressedToMe || (msg.SelfID == "" && len(msg.Mentions) > 0)):
- 已接线渠道(qq-obv11 填了 SelfID/MentionAll/AddressedToMe):只有 @ 她本人 /
  @全体 才必回,@ 别人降级为无结论 → 参与度触发/概率那一档(未触发只登记);
- 未接线渠道(kook/webhook/telegram/weixin_ilink 未填 SelfID):逐字不变,
  仍按旧判据必回(「没有信号」≠「没在对我说话」);
- 判据全部来自结构化字段,绝不匹配正文里的 @<数字>。

口径依据 design/18 §2 触发① 的 2026-10-13 修订(消费点在 M13 只接了 owner 车道)。
2026-10-14 07:21:07 +08:00
AskaEth 0c2322bb92 docs(api): 契约 v0.93 — 审查预算的唯一真源(MEMORY_REVIEW_TIMEOUT_SEC 默认 20→90)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-13 23:28:39 +08:00
AskaEth 9f1f895059 Merge branch 'feat/memtimeout-single-source-of-truth-for-re' into dev 2026-10-13 23:28:34 +08:00
AskaEth 18ad2b89ab test(memory): 两条审查超时用例搬到正确位置(复审 p2-1,纯搬运)
复审指出 TestEmbeddingTimeoutFitsReviewBudget 的整段文档被夹在「它描述的
函数」与「两条新用例」之间成了孤儿文档,而该函数自己的文档只剩一句、且在
文件里出现两次(一次属于那段孤儿注释,一次紧贴真正的函数)。

只搬不改语义:把 TestReviewTimeoutExplicitEnvValueWins 与
TestReviewTimeoutEffectiveValueIsTheOnlyDefault 整体挪到
TestEmbeddingTimeoutFitsReviewBudget 之后,删掉重复的那句文案与多余的空行,
让那 21 行文档重新紧贴它描述的函数(与 dev 版的相对位置逐字相同)。

纯搬运的证据:拿「排序后的行集合」比对搬运前后(前者取自 HEAD)——
`diff <(git show HEAD:internal/runtime/memory/preinsert_recall_test.go | sort) <(sort internal/runtime/memory/preinsert_recall_test.go)`
只差两行:一句重复文案 + 一个空行,其余逐行一致。

顺带按「主张与落点」把本文件自查了一遍:每段注释都紧贴它描述的符号
(实测文档与函数之间没有空行:`awk '/^\/\// {doc=NR} /^func / {if (NR-doc!=1) print NR}'`),
且文中的位置指代(「那条防线在下面的 …」「下方 cfg.TimeoutSec != 0 那条」)
在搬运后依然成立。
2026-10-13 23:21:25 +08:00
AskaEth 42a07cb454 fix(config): 单条入库审查的墙钟上限收敛到唯一真源(M16)
生产启动行仍是 timeout=20s —— M15 把 memory 包的 defaultReviewTimeout
抬到 90s 的修复**从未生效**:config 层的 env tag 自己带着 default:"20",
env 层永远产出非零值,于是 memory 侧「≤0 才回落默认」的归一化成了死代码。

修法(二选一中的「① 让 memory 侧成为唯一真源」):去掉 config 侧
MEMORY_REVIEW_TIMEOUT_SEC 的字面默认值,未配置时读出 0 = 「没配」,
由 normalizeReviewConfig 单点落地 90s。

生效值:
  .env / env 没写这个键 ⇒ 90s(唯一真源)
  写了正数            ⇒ 原样生效(不做任何「保底抬到 90」的加码)
  写了 0 / 负数       ⇒ 90s(与项目其它旋钮同一口径:≤0 = 用默认)

新增测试(走真实组装路径,不绕过任何一层):
  config.LoadMemoryReviewConfig → ReviewConfig.Timeout →
  NewPreInsertReviewer → normalizeReviewConfig → 生效值(= 启动行打印的 timeout=)
断言生效值严格大于「实测最坏召回链 57s + T1 子超时 8s」,并断言启动行打印它。
变异自证(修前红):把 tag 的 default:"20" 加回去 ⇒ 报出
  「组装后 timeout = 20s」+「生效值 20s != 唯一真源 1m30s」

原有 TestEmbeddingTimeoutFitsReviewBudget 拦不住这条,因为它比的是**本包常量**,
看不见 env 层;注释里已写明「比常量」与「比组装后生效值」的区别(防后人重蹈)。
2026-10-13 23:09:19 +08:00
AskaEth 202cd8535e Merge branch 'feat/embudget-fix-embedding-request-amplifica' into dev 2026-10-13 22:59:42 +08:00
AskaEth 9ff0421888 Merge branch 'feat/mempanel-memory-tidy-tuning-panel-write' into dev 2026-10-13 22:59:38 +08:00
AskaEth f6c47b0ae1 fix(memory): 清掉两处与实现相反的旧说法 + 并发哨兵改成窗口重叠(变异 8/8 红)
review P2 两条。

① 旧说法没删净(同类第 N 次:改了 A,同一主张还留在 B / C)。这次按「**这句主张的所有
落点**」重扫了一遍我这一片里含 Stop / Start / 重启 / 心跳已停 的注释,改掉两处与实现
正面打架的:
- applyTidyConfig 的注释还写着「调用方(ApplyPanelTuning)在 Stop 期间不能持 runMu」
  「前提仍是心跳已停」—— 而同文件的文件头刚写过「调优路径上一个 Start/Stop 都没有」,
  同一文件里两段话自相矛盾;
- 整理档那条并发哨兵的循环注释还写着「Stop 把 running 置为 false,忘了重启就会静默
  停摆」,而该循环里根本没有 Stop/Start(改成「唤醒不是停表」,并把 Stop/Start 那版
  明确标成旧版)。
顺带把文件头「所有读点都收进访问器」的说法改准:此外还有一处**同一把锁下**的相等比较
(ApplyPanelTuning 里的 s.cfg == next),它本身不需要访问器。

② 新哨兵是概率性守卫:固定 40 轮时撤回 DryRun 同步只红 4/8(写方那几十次覆写在微秒级
就跑完,读方还在慢端点里打转)⇒ 文档规定的 -count=1 会漏掉约一半回归。改成**两侧各自
跑满同一个 300ms 墙钟窗口**(写窗口与读窗口必然重叠,不靠快慢概率),读方两个活端点各
起两路;写方每轮换一个值(否则 ApplyPanelTuning 会被「配置没变」的早退挡掉,根本不写)。
实测:撤回 DryRun 同步红 **8/8**、撤回 ManualRun 同步红 **8/8**(各 8 次 -race -count=1);
干净 tip `-race -count=5` 全绿、三条哨兵一起 `-count=3` 全绿;该哨兵耗时 0.35s(原 0.18s)。

顺带把 clipJSON 用例注释里那句改准:40 字节处被切掉的是第 13 个汉字的**第三个字节**
(前两个字节被留下),而不是先前写的「第三个字节上」。
2026-10-13 22:50:38 +08:00
AskaEth 4df8d6f53a fix(memory): 按字段扫清 s.cfg 的其余无锁读(P1)+ 回显文案按 rune 截断
review P1:applyTidyConfig 在运行期**整体覆写** s.cfg,而我只把调度读收进了持锁
访问器 —— 同一字段还有几处无锁读,其中两处是**活端点**:

- TidyService.DryRun 的开关判定(在取 runMu **之前**)⇒ POST …/tidy/dry-run 与
  ?async=1 那条会起后台 goroutine 的版本(真机 90s 级);
- TidyService.ManualRun 的开关判定(同样在 runMu 之前)⇒ POST …/tidy/run;
- (原本也漏的)Enabled() 与 Start 的启动日志块。

按**字段**重扫了一遍(不按文件):现在生产代码里对 s.cfg 的读**只剩**三个持锁访问器
内部(enabled / cfgSnapshot / scheduleSnapshot),写点只有 applyTidyConfig 一处;
s.tidier.cfg 的读点全在持 runMu 的轮次 / 试算路径上(含异步版经 DryRun)。

新增第三条并发回归哨兵:真并发跑「试算 + 手动真跑」两个活端点 × 面板保存。
修前红/修后绿:撤回 DryRun 的同步 ⇒ 报「写 applyTidyConfig × 读 DryRun」;撤回
ManualRun 的同步 ⇒ 报「写 applyTidyConfig × 读 ManualRun」;两次变异都逐字还原
(diff 0 差异)。

顺手两条(塔指定):
- 那条并发哨兵的 doc 与实现相反(还写着「正确顺序是 Stop → 写 → Start」),已改成
  唤醒机制下的说法 —— 与「注释与实现不符」同类,不留旧说法。
- 非法值的回显文案原按**字节**截断(s[:40]),中文会被切成半截(非法 UTF-8,经
  JSON 编码变成 U+FFFD 替换符)。改成按 **rune** 截断,直接复用同包既有的
  clipRunes;用例取值刻意让截断点落在多字节字符中间(JSON 引号 + 一个 ASCII 前缀
  错开对齐),并做过变异复验:改回按字节 ⇒ 用例红(切出半个汉字)。
2026-10-13 22:33:07 +08:00
AskaEth 97c0c1b94a feat(memory): 调优重排改为唤醒通道,不再 Stop/Start(消灭与到期清扫的耦合)
按塔的裁定把「重排心跳」从生命周期操作改成唤醒通道,调优路径上一个 Start/Stop
都没有了。

- tidy_service.go:TidyService 新增 wake(cap 1 + 非阻塞发)与 wakeMu/wakeCancel;
  loop 的等待段抽成 waitForTick,三态判定(睡满 / 被调优唤醒 / 停);被唤醒只
  重算下一拍、**绝不**跑轮。调度读(nextDelay / dueNow)改走 scheduleSnapshot,
  在同一把锁下同取「cfg + lastRun」,于是运行期写 cfg 不必再把后台件停下来。
  Start / Stop / wg / running 与 Enabled 早退语义**一个字节没动**。
- tidy_service_tuning.go:ApplyPanelTuning 变成「持 runMu 写 cfg → 发一次唤醒」。
  唤醒 = 一次性标记(保证不丢:心跳不在等时标记留在缓冲里)**加上**取消当前那次
  等待(保证立刻生效,否则 24h→3h 要等原来那一觉睡完)。

两条冲突就此消失,各配一条可复现断言:
- **面板保存不再触发到期清扫、也不重置清扫 ticker**:新用例在保存前后各造一行已
  到期的记忆,保存之后那行必须**还在**、清扫审计计数必须不变;该用例自带活对照
  (启动清扫会删掉第一行),因此「行还在」不是量具失灵。若保存走 Stop/Start,清扫
  的「启动即扫一轮」会立刻把它删掉。
- **调优路径上不再存在任何 Start 调用**(结构上消灭交叉 Start/Stop):包内生产代码
  已 grep 不到任何 `.Start(` / `.Stop(` 调用,Start 与 Stop 各只剩组合根那一处调用点
  (`cmd/cyrene/main.go` / `cmd/cyrene/wire_services.go`),M3 注释里那条「Start/Stop
  各只调用一次」的不变式因此重新成立;交叉场景由带真心跳的调优用例在 `-race` 下跑过
  (不再 panic)。

热生效没退化:原来那条「23h30m → 30m」的断言按新机制重写 —— 用一个「听得懂 ctx
取消」的假等待看两觉的时长(保存前 23h30m、保存当场的第二觉 30m),并补上「唤醒不
跑轮(lastRun 不前移)」「唤醒后 running 仍为 true」。

变异自证(都跑过再还原,`diff` 逐字一致):
- 去掉 `notifyReschedule` 调用 ⇒「被唤醒后的第二觉」等不到 ⇒ 用例红;
- 只留标记、去掉「取消当前等待」那一半 ⇒ 同一条红(两半都必需)。

M3 的守卫 `expire_loop_test.go` **一行未动**:它的假 wait 只认 ctx,父 ctx 取消时
返回 false,在新机制下语义不变(无人唤醒时照样把心跳停住)。
2026-10-13 22:17:22 +08:00
AskaEth bdf3f3edaf docs(embedding,memory): 复核 p2 的三处措辞改正(纯注释,零行为改动)
review-m15 round1 判 p2-1items(fix-then-merge),三条都属「把证据能支撑什么写准」:

1. api.go 原写「最坏也只是这一片失败」与实现语义**相反** —— Embed 的实际语义是
   「任一片失败 ⇒ 整条调用失败、其余分片已算完的工作量一并丢弃」。改为与该包
   budget.go 里已经写对的那一句对齐,并点明调用方据此降级(记忆一条不丢、判定规则
   一字不改,变的是这一条审查的召回质量)。
2. 「尖峰与批次无关」是**过度断言**:原证据只支持「至少存在一个与批次无关的外部/
   争用分量」(4 条/片也实测到 7.139s);越界的**频率**是否随批次上升,1/40 vs 2/40
   的样本不足以判定 ⇒ 改标为「归因未定」,并明说批上限取 8 的依据始终是硬证据
   (16 条/片实测越界 + 真实代码路径 3 次里 2 次越界),**不依赖**任何成因推断
   (原文与同文件「16 条越界故不取更大」的理由自相矛盾)。同时补上独立复核窗口的
   对照(review-m15:24 次单请求 0 尖峰)说明尖峰强依赖窗口负载。
3. budget.go 的不变式不再被读成延迟保证:明确定性为「**稳态、空闲机器的建模拟合**,
   用途是构造批形状」,并点名真正的兜底(90s 审查预算按整条链算 + 任一片失败即降级
   的路径 + 已登记的链路韧性遗留);结论句由「8 条/片不越界」改为「**空闲实测下**不
   越界、负载下仍偶发越界」;perItemBudget 与 callBudgetShare 注释里「另一半/余量」
   的同口径措辞一并改正(加一句:这是建模余量,不是单请求一定不越界的保证)。

另:preinsert_config.go 里「设计稿那一行待同步」已过期 —— 塔同日已把 design/23 §15
同步为 Timeout=90s,改为「已按实测同步」。

验证:go build ./... / go vet ./... 干净;go test -count=1 ./... 全绿;
go test -race -count=1 ./internal/runtime/embedding/... ./internal/runtime/memory/...
全绿;diff 中无非注释行(纯文档改动)。
2026-10-13 22:12:11 +08:00
AskaEth a87fbeb494 fix(memory): 面板调优引入的两处运行期写入面收口(-race 修前红 → 修后绿)
一、审查档(preinsert.go / preinsert_gate.go):面板调优会在**运行期**改 cfg 的
两个字段(DisableIngestGate / TempTTL),而读这两个字段的两处不持锁
(gateEnabled 在同步入库闸上、deferDeadline 在第五态 defer 上)。
- 读侧改走新的 tuningSnapshot()(r.mu 由 Mutex 换成 RWMutex,读锁下取整份快照);
- **只**收这两个字段的读点:其余读点读的是构造期冻结的字段,不与写方交手,
  故刻意不给入库热路径加无谓的锁;注释写明「将来面板再接管别的字段,那些读点
  必须与它一起收」;
- 锁序:写方持锁期间只做字段赋值、不取任何别的锁(normalize/回落都在取锁之前
  跑完)⇒ 不引入新锁序。

二、整理档(tidy_service_tuning.go):修掉我自己的那条真竞争 —— 原顺序是
「Stop → Start → 写 cfg」,而新起的循环**立刻**读 s.cfg 重算下一拍,与写入并排
(那一拍还可能按旧配置算出来)。改成「Stop → 写 → Start」。
顺带去掉 Stop 的超时分支:超时意味着 wg 的 Wait 仍在飞,紧接着 Start 的 Add 会
踩上 M3 在 tidy_service.go 明文写下的 WaitGroup 复用纪律(-race 下 panic);`Stop`
期间也不持 runMu,避免与「心跳正要开跑一轮」互相等死。

证据(全部逐条跑过再还原,还原用 diff 逐字校验为 0 差异):
- 审查档:撤回读侧修复 ⇒ 2 处 DATA RACE(写 preinsert_tuning.go 的两个字段 × 读
  gateEnabled / deferDeadline);恢复 ⇒ 绿。
- 整理档:把顺序改回「先起后写」⇒ DATA RACE(写 applyTidyConfig × 读 nextDelay);
  恢复 ⇒ 绿。
- 新增两条只在 -race 下有意义的回归哨兵(tidy_service_tuning_test.go):审查档真
  并发读写;整理档用**真心跳**当读者(不伪造生产不存在的调用方,那个入口在包里
  只有心跳 goroutine 会调)。

热路径成本:新增的同步是「一次无竞争 RLock + 整份小结构体快照」,隔离实测约
4ns/次(5e6 次共 22.9ms);入库闸那条测量用例的批均值在 2.62ms → 2.71~2.81ms
之间波动,落在那条用例自身的运行间噪声里(同一份代码连跑两次,纯写入基线就从
39µs 飘到 60µs)。
2026-10-13 22:11:41 +08:00
AskaEth f3fb097630 perf(embedding,memory): 批上限 4→8 削掉请求放大,审查预算 20s→90s 覆盖实测链长
实测(2026-10-13,本机空闲,活库 cyrene/user 分区 201 条、均长 34.7 汉字):
- 一条入库审查召回链 = 51 个「4 条」请求 = 23.070 / 21.860 s;旧审查预算 20s
  比链长还短 ⇒ 每一条候选满额的审查都超时,且超时后 **一行留痕都不写**
  (7 天里 92.1% 的审查 recall_mode 为空、2274 条超时降级的根因)
- 同一条链改成「8 条」= 26 个请求 = 21.435 / 21.597 s:请求数减半,墙钟几乎不变
  —— 链路耗时由「条数 × 每条成本」主导,批次只改请求数
- 「16 条」在**真实代码路径**上 3 次里 2 次让单请求越过 5s 单次超时(1.818 /
  5.016 超时 / 5.001 超时)⇒ 批上限不越过 8;隔离测量里 12 条 0.996~1.017 s 仍在界内

改动:
- embedding: perItemBudget 500ms→300ms(实测摊销 70~107ms/条的约 3 倍)
  ⇒ DefaultBatchLimit 由 (5s/2−100ms)/300ms = **8**,同一套推演、两维都成立
  (条数 100ms+8×300ms=2.5s;字符 100ms+480×5ms=2.5s,均在超时的一半内)
- embedding: 新增 maxSafeBatchLimit=12(实测外沿)+ 编译期断言(越界即 uint 溢出、
  编译失败),与 tidy_config.go 那条同手法
- memory: defaultReviewTimeout 20s→**90s**(> 实测最坏链:空闲 21.6s⇒4.2 倍余量、
  与整理链并发 56.7s + T1 8s ⇒ 1.4 倍;不再大的理由=单 worker 有界队列 256 的
  最坏积压 256×90s≈6.4h、服务真挂死时第一片 5s 就降级用不到这条预算)

测试(不留过时断言):budget_test.go 加「钉值 8/480 + 两维不等式 + ≤12 外沿 +
16 条必须仍判越界」四层;api_url_test.go 的 reviewBudgetRef 20s→90s;
preinsert_recall_test.go 新增跨包断言「预算 > 实测最坏链 + defaultT1Timeout」。
变异自证:perItemBudget 回 500ms ⇒ 钉值断言红;reviewTimeout 回 20s ⇒ 跨包断言红;
perItemBudget 150ms(⇒16)⇒ 编译失败(constant -4 overflows uint)。三者均已逐字还原。

遗留(如实登记,未修):① 整理轮每轮重嵌整个分区(user 一轮仍 250 个请求),
解法方向是复用已入库的 vec_items 或给整理轮一个 embedding 请求预算;② 链路对单点
慢分片没有韧性 —— 一个分片失败即整条 Embed 失败、丢掉其余分片的工作量,且实测
任何批次形状都会偶发 >5s 尖峰(4 条 7.139s / 8 条 9.711s,与批次无关),方向是
重试/分片级容错,调常量解决不了。
2026-10-13 21:59:31 +08:00
AskaEth e9e7b7dcb2 test(memory): memory-tidy.json 写路径(补丁保留其它键 / 值没变不落盘 / 坏文件拒写 / 原子写)
塔批准的 scope 里点名了 internal/config/runtimeconfig/memorytidy_save_test.go,
补上这四个口径的包内单测(网关侧另有端到端用例覆盖三态与审计)。
2026-10-13 21:59:18 +08:00
AskaEth 27ee0a8149 test(memory): 拆分调优写端点测试(原文件 524 行 > AGENTS §10 的 500 行上限)
守卫用例(非法值不落盘 / 坏文件不覆盖 / 同值不重写 / 热生效两种结果)移到
admin_memory_tuning_guard_test.go,helper 与读侧用例留在 admin_memory_tuning_test.go;
口径不变,两个文件都 ≤300 行。
2026-10-13 21:59:18 +08:00
AskaEth eddf921229 feat(memory): 记忆整理调优面板读写(三态 + 热生效 + 面板表单)
主人 2026-10-07 两条口径:「.env 只做兜底用,后端自动创建独立的面板配置文件
之后就走面板配置文件,这些键优先从面板配置上取」+「频率必须调,可以调到 1 小时
或者三小时,或者动态分配(白天短、静默期长)」。

此前 $DATA_DIR/config/memory-tidy.json 只有读路径(装配期读一次 + 启动播种):
主人要改频率只能改文件 + 重启后端。本切片补上「读回来 → 改 → 落盘 → 当场生效」。

后端(读写端点,契约待塔登记 v0.92):
- GET  /api/v1/admin/memories/tidy/tuning  读盘上 7 键值 + 取值规则 specs +
  缺位时 .env 档的进程生效值(与 /admin/config-facts 同源)
- PUT  同路径:三态补丁(键缺位 = 不改动 / 合法值 = 采用 / 非法值 = 400 且盘上一
  个字节都不动)+ null 清除哨兵(删键 ⇒ 回到下一档)+ writeAuth + 审计
  memory.tidy.tuning;值一个都没变时不落盘,文件解不开时拒绝覆盖(409)
- 落盘走 runtimeconfig.PatchMemoryTidyTuning(原子写 temp+rename,与
  SeedMemoryTidy/SaveWebSearch 同族同手法);补丁语义保留下受管的其它键
  (t1Judge/t1PerWindow/autoApplyPerRound 原样保留)
- 热生效:保存后按「面板 > .env > 代码默认」重算并叠到运行期对象;整理档会
  重排心跳(否则新周期要等旧周期那一觉睡完),审查档当场改 ingestGate/tempTtlSec。
  未接线/失败时如实回 hot:false + hotError,绝不把「落盘了」说成「生效了」

前端(Miuix):
- 记忆管理页新增「记忆整理调优」卡:7 键表单(中文标签、单位、留空⇒跟随谁、
  env 档当前值、坏值就地报错并拦住保存、保存后回显从磁盘读回的真值)
- 试算卡的 recallMode 按契约 v0.91 如实渲染:embedding / lexical(已降级)/
  mixed(部分分区降级)/ ""(无候选可比)——空串不再渲染成空标签;并把
  error.code=tidy_recall_unavailable 单列一句(「尝试过但向量源不可用」,与
  「没有候选可比」显式区分)

验证:go test ./internal/{services/gateway,runtime/memory,config/runtimeconfig}
全绿;三态/清除/坏文件拒写/同值不重写/热生效接线各有用例,并逐条做过变异自证;
tsc --noEmit + vite build 通过;两个 esbuild 前端测试 39 + 34 + 41 checks 通过。
2026-10-13 21:59:18 +08:00
AskaEth 17c41265e8 Merge branch 'feat/memexpire-bounded-expiry-sweep-with-audi' into dev 2026-10-13 21:59:01 +08:00
AskaEth 8a274e120d docs(memory): 到期清扫的注释改用符号引用,不再写会漂的行号
review r2(p2-1items)指出:r1 那笔注释里的行号是旧值 —— 我在文件更前面插了
12 行之后 `Stop` 整体下移,而注释里仍写着「`Stop` 在 `tidy_service.go:214` 就放锁,
到 `:219` 才 Wait」(214 是 `Stop` 的文档注释行、219 是 `s.mu.Lock()`)。

行号会漂、符号不会,因此改成符号 + 相对位置:
- 「在同一个 `func (s *TidyService) Stop` 里,`s.mu.Unlock()` 在前、`s.wg.Wait()`
  (另一个 goroutine 里)在后,两者之间没有锁」;
- 「`Start` 只在组合根启动期调一次(`cmd/cyrene/main.go` 的
  `a.memoryTidy.Start(ctx)`)、`Stop` 只在关停链里调一次(`cmd/cyrene/wire_services.go`
  的 `_ = a.memoryTidy.Stop(ctx)`)」。

同一类问题只此一处遗留(`expire.go` 里「照 `preinsert_gc.go:65-69` 的批删范式」
同样改成符号:`ingestReviewSweepBatchSize` / `maxIngestReviewSweepBatches` 那一对
常量)。已 grep 全部改动文件确认再无 `*.go:<行号>` 形式的引用。

(本提交信息刻意不复述任何行号。)
2026-10-13 21:54:38 +08:00
AskaEth 69210a8586 docs(memory): 改正 Start 里那笔 Add 的并发注释,并补 Enabled=false 回归测试
review r1(p2-1items)指出:`tidy_service.go` 新加的那段注释(以及 2ba0a40 的提交
信息)写着「计数在这把锁里加 …… `Stop` 也持这把锁,这样 Add 与 Stop 的 `wg.Wait`
不可能撞在一起」。**这句不成立**:`Stop` 在 `tidy_service.go:214` 就放锁,到 `:219`
(另一个 goroutine 里)才 `wg.Wait`。锁只串行化 `running` 翻转与两笔 Add,
**不覆盖 Wait**。reviewer 用交叉 Start/Stop 探针在 -race 下复现了
`panic: sync: WaitGroup is reused before previous Wait has returned`,并实测该 panic
在 M3 之前的基线(20051f0)上同样可复现 ⇒ 既有性质,本切片不改行为,只改说法:

- 照真实理由改写注释:锁内加是为了让 `Stop` 的 `running` 判定与这两笔 Add
  **原子化**(杜绝「Stop 已看到 running=false 早退」与「Wait 漏掉一笔尚未 Add 的
  goroutine」两个窗口);
- 明确写出这把锁**不**覆盖 `Stop` 的 `wg.Wait`,WaitGroup 的复用纪律靠
  「`Start`/`Stop` 在进程生命周期各只调一次」维持;
- 并写明那个交叉探针的**当前调用点不可达**(`Start` 只在 `cmd/cyrene/main.go:97`
  调、`Stop` 只在 `cmd/cyrene/wire_services.go:212` 调),免得后人看到「有窗口但
  不可达」以为是漏改。

同时补上 reviewer 点出的真实缺口:`Enabled=false ⇒ 清扫不跑` 此前只有注释、没有
已提交的回归测试。新增 TestTidyServiceStart_整理关掉时清扫不跑,真跑 Start 后断言
「没有删除发生 + 没有审计 + running 保持 false」三件一起(不是只断言 cfg.Enabled
字段那种假牙)。两条断言各有牙:把 Enabled 闸改成恒 false ⇒ 红在 running 那一条;
在 Enabled 早退分支里偷偷起一个清扫(running 仍为 false)⇒ 红在「到期行却被删了」。
2026-10-13 21:46:24 +08:00
AskaEth 44c25eb03b refactor(memory): 到期清扫挂点从 wait 注入缝改为 TidyService.Start
塔裁定:清扫不许挂在注入缝上 —— 包一层 s.wait 会让「清扫是否存活」依赖于
「loop 每一轮都调 s.wait」这个**没有任何契约固定**的假设;将来谁换了等待原语、
或把循环改成 select + 自己的 timer,清扫会**静默消失**(不报错、不红测试,
只是临时期限再也删不掉)。后台件起在 Start 里,读者一眼看到它们同生共死。

- tidy_service.go:Start 里紧跟 go s.loop(cctx) 起 go s.expirySweepLoop(cctx),
  wg 计数与既有那一笔同处(同一把锁内加,避免 Add 与 Stop 的 wg.Wait 相撞)。
  `if !s.cfg.Enabled { … return }` 仍在其前 ⇒ 语义与改动前逐字相同:整理被关掉
  ⇒ 清扫不跑(与 preinsert_gc 随审查服务启停同口径)。
- expire_loop.go:删掉 AttachExpirySweep 与对 s.wait 的包裹;文件头写明
  「挂点必须是 Start 里那处显式的 go」,防止后人改回注入缝。
- expire_loop_test.go:那条运行时对照改写成 TestTidyServiceStart_清扫随服务起停
  —— 真 Start、真 Stop(Stop 不超时即证明清扫随 ctx 退出且登记在同一个 wg)。
- wire_memory_tidy.go:去掉组合根的挂载调用,注释改成「起在 Start 里,组合根零接线」。
- wire_memory_tidy_test.go:把 go s.expirySweepLoop(cctx) 钉进 TestMemoryTidyIsWired
  的 seams(摘掉那一行 ⇒ 用例红,已实测)。
2026-10-13 21:43:27 +08:00
AskaEth a017ab6cf8 feat(memory): 临时记忆到期物理删除(有界清扫 + 同步清向量 + 删前审计)
design/23 §19 的第 ② / ③ 步:读侧时效闸(M1)只负责「看不见」,本切片负责
「真删掉」。三条护栏照 §19.3:删除是本包内唯一 DELETE 出口、有界(500 行/批 ×
20 批)、删前留审计。

- internal/runtime/memory/expire.go:扫描集合写死为「expires_at ≠ '' ∧
  expires_at ≤ now ∧ status='active' ∧ 来源非受限」。空 expires_at(长期/日常
  记忆,含 M2 登记的「INSERT 与补写之间中断」窗口)结构性进不了集合;受限来源
  复用 nonOwnerSource(与整理/审查同一处判据)。键集游标 (expires_at, rowid)
  保证被跳过的行只读一次、扫描位置一定前移。审计串 = id + persona_id + scope +
  expires_at + 按 rune 截断(48)的摘要 —— 本仓唯一一处允许携带内容片段的审计。
- internal/runtime/memory/expire_loop.go:清扫循环挂在 TidyService 的生命周期上
  (AttachExpirySweep 由组合根调用),随心跳 ctx 跑、随 Stop 停;一轮只写一行
  汇总日志,逐条的那一行进审计。
- internal/runtime/memory/vector/delete.go:新增可选删除能力 vector.Deleter +
  两个后端实现,删行时同步清向量(M1 reviewer 的 finding:过期向量占 top-K 预算)。
- cmd/cyrene/wire_memory_tidy.go:挂上清扫;顺带把已过时的「这十键」改成「这十一
  键」(M2 加了第十一键 tempTtlSec)。
2026-10-13 21:43:27 +08:00
AskaEth 80096c8bd1 docs(api): 契约 v0.92 — 记忆整理调优键读写端点(B26/B27,切片 M4)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-13 21:38:47 +08:00
AskaEth 20051f0168 Merge branch 'feat/ownergate-owner-lane-gate-plus-conversat' into dev 2026-10-13 21:18:02 +08:00
AskaEth 8690d129a7 Merge branch 'feat/memwrite-defer-fifth-state-plus-temp-ttl' into dev 2026-10-13 21:09:47 +08:00
AskaEth 48a5bb89f2 fix(config): owner_lane.json 并发落盘不再损坏/丢更新(review p1)
review p1:Set 的固定 tmp 名(path+".tmp")+ 落盘发生在 mu.Unlock() 之后 ⇒
并发 Set 撞同一临时文件把 JSON 写坏(load 退回默认「甲」、主人刚说的「少说点」
白说且无提示),或 rename 完成顺序与新快照先后不一致而丢更新。

修法(两条都取,各司其职):
- 落盘挪进锁内(Set 全程持 s.mu):并发 Set 完全串行 ⇒ 快照与 rename 顺序一致,
  不可能丢更新。落盘是一个小 JSON,持锁 I/O 代价可忽略;读侧读内存、不读文件。
- 每次用 os.CreateTemp 新建临时文件(仓库同款先例 internal/user/save.go),
  不再撞固定 tmp 名;CreateTemp 以 0600 打开并再 chmod 一次,任一步失败都清掉 tmp。
- 旧 save(快照) 换成 saveLocked()(调用方须持锁,直接 marshal s.cfg)。

测试:新增并发用例 TestOwnerLaneStoreConcurrentSetKeepsAllUpdates(24 轮 × 24 写者),
断言 ① 文件始终是合法 JSON、② 每一次成功的 Set 都能在最终文件读到(不丢更新)。
修前红(原实现在同一用例上):5/5 轮复现,两种失败模式都抓到 ——
"并发落盘把配置写坏了(文件不是合法 JSON):invalid character '\"' after top-level value"
与 "丢更新 —— 键 qq-obv11|g-N-M 不在最终文件里";修后连续 3 次绿,-race 下亦 ok。
Bridge.SetOwnerLanePolicy 无需改动:它在 ownerLaneMu 下设内存后调注入的 store,
串行化由 store 侧保证。
2026-10-13 21:07:46 +08:00
AskaEth c7ea67dcb6 Merge branch 'feat/embrecall-fix-embedding-recall-degradati' into dev 2026-10-13 21:07:21 +08:00
AskaEth a3ae98ef2e docs(memory): N2 注释三处更正(示例 / 标点容忍 / 位置锁)+ 逐条实测
应答 review round4 的 p2#1。**只改注释**,行为代码、后缀表 golden、25 条语料、
主语表一字未动。三处都指向**更严格**的表述(无安全影响),且每一处都先实测再下笔
—— 上一轮的教训正是「注释里的例子自己没实测过」。

## ① 示例 `生气得很` 与实现不符 → 换成实测覆盖的示例

原句举例「生气**得很**」,但 `得` **不在**后缀表里:
  生气得很 → rest="得很" → rule=""(放行)
与它并列的另两个例子矛盾。换成实测 `rest=""` 的:
  心情不错 → rest="" → rule="n2_emotion"
  不开心   → rest="" → rule="n2_emotion"
  心情很好 → rest="" → rule="n2_emotion"
并把反例留在原位(显式标注「反例」):
  生气得很 → rest="得很" → rule=""(放行)

## ② 「任何不在表中的字符 ⇒ 放行」对标点不绝对 → 补齐容忍面

标点与空白是被 `onlyRetentionPunctuation` 收尾清掉的,因此原表述漏了这半边:
  心情不错! → rest="" → rule="n2_emotion"   ← 实测 defer(标点被容忍)
现改为「任何一个**非标点 / 非空白**的字符只要不在词表里,就会留在余部」。

## ③ 「主语/时间词只允许出现在句首」不准确 → 按实现写「位置锁」

时间词也可**紧跟主语之后**。现按实现写成:
  主语只在**位置 0** 被消费一次;时间词只在**紧随其后**被消费一次(无主语时它就是首词)。
实测:
  今天好累     → rest=""             → rule="n2_emotion"
  主人今天好累 → rest=""             → rule="n2_emotion"
  今天主人好累 → rest="今天主人好累"  → saw=false → rule=""(放行)
「指人/指时实体永远不可能落到宾语位置」这条**支柱本身不变**(review round4 已核),
变的只是对**位置**的准确描述。

## ④ 顺手同批修掉的同句不准确:`retentionEmotionTimeWords` 的文档

它与 ③ 是**同一句主张的另一个落点**(「只允许出现在句首的时间词」),留着就等于把同一
处不准确留在盘上。已改成与 ③ 一致的表述并附同一条实测。
(`consumeRetentionPrefixes` 的「反复在句首消费」指的是**当前余部**的开头,是对辅助函数
自身语义的描述,不是文法主张,未动。)

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
TestExtractionRetention 六个用例 69 个子例全 PASS。
2026-10-13 21:06:33 +08:00
AskaEth 95f517d61d refactor(embedding): 删掉生产不可达的冷启动 WARN 及其 logger 缝
review round2 的 p2:`api.go` 那句「一次性冷启动放宽被花掉」的 WARN 在生产里
**不可能被人看见** —— 本包默认丢弃日志器,`WithLogger` 全树零调用者,生产走
`embedding.New(cfg)` 不带任何选项。而它的注释写着「这是唯一能审计一次性放宽的
地方」,与实际不符(本项目规则:注释不得说与实现不符的话)。

- 删该 WARN(失败并非静默:四个调用方本来就把错误原文写进各自的 WARN,
  路径见 review 召回 / 整理召回 / 向量索引写入 / 检索)。
- 连带删掉 `WithLogger` 与 `log` 字段:WARN 是 `e.log` 的唯一使用者,留着它会
  变成一个**空转的公开选项 + 又一次与实现不符的注释**(与上一轮删
  `WithRequestTimeout` 同一条理由:不留没人用且未测的缝)。
- `callTimeout` 不再返回 `relaxed`(它只为那条 WARN 存在)。
- `NewAPI` 的注释改为把「本包不记日志、失败沿错误返回」写成**实话**。

行为零变化(生产本就是丢弃日志器),只删死代码与不实注释。
2026-10-13 21:01:54 +08:00
AskaEth 4365224c9b docs(memory): N2 安全论证改成真实机制(位置锁 + 四类修饰)+ 两条 tripwire
应答 review round3 的 p2#1(要修的只有这一条)。

## 注释里的论证被证伪,改成真实机制

原句「封闭表里**没有承载内容的词**:没有动词、名词、量词」**字面不成立** ——
reviewer 逐表审过:情绪词表(既是核心也是后缀 token)含 `心情`(名词)、
`生气`/`害怕`/`紧张`(动词)、`压力大`(动宾);否定表里 `没有` 是动词;程度表里
`比较` 可作动词/名词;主语表里 `主人`/`我`/`他` 本身就是**指人实体**。

真正保安全的是**位置**:
1. **位置锁定** —— 主语 / 时间词只允许出现在**句首**、各至多一次 ⇒ `主人`/`我`/`你`/
   `他`/`今天` 这些指人/指时实体**在文法里永远不可能落到宾语位置**;
   (上一版黑名单正是死在这里:`会想我` 的 `我` 被当成主语剥掉)
2. **核心之后只允许「程度 / 否定 / 情绪 / 语气」四类修饰**,且它们只允许**紧跟**
   情绪核心之后 —— 核心必须先被消费,那一段在语义上只能是**情绪的修饰**
   (「心情不错」「不开心」),**装不下一个宾语短语**。

「上一版为什么错、黑名单为什么修不完」那一段**保留**(reviewer 确认该部分写得对)。

## 两条 tripwire(回应「后来人照字面加词、没有任何测试会红」)

- `TestExtractionRetention_PersistentFactsAreNeverDeferred`:**25 条**持久事实/习惯
  语料(塔给的 6 条 + reviewer 扩展的 19 条,来自 review round3 的对抗构造)逐条断言
  **绝不 defer**,钉住**语义后果**;语料是**可增长的**(新事故 → 一条回归断言)。
- `TestExtractionRetention_SuffixTableIsPinned`:把后缀表的**内容**逐 token 钉死
  (60 个 token 的 golden),任何增删立刻红。
  **为什么它比语料更接近「表的内容性质」**:语料只能覆盖**已经想到的**输入,而这张表
  是判据的全部自由度 —— 往后缀里加一个名词,表变了而语料可能一条都不碰;golden 钉的
  是「表变了」这个**事实本身**,改表必须显式改 golden ⇒ 变成一次可 review 的
  意图声明,而不是一次静默放宽。

两条一起才够:golden 钉「表变了」,语料钉「变了以后语义还对不对」。

## 变异自证(两条 tripwire 分别会红)

变异 A:往后缀语气词表加一个名词「**妈妈**」(语料里没有任何一条会因此变 defer)
```
--- FAIL: TestExtractionRetention_SuffixTableIsPinned (0.00s)
    后缀表 token 数 = 61, want 60 —— 表被改过。…
FAIL
```
⇒ golden 抓住了**语料抓不到**的改表。

变异 B:往后缀表加 `会`/`想`/`时`(看起来像无害虚词)
```
--- FAIL: TestExtractionRetention_PersistentFactsAreNeverDeferred (0.00s)
    --- FAIL: …/主人心情不好时会不好
        extract_retention_test.go:284: 持久事实 "主人心情不好时会不好" 被判成 "n2_emotion" —— 它会进临时层、7 天后被物理删除(不可逆)
    --- FAIL: …/主人不开心时会好好想
        extract_retention_test.go:284: 持久事实 "主人不开心时会好好想" 被判成 "n2_emotion" —— …
--- FAIL: TestExtractionRetention_SuffixTableIsPinned (…)  后缀表 token 数 = 63, want 60
FAIL
```
⇒ 语料把「会上线的那个后果」直接指了出来。两处变异均已还原。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
TestExtractionRetention 六个用例共 69 个子例全 PASS。
2026-10-13 20:57:31 +08:00
AskaEth 6c30ca540a fix(memory): 冷启动放宽改为一次性 + 试算轮区分「没候选」与「向量不可用」
review round1 的 p1/p2 收口(commit 5967272 之后)。

p1:旧实现 `warmed` 只在 2xx 之后才翻 ⇒ 注释里写的「只在第一次成功响应之前」
实际是「服务坏着的全部时间」。reviewer 实测「接受连接但永不回包」时**每次**调用
都等满 30s(fail-fast 变 fail-slow):审查 20s 预算被一次吃光、索引写入拖成
8×30s、开机探针阻塞 30s。

- `warmupLeft` 改为**一次性许可**(CompareAndSwap 取走即失效,成败都算花掉)
  ⇒ 挂死时只有第一个请求慢一次,此后一律 DefaultRequestTimeout。
- `warmupTimeout` 30s → 8s:与常态 5s 同一量级(≤2×),且严格小于审查总预算
  20s(一次冷启动不许吃光整轮);两条界都由 TestDefaultRequestTimeout 守住。
- 放宽被花掉且没成功时留一条 WARN(事后可复核「第一个请求」这个判据)。
- 更正两处自述:api.go 的「不会被 30s 拖住」与 budget.go 的不变式说明(批次形状
  永远按稳态超时推导;冷启动只放宽第一个请求的截止时刻)。
- p2:删掉零调用者的 `WithRequestTimeout` —— 单次超时只允许改常量,批次上限与它
  同源并由编译期/常量断言复核。

p2:试算轮在向量源不可用时 `RecallMode` 与「没有候选可比」同为空串。
- `recallLedger` 增 `unavailable` 计数 + `noteUnavailable()`(strictRecall 失败
  那一笔记一笔);试算汇总行新增 `recall_unavailable_partitions`,**成败都记**。
- 不扩 `recallMode` 取值集合:失败那一路不序列化 result(响应是
  500 tidy_recall_unavailable),新取值只会出现在日志里却要动一次契约,不划算。

测试:三种失败形态 × 两次调用的耗时断言(连接被拒 / 500 / 永不回包),
试算轮的「不可用」与「没候选」两态日志断言;两处变异自证(warmupTimeout 调回
30s ⇒ TestDefaultRequestTimeout 红;去掉 noteUnavailable ⇒ 试算汇总行退化成
recall_mode="" + recall_unavailable_partitions=0)。
2026-10-13 20:51:10 +08:00
AskaEth 1d49d266f0 fix(channel): message_sent 回声升 T2 落行,出站消息 id 不再被丢
塔转 M12 登记的缺口(塔裁定「归你」,文件都在本片 scope 内):她自己发出消息
的回声(message_sent)被当成「帧级链路遥测」一并豁免 self 升档 ⇒ 停在 T1,
只进计数、不落行,`message_id` 被丢,「她最近发了哪些消息」在库里查不到。

- events.go: 把 MessageSent 从 isFrameTelemetry 的豁免里摘出(它不是帧级遥测:
  一帧 = 她真发出的一条消息,回声带的是那条出站消息的 id)。
  心跳 / 生命周期的豁免**保持不变**(其登记的迁移债注释原样保留)。
- events_mapping_test.go: 两条 MessageSent 断言同步更新
  (:52 自身回声命中自己 ⇒ 由 T1 改 T2;:33 无 self 信号仍走基础档 T1,标签改精确)。
- events_test.go: 新增端到端用例(真 SQLite):self 命中 ⇒ 落 1 行、tier=t2、
  MessageID=out-msg-42 留在库里、不进上下文、不驱动 loop。
2026-10-13 20:50:31 +08:00
AskaEth 537a8b7988 feat(channel): owner 车道结构化参与闸与可调旋钮(乙 = C)
主人 2026-10-13 裁决「乙 C」= 默认按甲(群里不对她说就不开口)+ 可调旋钮。

- inbound.go: owner 不再无条件短路参与闸(去掉 `!actor.IsOwner()` 门),
  主人群消息改走 shouldRespond 的 owner 车道分支;主人私聊恒回。
- ownerlane.go(新): owner 车道结构化闸 + 判据
  `msg.AddressedToMe || (msg.SelfID == "" && len(msg.Mentions) > 0)`
  (未接线渠道退回旧判据,保护 kook/webhook 的群 @);指向她本人必回(硬保留);
  非指向消息按每群旋钮——默认只进上下文不回(registerSilenced 落隔离会话)。
  「少说点」类关键词落成结构化降频配置,带 source 日志。
- channelbridge.go: Options.OwnerLaneFor/OwnerLaneSet 读写接缝 + Bridge 状态。
- engage.go: 冷避词表并入「少说点」(与既有「少说两句」同义)。
- runtimeconfig/channels_ownerlane.go(新): owner_lane.json 落盘面
  (默认档 + 每群覆盖,可读可改,面板可写)。
- wire_channels.go: 注入 owner_lane.json 存储($DATA_DIR/config/)。
- 测试:不对她说就不回(进上下文零回复)/ @她必回(即使降频)/ 未接线退回
  旧判据 / 降频生效且可恢复 + 频率闸 / 旋钮读写接缝 / 归属不变量闭合 /
  陌生人私聊与主人私聊回归。变异自证:恢复 owner 短路 ⇒ 5 条用例红。
2026-10-13 20:48:05 +08:00
AskaEth b1d51d8de2 fix(memory): N2 判据改为白名单形态匹配(结构上不再吞实体)
## 上一版为什么没修完(review round2 举证)

上一版把 N2 实现成**黑名单剥离**:`strings.ReplaceAll` 扫整句、把「情绪词 + 时间词 +
主语 + 程度 + 虚词 + 标点」统统删掉,**残差为空才 defer**。它的要害是剥离表里混进了
**本身就能当实体的词** —— 人称词(`我`/`我们`/`他`/`咱`/`自己`)与可作内容的单字
(`好`/`会`/`要`/`想`/`对`/`在`/`有`/`是`/`时`/`得`/`和`)。宾语恰好由这些词构成时,
实体被吞、残差为空 ⇒ **仍判 defer**:

  主人不开心时会想我    → residual=""      → defer   ✗(持久习惯进临时层,7 天后物理删除)
  主人难过时会想我们    → residual=""      → defer   ✗
  主人心情不好时会想他  → residual=""      → defer   ✗
  主人不开心时会想和好  → residual=""      → defer   ✗(`和`+`好` 两个词项各吃掉一半)
  主人难过时会想妈妈    → residual="妈妈"  → create  ← 对照:宾语没被表吃掉

⇒ 这证伪了上一版代码里声称的不变量(「还剩一个实体就必须放行」),也说明**黑名单这
条路走不到头**:「实体」是开放集合(想我/想我们/想他/想和好/想妈妈/听音乐/咬指甲/
养猫/…),永远列不全。

## 新判据:白名单**形态**匹配

「纯情绪句」的**形态是封闭的**,据此改成「整句恰好落在形态里才 defer,其余一律放行」:

	[主语]? [时间]? [程度]* 情绪核心 [程度|否定|情绪|语气]*   余部须只剩标点/空白

实现在 `retentionEmotionFormRest`(唯一判据点):
- 主语与时间词**只允许出现在句首**、各至多一次;
- 情绪核心**必须**消费到一个情绪词,否则 sawEmotion=false ⇒ 放行;
- 核心之后**只**允许「程度 / 否定 / 情绪 / 语气」四类修饰(`retentionEmotionSuffixTokens`);
- 最后要求**整句消费干净**(余部只剩标点/空白);
- 各段一律**最长词优先**(否则 `不` 会抢在 `不错` 前、`好` 会拆掉 `好累`)。

## 为什么它在**结构上**不可能再把带实体的句子判成 defer

1. 判 defer 的门槛是**整句被封闭表消费干净**。表里**没有承载内容的词** —— 没有动词、
   名词、量词。任何一个形态外的字(`想`/`会`/`时`/`和`/`我`/`妈妈`/`听音乐`)都无法被
   消费,必然留在余部 ⇒ 余部非空 ⇒ **放行**。
2. 主语/时间词**限死句首**:`我`/`他`/`我们` 出现在核心之后时**没有**任何词项能吃掉它
   (后缀表里没有它们)—— 这正是上一版翻车的地方,现在从结构上排除了。
3. 失败模式从「**未知实体被吞**(危险、不可逆)」翻成「**未知情绪句没被认出**(安全:
   多留,有 T1 与整理兜底)」。方向与 design/23 §18.3 的 fail-safe「宁可多留,不可
   误弃」一致。

## 配套测试

- 正例清单补 5 条**对抗形态**(宾语由「能当实体的词」构成):`主人不开心时会想我` /
  `主人难过时会想我们` / `主人心情不好时会想他` / `主人不开心时会想和好` /
  `主人难过时会想妈妈` —— 与上一轮的 5 条(听音乐/咬指甲/养猫/喜欢吃甜的/找朋友聊天)
  合计 10 条「带宾语的情绪句」正例;
- `TestExtractionRetention_EmotionWithObjectIsNotDeferred` 改为断言**余部**(未被形态
  消费掉的部分)+最终结论:纯情绪 ⇒ 余部为空 ⇒ defer;带宾语 ⇒ 余部非空(
  `时会想我` / `时会听音乐` / …)⇒ 放行。**「带宾语 ⇒ 余部非空」这条不变量现在是被
  判据本身保证的**,不再是靠一张表的完备性。

## 修前必红 / 修后绿(原样输出)

修前(把 `judgeRetentionEmotion` 换成 commit 979d1cf 的黑名单实现,逐字):

```
--- FAIL: TestExtractionRetention_EmotionWithObjectIsNotDeferred (0.00s)
    --- FAIL: …/主人难过时会想我们  extract_retention_test.go:122: judgeExtractionRetention("主人难过时会想我们") = "n2_emotion", wantEmotionDefer=false
    --- FAIL: …/主人心情不好时会想他  … = "n2_emotion", wantEmotionDefer=false
    --- FAIL: …/主人不开心时会想和好  … = "n2_emotion", wantEmotionDefer=false
    --- FAIL: …/主人不开心时会想我    … = "n2_emotion", wantEmotionDefer=false
--- FAIL: TestExtractionRetention_PositivesAreNeverDeferred (0.00s)
    --- FAIL: …/情绪+代词宾语(想我)   正例 "主人不开心时会想我" 被拦成 "n2_emotion" —— 把真记忆丢进临时层
    --- FAIL: …/情绪+代词宾语(想他)   …   --- FAIL: …/(想我们) … --- FAIL: …/(想和好) …
FAIL
```

修后:`TestExtractionRetention_{UpperCaseASCII,NegativesAreDeferred,
EmotionWithObjectIsNotDeferred,PositivesAreNeverDeferred}` 全 PASS(共 44 个子例)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿。
2026-10-13 20:47:59 +08:00
AskaEth 041a6ff504 Merge branch 'feat/outboundid-capture-platform-message-id-o' into dev 2026-10-13 20:47:55 +08:00
AskaEth b504df01e0 feat(channel): 出站消息 id 采集(可选 seam + qq_obv11 两分支取回)
「接她的话」缺的 A 层:平台给她自己那条出站消息分配的 id,此前在解析的那一刻
就被丢掉(HTTP 只校 retcode、WS 写完不等回声、契约层没有 id 出口)。

- internal/channel/adapter/outbound.go(新):SendReceipt/SendOutcome/SendIDStatus
  + 可选接口 OutboundReceiptAdapter + 调用方入口 SendWithReceipt。**不加进
  PlatformAdapter** —— 四个未接线适配器与全部桩实现一行不改(向后兼容硬指标);
  也不用回调:id 是某一次投递的属性,必须随那次投递返回。
- qq_obv11:HTTP 从响应信封 data.message_id 取;WS 登记 send_msg 的 echo 等待者
  (复用 probe.go 的 action 等待表与 routeActionResponse,不另开通道)。取不到
  一律空 id + 三态可分辨(reported/absent/unreported),绝不编造。
- send_msg echo 键改用单调计数器(旧 UnixNano 在 Windows 时钟粒度下会撞键,
  撞键即拿不到自己的回执)。
- 既有 SendMessage/SendProactive 路径行为不变(wantReceipt=false 不等待)。

验收:go build ./... + go vet ./... + go test ./... 全绿(cmd/cyrene、internal/webui
的 setup 失败是 worktree 缺 dist 的既有环境限制,base 同样失败)。
2026-10-13 20:38:12 +08:00
AskaEth 5daba92d0f docs(memory): 十一键措辞收口(seed* + snapshot*)
tempTtlSec 落地后,`memory-tidy.json` 的播种契约就是**十一键**,而三处文件的注释仍
写「十键」。这些是**过时的陈述**(不是风格问题):下一位读者会从「快照契约:十键非
nil」里读出一个错误的口径,而 `memoryTidySeedComplete` 实际校验的正是十一键。

改的只是措辞,一行逻辑没动:
- internal/config/runtimeconfig/memorytidy_seed.go:25,160
- internal/config/runtimeconfig/memorytidy_seed_test.go:19,27,62,82,97,214
- internal/runtime/memory/tidy_snapshot_test.go:14,27,159

(`cmd/cyrene/wire_memory_tidy.go:65` 还有同类的一处「这十键」,该文件属 M3 的
scope,未动 —— 塔已转 M3。)

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿。
2026-10-13 20:38:02 +08:00
AskaEth 979d1cfbb2 fix(memory): 留存规则 N2 改按实体判定(持久习惯不再被误弃)
## 改了什么(行为变更)

`judgeRetentionEmotion` 原判据是「含情绪词 ∧ 无阿拉伯数字 ∧ ≤24 rune」—— 它把
design/03 §10.5 对 N2 的定义「**词项 + 无实体**」里的「无实体」这一维**顶替成了
「无数字」**,方向恰好落在 fail-safe 的反面。

反例(review 走 `judgeIngest` 真链路复现):
  主人心情不好时会听音乐   → 旧判据 defer / t0_low_value
  主人焦虑时会咬指甲       → 旧判据 defer / t0_low_value
这两条是**稳定习惯**(正向清单),判 defer 即 7 天后从所有读路径消失(读侧时效闸,
design/03 §9.6)、随后被后台清扫**物理删除**(design/23 §19)—— **不可逆**,与
mission 文本写死的 fail-safe「宁可多留,不可误弃」相反。

新判据(设计与 mission 允许,不改设计):**剥掉情绪词 / 时间词 / 主语 / 程度词 /
虚词 / 标点之后,余下为空才判 defer**;只要还剩一个实体(听音乐 / 咬指甲 / 养猫
/ 找朋友聊天)就放行。实现是 `retentionResidual` + 一张**长词优先**的剥离表
(长词优先是必须的:先剥「好」会把「好累」拆成光杆「累」而永远剥不掉)。

配套:
- 剥离表新增「不错」(情绪评价,整词)与光杆「累」—— 只靠虚词表里的「不」会把
  「心情不错」剥成「错」、把「主人今天真的特别累」剥成「累」而漏判;
- 去掉 N2 专属的 24-rune 上限(判据恒是「无实体」,长度由全局 40-rune 上限兜住);
- 数字不再单独设防:它不在剥离表里 ⇒ 必然留在残差里 ⇒ 一律放行(与旧
  hasRetentionDigit 等价,但由同一条判据统一给出)。

## 为什么这是 p1 而不是精度问题

判错的方向不对称:漏判只是噪声留进长期层(有 T1 与整理兜底),误弃是**不可逆的
数据丢失**。因此 N2 宁可漏判,绝不误弃。

## 配套测试(此前这条缝**完全没被覆盖**)

- 正例清单新增 5 条「带宾语的情绪句」:`主人心情不好时会听音乐` /
  `主人焦虑时会咬指甲` / `主人心情低落时会养猫` / `主人压力大的时候喜欢吃甜的` /
  `主人难过时会去找朋友聊天`(旧正例里没有这一类样本,所以旧缺陷一路全绿);
- 新增 `TestExtractionRetention_EmotionWithObjectIsNotDeferred`:逐条断言**残差**
  与最终结论 —— 带宾语 ⇒ 不 defer、纯情绪 ⇒ defer,两者必须给出相反结论;
- 新增反例 `主人今天真的特别累`(纯情绪、无实体)钉住「不是靠长度躲过」。

修前必红 / 修后绿(把 N2 退回旧判据后原样输出):
  正例 "主人心情不好时会听音乐" 被拦成 "n2_emotion" —— 那是把真记忆丢进临时层
  正例 "主人焦虑时会咬指甲" 被拦成 "n2_emotion" —— …
  judgeExtractionRetention("主人心情不好时会听音乐") = "n2_emotion", wantEmotionDefer=false
  --- FAIL: TestExtractionRetention_PositivesAreNeverDeferred
  --- FAIL: TestExtractionRetention_EmotionWithObjectIsNotDeferred
还原后:TestExtractionRetention_{UpperCaseASCII,EmotionWithObjectIsNotDeferred,
NegativesAreDeferred,PositivesAreNeverDeferred} 全 PASS。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿。
2026-10-13 20:37:57 +08:00
AskaEth 3f266c26b2 Merge branch 'feat/speakerattr-attribute-group-speakers-and' into dev 2026-10-13 20:37:19 +08:00
AskaEth e32d85afc3 docs(api): 契约 v0.91 —— recallMode 如实上报(新增 mixed/空态语义)
起因:M6 诊断发现旧实现把「向量路径实际失败、回退词项」静默报成 embedding,面板看不出降级。M9 改为上报实际选用的召回路径,取值域扩 mixed(部分分区降级)与 ""(无候选可比)。同步:版本行 + §6 v0.91 条目 + §4.12 的 recallMode 值表;并记录「客户端 5s 超时未改、改的是批上限推演」这条对客户端透明但重要的取舍。
2026-10-13 20:37:17 +08:00
AskaEth 91e4dd2fe1 fix(channel): 静默登记同源第三方标注 —— 无昵称非主人不再被读成主人
review p1:回帖路径已改用 thirdPartySenderName(非主人恒有标注:昵称 → actor
uid → 「未署名发送者」),但 registerSilenced 仍用 senderDisplayName,非主人
无昵称时写出空串;静默行会被 transcript 读回她的上下文,而受限会话里空标注就是
「主人本人」⇒ 在 senderName 可选且无兜底的渠道(webhook)上,一条没昵称的陌生人
消息被渲染成「[发言归属] 以下发言来自主人(管理员账户)本人」。

- light.go:270:senderDisplayName → thirdPartySenderName,与回帖路径同源。
  senderDisplayName 未成死代码:仍被 light.go:223(LightJudge 输入)、
  engage.go:284、flowgate.go:183 使用。
- 新增 TestSilencedNamelessMemberIsNotReadAsTheOwner:走真链路(静默登记 → 落库
  → 下一回合播种转录 → loop 归属渲染 → provider 请求),钉住「无昵称的非主人经
  静默登记后,读回上下文时不得出现主人标注」。修前必红(写入口径与模型侧两条
  断言同时报,含被读成主人的原样请求),修后绿。

验证:go build ./... / go vet ./... / go test -count=1 ./...(全树)全绿。
2026-10-13 20:34:04 +08:00
AskaEth 59672720bd fix(memory): embedding 批上限与超时同源 —— 不再「客户端先放弃、服务端继续算」
M9(EMBRECALL)。故障机制(M6 + 本次复测):客户端 5s 单次超时先放弃,服务端仍在
算那一批 ⇒ 本机 llama.cpp 被自己顶住 ⇒ 后续每个请求(含写向量索引的单条请求)
排队到自己的超时 ⇒ 召回全线降级。

- embedding/budget.go(新):把「一次请求的耗时上界 ≤ 单次超时的一半」写成不变式,
  批上限(条数 4 / 字符 480)与 5s 超时由**同一个不等式**解出;实测依据与复跑命令
  写进注释(空闲态摊销曲线 1→0.19s / 8→1.1s / 12→1.3s / 64→8.5s,约 50ms 固定 +
  2.7~3.9ms/字符,n≥32 起有数倍放大 ⇒ 余量取一半)。
- embedding/api.go:Embed 按同一对上限**自己切片**(不变式与调用方无关);冷启动
  首次调用单独给 warmupTimeout,拿到第一个成功响应后立刻回常态超时。
- memory/tidy_config.go:tidyEmbedBatch = embedding.DefaultBatchLimit(同源),
  并加编译期断言 —— 调回 64 会直接编译失败(constant -60 overflows uint)。
- memory/tidy.go | tidy_recall.go | tidy_dryrun.go:recall_mode 改报**实际发生**
  的来源(""=本轮没候选可比 / embedding / lexical / mixed),新增
  recall_degraded_partitions;试算轮 RecallMode 同口径(M6 medium finding)。
- 测试:embedding 侧用「实测曲线 + 会睡够时间的假客户端」钉住两态
  (批内 < 超时 ⇒ 不降级;旧配对 64 条 ⇒ 必然超时),并断言 abandoned == 0;
  memory 侧钉住降级/健康/没候选三态与试算轮的如实上报。

不改 preinsert*、不改 .env、不动判定规则与任何阈值。
2026-10-13 20:33:26 +08:00
AskaEth d4ed043d5c test(memory): 修播种日志用例的偶发假红 + 补 TempTTL 接线缝断言
两件都是**测试面**的补件,不碰被测逻辑。

① 既有 flake(internal/config/runtimeconfig/memorytidy_seed_test.go):
TestSeedMemoryTidy_LogsPathAndReasonWithoutValues 原先用**裸数字**扫**整行**日志找
4242/10800/1800,而同一行合法地打了 `path=<临时目录>`,`t.TempDir()` 的目录名带
随机数十进制 ⇒ 偶发命中(约个位数/10000),表现为假红。
修法取「断言带字段名的形态」:哨兵改成 `=4242` / `=10800` / `=1800` / `=259200`
(slog 的 `key=value` 字段形态),路径里的随机数前面是路径分隔片段而不是 `=`,
因此不受影响。
**为什么不用另一个备选「只扫 keys= 之后的内容」**:那会把断言缩成「keys 列表里没
有值」,任何以别的字段名打出来的值(`interval_sec=4242`、将来新加的字段)都会溜
过去 —— 等于把这条防护改成空壳,而不是修掉它的假红。
顺手把 tempTtlSec 的哨兵值 259200 一并纳入(上一切片给 completeSeedTuning 加了这
个键,漏掉它会让「不打配置值」在这一个键上失去覆盖)。

② 缝断言(cmd/cyrene/wire_memory_review_test.go):
TestMemoryReviewIsWired 的 seams 表补上 `a.memoryTempTTL()` 与 `TempTTL: tempTTL`
—— 上一切片把面板 `tempTtlSec` 接进 ReviewConfig 时,这条缝没有被覆盖,「接线被
摘掉」不会红。

自证:
- ① 前后对比(确定性,把临时目录名强制含 1800):旧形态 FAIL(「日志里出现了配置
  值 1800」)、新形态 PASS;另跑 `-count=20` 全绿。
- ② 变异:把 helper 与调用点一起改名 ⇒ `wire_memory_review_test.go💯 找不到
  "a.memoryTempTTL()"` FAIL。
2026-10-13 20:26:46 +08:00
AskaEth 8ab241f627 feat(channel): 群聊发言人归属落进模型上下文(user 侧也产出标注)
裁决 06.4.1 行动项①②落地(主人 2026-10-13 定稿:结构化归属 + system 标注,
不把元数据拼进用户正文)。

- loop/attribution.go:发言人归属从「只认 assistant」扩到 user 侧,按三条
  结构事实各出一条边界说明 —— assistant 的角色(她自己 / 别的角色,措辞逐字
  不变)、受限会话里带昵称的第三方人员(明标「不是主人」)、以及不带昵称的
  主人本人;受限会话里未标注的 assistant 历史按 design/22 §7-4 归到本会话角色。
  标注仍只新增 system 消息,正文一字不改。
- channelbridge/inbound_speaker.go(自 inbound.go 拆出):第三方发送者标注车道
  thirdPartySenderName —— 主人恒返回空(主人从不被标注),其余昵称缺失时退到
  平台 uid 再退到「未署名发送者」,绝不落进「空标注 = 主人」那条车道;
  thirdPartyBoundaryPrompt 删掉「只有未标注昵称的消息才是管理员(主人)发言」,
  改为「谁在说话以「发言归属」标注为准」。
- channelbridge/source.go:来源行删掉「(非管理员账户=第三方人员,未标注昵称=主人)」。
- 主人本人在群里的消息落库不再写昵称(sender_name 空 = 主人),使转录里同一句话
  的归属与当回合一致。

验证:go build ./... / go vet ./... / go test ./...(含全树)全绿;
变异自证:把 user 侧归属恢复成不产出 ⇒ 3 条 loop 用例 + 1 条渠道端到端用例变红。
2026-10-13 20:24:21 +08:00
AskaEth a6f3817061 Merge branch 'feat/addressto-add-addressed-to-me-signal-and' into dev 2026-10-13 20:23:43 +08:00
AskaEth b26bc55a15 feat(channel): 消息契约加「她自己账号 id / 是否在叫她」结构化判据并接线 qq_obv11
契约(internal/channel/adapter):UnifiedMessage 新增三个字段 —— SelfID(她自己在该
平台的账号 id)、MentionAll(是否 @全体,design/26 §2.4 的 mention_all)、AddressedToMe
(本条是否指向她本人);判据的唯一出处是新增的 MentionedSelf(结构化 id 精确比对,
空 selfID 一律 false)与 AddressedToSelf(私聊恒 true;群聊 = 点名她或 @全体;
平台未给 SelfID ⇒ fail-closed)。全程不匹配正文文本。

接线(plugins/channels/qq_obv11):新增 addressed.go,把事件里早已解出、此前只用于
debug 日志与 notice 判定的 self_id 接进 toUnified,并从 at 段解出 @全体(qq=all),
在 ChannelType 判定之后统一回写三个字段。

design/18 §2 触发① 的旧口径「Mentions 非空」= @谁不论(实测群 672432767 里 @别人两次
被回);本片让消费侧(light.go:142,归 M12)能改用 AddressedToMe || MentionAll。
既有字段 Mentions/ReplyToID/ReplyToText 语义与形状不变。

测试:qq_obv11 十种帧形态(私聊/@她/@别人/@全体/无@/CQ 形态/fail-closed/纯回复)
逐条断言;adapter 侧覆盖 MentionedSelf 与 AddressedToSelf。变异自证:判据改回
「Mentions 非空」⇒ group_mention_other 红;摘掉 selfId 接线 ⇒ group_mention_me 红。
2026-10-13 20:17:56 +08:00
AskaEth 1cd38514f3 Merge branch 'feat/owneracct-fix-add-account-id-button-roun' into dev 2026-10-13 20:07:02 +08:00
AskaEth 625ae33c1e fix(admin): 主人账号编辑器草稿改存无损形态 ——「+ 添加账号 ID」不再点了没点
根因:`emit` 把每次改动都写成 `flattenAccounts(groups)`(写盘形态,空行被丢掉),而
草稿本身就是界面手势的往返载体 ⇒ 刚点出来的占位空行当场蒸发,界面照旧(主人报的
「配置指定适配器主人账号的卡片内的添加账号ID按钮似乎没用」)。`+ 添加平台` 走同一条
通路(空平台组同样被丢),实测同样失效。

改法:分清两种扁平形态 ——
  · 草稿 `draftAccounts`(编辑期,无损:空行/未选平台的组都留着;不 trim 正在输入的字)
  · 写盘形态 `accountsForSave`(保存期,只留平台与账号 ID 都非空的条目)
后端契约与 `configs/users/owner.yaml` 格式一个字不改。保存路径写盘前必须收形:半成品
条目(有 id 没平台)会被后端 `Validate()` 直接 400(internal/user/load.go)。

测试:`OwnerAccountsView` 从编辑器里拆出来(无 hook),测试可直接调用它、点它自己的
按钮、把 onChange 收到的真草稿再喂回去重渲染 —— 走的是「手势 → 草稿 → 界面」真往返
(本项目没有 jsdom)。修前该测试红在「点『+ 添加账号 ID』⇒ 界面上真的多出一行 —
got 1, want 2」。
2026-10-13 20:00:43 +08:00
AskaEth b01e2ddb1a refactor(admin): 主人账号编辑器拆出无 hook 的 OwnerAccountsView
界面本体与「分组/选项 memo」分层:OwnerAccountsEditor 只做 memo 组装,OwnerAccountsView 无 hook。本项目没有 jsdom,拆开后测试可以**直接调用**视图、点它自己的按钮,从而钉住「手势 → 草稿 → 重渲染」这条往返(见下一个提交)。行为不变:emit 仍用 flattenAccounts;账号行加 data-account-row 定位标记。
2026-10-13 19:56:32 +08:00
399 changed files with 46016 additions and 1972 deletions

No files matched your search

+1 -1
View File
@@ -81,6 +81,6 @@
- 🔴 **等待子代理:不许用长 `WaitFor` 阻塞回合(主人 2026-10-04 反馈)**:主会话在 `WaitFor` 里等子代理时,**主人无法给任何补充输入** —— 等于把主人锁在门外。规矩:**派完就结束回合**(子代理的完成通知会自动唤醒主会话);确需同回合内等,**单次 `WaitFor` ≤ 60s**,且只用于「马上就要用它的结果」;等待期间若有并行工作,**先做工作再结束回合**,不要空等。 - 🔴 **等待子代理:不许用长 `WaitFor` 阻塞回合(主人 2026-10-04 反馈)**:主会话在 `WaitFor` 里等子代理时,**主人无法给任何补充输入** —— 等于把主人锁在门外。规矩:**派完就结束回合**(子代理的完成通知会自动唤醒主会话);确需同回合内等,**单次 `WaitFor` ≤ 60s**,且只用于「马上就要用它的结果」;等待期间若有并行工作,**先做工作再结束回合**,不要空等。
- 🔴 **遇到开发文档冲突 / 意义不明 / 规格矛盾时**:**阻塞会话**,用 `AskUserQuestion` 询问主人,**不得自行猜测**;文档清、不冲突则自主推进。 - 🔴 **遇到开发文档冲突 / 意义不明 / 规格矛盾时**:**阻塞会话**,用 `AskUserQuestion` 询问主人,**不得自行猜测**;文档清、不冲突则自主推进。
- 🔴 阶段验证:后端 `go build ./...` + `go vet ./...` + `go test ./...` 全绿 + windows/darwin 交叉编译;前端(M9+)`tsc --noEmit` + `vite build` 通过。 - 🔴 阶段验证:后端 `go build ./...` + `go vet ./...` + `go test ./...` 全绿 + windows/darwin 交叉编译;前端(M9+)`tsc --noEmit` + `vite build` 通过。
- 🔴 **换二进制前先确认前端是否变更(2026-10-05 登记)**:`scripts\ctrl.bat build` **只跑 `go build`,不含前端**。若本次改动触及 `frontend/web/**`,必须先在 `frontend/web` 跑 `npm run build`(会把 `dist` 拷到 `internal/webui/dist`),**再** `ctrl.bat build` + **一次** `restart`;否则后端产物里带的是旧前端。反过来:**纯前端改动不需要重启后端**(运行时优先读磁盘 `frontend/web/dist`),刷新浏览器即可。 - 🔴 **换二进制前先确认前端是否变更(2026-10-05 登记)**:🔴 **`internal/webui/dist/.gitkeep` 是 `git add -f` 追踪的占位文件,不要删、不要 `git rm --cached`** —— 它让**干净 checkout / 新 worktree** 能直接 `go build ./...`(`//go:embed all:dist` 在空目录上会编译失败);真实产物仍被 `.gitignore` 的 `dist/` 忽略,`frontend/web/copy-dist.mjs` 已在构建末尾保证把它写回来。删掉它会同时打坏编译与前端拷贝链。`scripts\ctrl.bat build` **只跑 `go build`,不含前端**。若本次改动触及 `frontend/web/**`,必须先在 `frontend/web` 跑 `npm run build`(会把 `dist` 拷到 `internal/webui/dist`),**再** `ctrl.bat build` + **一次** `restart`;否则后端产物里带的是旧前端。反过来:**纯前端改动不需要重启后端**(运行时优先读磁盘 `frontend/web/dist`),刷新浏览器即可。
- 🔴 **裁定必须带完成状态(2026-10-05 登记)**:凡是给主人 / 给安卓端的裁定、承诺、回执,**每一条都必须标注「已完成 / 在办 / 未开始」**;标「已完成」须附 commit 号或验收证据,且**完成后回原条目处补记「已完成 + commit」**——不得只在别处口头称完成。**禁止把「未验证」表述成「已修好」**。 - 🔴 **裁定必须带完成状态(2026-10-05 登记)**:凡是给主人 / 给安卓端的裁定、承诺、回执,**每一条都必须标注「已完成 / 在办 / 未开始」**;标「已完成」须附 commit 号或验收证据,且**完成后回原条目处补记「已完成 + commit」**——不得只在别处口头称完成。**禁止把「未验证」表述成「已修好」**。
- 🔴 **提交一律逐文件 `git add <显式路径>`(2026-10-05 实测事故)**:**禁止**在「同一目录下有多路在途切片」时用 `git add -A -- <目录>` —— 实测它会**把别的子代理的在途文件一起吞进提交**(本次误把 237 的 `sessionenv.go` 提交并推了)。修复套路:`git reset --soft HEAD~1` → `git restore --staged <误入文件>` → 重新 commit → `git push --force-with-lease origin dev`(工作树内容不丢)。 - 🔴 **提交一律逐文件 `git add <显式路径>`(2026-10-05 实测事故)**:**禁止**在「同一目录下有多路在途切片」时用 `git add -A -- <目录>` —— 实测它会**把别的子代理的在途文件一起吞进提交**(本次误把 237 的 `sessionenv.go` 提交并推了)。修复套路:`git reset --soft HEAD~1` → `git restore --staged <误入文件>` → 重新 commit → `git push --force-with-lease origin dev`(工作树内容不丢)。
+239 -33
View File
@@ -1,12 +1,12 @@
# Cyrene v2 客户端 API 契约 # Cyrene v2 客户端 API 契约
> **契约版本:`v0.90`**(上一版 `v0.89`,其条目见 §6;`v0.88` / `v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。 > **契约版本:`v0.96`**(上一版 `v0.95`,其条目见 §6;`v0.89` / `v0.88` / `v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。 > **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。 > **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。 > **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
> **维护铁律**:每实现/变更一个 REST 端点、WS 事件、admin API **立即改本文档**;契约变更必须版本化,不破坏既有客户端。 > **维护铁律**:每实现/变更一个 REST 端点、WS 事件、admin API **立即改本文档**;契约变更必须版本化,不破坏既有客户端。
> **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§7](#7-在飞切片与未实现未确证)。 > **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§7](#7-在飞切片与未实现未确证)。
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」已由 v0.79 落地**(切片 L45:DTO `voice` + `GET /api/v1/media/{name}` + 副本落盘;残留缺口——WS 实时帧/握手回放不带 `voice`、AMR 不落副本(**后者已由 v0.80 修复**)——见 §4.5 / §7.2 / §7.4-29)。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。 > ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」已由 v0.79 落地**(切片 L45:DTO `voice` + `GET /api/v1/media/{name}` + 副本落盘;残留缺口——WS 实时帧/握手回放不带 `voice`、AMR 不落副本——**两者均已修复**:前者已由 v0.97(切片 M44)、后者已由 v0.80(切片 W4)——见 §4.5 / §7.2 / §7.4-29)。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
> **v0.68 是纯增量版(已实现,非契约先行)**:新增 1 条管理只读端点 `GET /api/v1/admin/config-facts`(配置事实面,见 §2.2 B12),并给 `GET /api/v1/admin/backthink` 的条目加 2 个可选字段(`urgency` / `quietHoursBypassed`,见 §2.2 B8)。**无破坏性变更**,老客户端零影响。 > **v0.68 是纯增量版(已实现,非契约先行)**:新增 1 条管理只读端点 `GET /api/v1/admin/config-facts`(配置事实面,见 §2.2 B12),并给 `GET /api/v1/admin/backthink` 的条目加 2 个可选字段(`urgency` / `quietHoursBypassed`,见 §2.2 B8)。**无破坏性变更**,老客户端零影响。
> **v0.69 是缺口修复版(已实现)**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它(§2.2 A5 / §2.3 C28 注)。修的是「面板保存一次渠道配置,就把主人手配的**关闭流控(-1)**静默重置回 `0` = 保护性默认 6 条/分」。**无破坏性变更**(老客户端不带该键,行为与 v0.68 相同)。 > **v0.69 是缺口修复版(已实现)**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它(§2.2 A5 / §2.3 C28 注)。修的是「面板保存一次渠道配置,就把主人手配的**关闭流控(-1)**静默重置回 `0` = 保护性默认 6 条/分」。**无破坏性变更**(老客户端不带该键,行为与 v0.68 相同)。
> **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。 > **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。
@@ -20,7 +20,7 @@
> **v0.76 是三面增量版(⚠️ **逐条状态不同**:**① 已在 v0.78 实现、覆盖率又由 v0.79 补齐**;**②③ 已实现**)**:① **消息读取面新增 `toolCalls[]`(工具回看面)—— ✅ 已实现(v0.78,切片 CODE-TC),覆盖率已于 v0.79 补齐至全部编排回合**:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`),每条含工具名 / 状态(`ok`/`error`/`unknown`)/ 耗时毫秒 / **已脱敏**的入参与结果摘要;数据源是**已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`;**v0.78 加迁移 `0023` 的按路查询键**,保留期 168h),**不从正文反解**。🔴 **缺席语义**:图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库 / 该路标识查不到图)⇒ 字段**整体缺席**,**绝不回空数组**冒充「这一轮没调过工具」。⚠️ **历史说明(v0.76 登记时未实现的原因,实测确认非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** ⇒ 两者无法按消息字面 join,按时间窗猜映射项目口径不做;补齐需先让链路图带上**按路的回合标识**、并补一个**按路查图**的读侧入口。**v0.78 已把这两件都补上** ⇒ `toolCalls[]` 已实现;🔴 **v0.78 时只有真并行路能回看**(普通单路回合的图当时不带权威 `turnId`)—— **已由 v0.79(切片 CODE-TC-2)补齐**(见 **§4.4、§7.2、§7.4-23 与 §6 的 v0.78 ① / v0.79 ④**)。② **未读面(✅ 已实现)** —— `message_persisted` 的 `data` 加**可选** `unreadCount`(`omitempty`:只在 **>0 且确实算得出来**时出现;**缺席 = 老行为**),`GET /api/v1/sessions` 列表项同键(该键**自 D4 起就已在**,本版未改它)。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`)⇒ 多端共享**(Web 读了移动端也清),这是与安卓侧已对齐的**取舍**,不是缺陷。详见 **§3.4.4、§4.13**。③ **记忆整理试算加性异步** —— `POST /api/v1/admin/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`;新增 `GET /api/v1/admin/memories/tidy/dry-run/{runId}` 轮询;**不带 `async` 仍是原同步行为**。**无破坏性变更**(新键 / 新可选参数 / 新端点;老客户端零影响)。详见 §1(会话列表与消息读取)、§2.2 B15 / §4.12.1、§3.4.4。 > **v0.76 是三面增量版(⚠️ **逐条状态不同**:**① 已在 v0.78 实现、覆盖率又由 v0.79 补齐**;**②③ 已实现**)**:① **消息读取面新增 `toolCalls[]`(工具回看面)—— ✅ 已实现(v0.78,切片 CODE-TC),覆盖率已于 v0.79 补齐至全部编排回合**:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`),每条含工具名 / 状态(`ok`/`error`/`unknown`)/ 耗时毫秒 / **已脱敏**的入参与结果摘要;数据源是**已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`;**v0.78 加迁移 `0023` 的按路查询键**,保留期 168h),**不从正文反解**。🔴 **缺席语义**:图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库 / 该路标识查不到图)⇒ 字段**整体缺席**,**绝不回空数组**冒充「这一轮没调过工具」。⚠️ **历史说明(v0.76 登记时未实现的原因,实测确认非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** ⇒ 两者无法按消息字面 join,按时间窗猜映射项目口径不做;补齐需先让链路图带上**按路的回合标识**、并补一个**按路查图**的读侧入口。**v0.78 已把这两件都补上** ⇒ `toolCalls[]` 已实现;🔴 **v0.78 时只有真并行路能回看**(普通单路回合的图当时不带权威 `turnId`)—— **已由 v0.79(切片 CODE-TC-2)补齐**(见 **§4.4、§7.2、§7.4-23 与 §6 的 v0.78 ① / v0.79 ④**)。② **未读面(✅ 已实现)** —— `message_persisted` 的 `data` 加**可选** `unreadCount`(`omitempty`:只在 **>0 且确实算得出来**时出现;**缺席 = 老行为**),`GET /api/v1/sessions` 列表项同键(该键**自 D4 起就已在**,本版未改它)。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`)⇒ 多端共享**(Web 读了移动端也清),这是与安卓侧已对齐的**取舍**,不是缺陷。详见 **§3.4.4、§4.13**。③ **记忆整理试算加性异步** —— `POST /api/v1/admin/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`;新增 `GET /api/v1/admin/memories/tidy/dry-run/{runId}` 轮询;**不带 `async` 仍是原同步行为**。**无破坏性变更**(新键 / 新可选参数 / 新端点;老客户端零影响)。详见 §1(会话列表与消息读取)、§2.2 B15 / §4.12.1、§3.4.4。
> **v0.77 是链路图按路分桶 + 记忆整理护栏/异步落地版(已实现;纯增量)**:① **`graph_update` 按路分桶**(切片 FV2)—— 帧 `data` 新增可选 `turnId`(该图的**路**的权威回合标识)与 `round`(会话轮次号);真并行(`parallel:true`)时同一会话**每一路各发自己的帧**(`data.id` / `data.turnId` 各不相同),两路不再互相顶掉;`GET /api/v1/admin/flow/graph` 响应新增 `lanes[]`(**最近一轮每一路**的图,恒为非 null 数组),`graph` 语义不变(最新一路 / 指定轮 / `null` 诚实空态)⇒ **老的读取方零改动**。**无破坏性变更**(两个新键可选;单路时 `turnId` 缺席 ⇒ 客户端行为与 v0.75 逐字相同)。详见 **§2.2 B13 / §3.4.5 / §4.11 / §7.4-19**。② **记忆整理护栏抬高 + 诚实截断 + 试算异步落地**(切片 MT-A)—— 每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认值 `20 → 200`、两两比对上限改为 env 键新键 `MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`,原先**写死** `400`);配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(墙钟时间预算截断);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询可用;新错误码 **`tidy_run_not_found`**)。详见 **§2.2 B14/B15 / §4.10(a) / §4.12 / §4.12.1 / §5**。③ **「入库前审查」的 4 个 env 键登记**(切片 MT-B,纯增量)—— `MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`);⚠️ 该切片的**面板读侧 / 回滚端点目前没有 HTTP 入口**(见 §7.2)。详见 **§5**。 > **v0.77 是链路图按路分桶 + 记忆整理护栏/异步落地版(已实现;纯增量)**:① **`graph_update` 按路分桶**(切片 FV2)—— 帧 `data` 新增可选 `turnId`(该图的**路**的权威回合标识)与 `round`(会话轮次号);真并行(`parallel:true`)时同一会话**每一路各发自己的帧**(`data.id` / `data.turnId` 各不相同),两路不再互相顶掉;`GET /api/v1/admin/flow/graph` 响应新增 `lanes[]`(**最近一轮每一路**的图,恒为非 null 数组),`graph` 语义不变(最新一路 / 指定轮 / `null` 诚实空态)⇒ **老的读取方零改动**。**无破坏性变更**(两个新键可选;单路时 `turnId` 缺席 ⇒ 客户端行为与 v0.75 逐字相同)。详见 **§2.2 B13 / §3.4.5 / §4.11 / §7.4-19**。② **记忆整理护栏抬高 + 诚实截断 + 试算异步落地**(切片 MT-A)—— 每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认值 `20 → 200`、两两比对上限改为 env 键新键 `MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`,原先**写死** `400`);配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(墙钟时间预算截断);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询可用;新错误码 **`tidy_run_not_found`**)。详见 **§2.2 B14/B15 / §4.10(a) / §4.12 / §4.12.1 / §5**。③ **「入库前审查」的 4 个 env 键登记**(切片 MT-B,纯增量)—— `MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`,⚠️ **该默认值已由 v0.93 改为 `90`** —— 当时登记的值是准确的,见 §6 v0.93);⚠️ 该切片的**面板读侧 / 回滚端点目前没有 HTTP 入口**(见 §7.2)。详见 **§5**。
> **v0.86 是「搜索结果条数上限可配」版(已实现并推库 `bff57bd` + `c68387a`;**纯增量:面板多一个 number 字段 + 两个动作各多一个键;无破坏性变更**)**:`web-search` 的「结果条数上限」从**改不了**变成**面板上真能改**(此前三处断点:字段表里没这个字段 / `saveProvider` 只并四个键 / `mergeSelectionPatch` 只处理 `primary`+`fallback` ⇒ **手工塞进 `values` 也会被静默丢弃**,「我改了它却没变」成立)。① **面板声明**新增字段:`pluginId=web-search` / `panel=providers` / form `add` 多一个 **`maxResults`**(`type:"number"`, `min:1`, 标签「结果条数上限(默认 10)」)—— 前端本就渲染 `number` ⇒ **客户端零改动**。② **`plugin.saveProvider` 的 `values` 新增可写键 `maxResults`**:**三态** —— 缺位/null ⇒ 不改动;**正整数**(含整数字符串)⇒ 采用;`0` / 负数 / 小数 / 空串 / 非数 / 布尔 / 数组 ⇒ **`400 invalid_payload`**,且**盘上一字节不动**(**绝不静默忽略**)。③ 响应新增回显:`plugin.saveProvider` 的 `data.result.maxResults`(**0 = 文件里未声明**)、`plugin.reauthProvider` 的 `values.maxResults`(盘上有值才出现 ⇒ 用于「重配凭据」回填,否则一保存就把上限清掉)。④ **语义**:面板文件的 `maxResults` **压过** `.env` 的 `WEB_SEARCH_MAX_RESULTS`,是**运行期真上限**(模型的 `count` 只能更小),**保存即生效**(走既有热重载,响应里的 `hotReload`);代码默认值 **5 → 10**(三处口径已一致:env 默认 / io 包 `defaultMaxResults` / 面板文件)。⑤ **计数不变**(无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`)。⚠️ **已知取舍(如实)**:**清空输入框 = `400`**(后端按「非数 ⇒ 报错」拒)⇒ 面板上**没有「去掉上限」的手势**(要回到 `.env` 兜底得改文件);**小数/负数一律拒**;文件里没写该键时 `reauth` **不回填**(避免回填 0 让保存整笔进不去),因此那个框是空的、看不出当前生效值。 > **v0.86 是「搜索结果条数上限可配」版(已实现并推库 `bff57bd` + `c68387a`;**纯增量:面板多一个 number 字段 + 两个动作各多一个键;无破坏性变更**)**:`web-search` 的「结果条数上限」从**改不了**变成**面板上真能改**(此前三处断点:字段表里没这个字段 / `saveProvider` 只并四个键 / `mergeSelectionPatch` 只处理 `primary`+`fallback` ⇒ **手工塞进 `values` 也会被静默丢弃**,「我改了它却没变」成立)。① **面板声明**新增字段:`pluginId=web-search` / `panel=providers` / form `add` 多一个 **`maxResults`**(`type:"number"`, `min:1`, 标签「结果条数上限(默认 10)」)—— 前端本就渲染 `number` ⇒ **客户端零改动**。② **`plugin.saveProvider` 的 `values` 新增可写键 `maxResults`**:**三态** —— 缺位/null ⇒ 不改动;**正整数**(含整数字符串)⇒ 采用;`0` / 负数 / 小数 / 空串 / 非数 / 布尔 / 数组 ⇒ **`400 invalid_payload`**,且**盘上一字节不动**(**绝不静默忽略**)。③ 响应新增回显:`plugin.saveProvider` 的 `data.result.maxResults`(**0 = 文件里未声明**)、`plugin.reauthProvider` 的 `values.maxResults`(盘上有值才出现 ⇒ 用于「重配凭据」回填,否则一保存就把上限清掉)。④ **语义**:面板文件的 `maxResults` **压过** `.env` 的 `WEB_SEARCH_MAX_RESULTS`,是**运行期真上限**(模型的 `count` 只能更小),**保存即生效**(走既有热重载,响应里的 `hotReload`);代码默认值 **5 → 10**(三处口径已一致:env 默认 / io 包 `defaultMaxResults` / 面板文件)。⑤ **计数不变**(无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`)。⚠️ **已知取舍(如实)**:**清空输入框 = `400`**(后端按「非数 ⇒ 报错」拒)⇒ 面板上**没有「去掉上限」的手势**(要回到 `.env` 兜底得改文件);**小数/负数一律拒**;文件里没写该键时 `reauth` **不回填**(避免回填 0 让保存整笔进不去),因此那个框是空的、看不出当前生效值。
> **v0.87 是「搜索 provider 扩到八家」版(已实现,纯增量;⚠️ 两家的成功响应都未真机实测)**:新增两家已登记接入位 —— **`tencent_wsa`**(腾讯云·元宝搜索,`POST {base}/SearchPro` + `Authorization: Bearer`,**零签名**;🔴 **HTTP 状态码恒 200** ⇒ 判失败**只能**读响应体 `Response.Error.Code`;`Pages` 是**JSON 字符串数组** ⇒ 需二次解析)与 **`metaso`**(秘塔搜索,`POST {base}/api/v1/search`;⚠️ 响应 schema 全部来自第三方来源且互相打架 ⇒ 实现走**宽松解析、未实测**)。对客户端的**唯一影响是两处名单**:`plugin.saveProvider` 的 `values.provider` 合法闭集(**六家 → 八家**)与 `plugin.providers` 的候选(多两条 `{value,label}`,中文名见 §6 本版条目)—— **端点形状 / 错误码 / 三态写回语义 / §2.0 计数(`14 + 25 + 40 = 79`)全部一字未改**,且前端候选本就是 `optionsFrom` 数据驱动 ⇒ **客户端零改动**。**零新配置键**:两家只走 `$DATA_DIR/config/plugins/web-search.json`,`.env` / `.env.example` **未动**。 > **v0.87 是「搜索 provider 扩到八家」版(已实现,纯增量;⚠️ 两家的成功响应都未真机实测)**:新增两家已登记接入位 —— **`tencent_wsa`**(腾讯云·元宝搜索,`POST {base}/SearchPro` + `Authorization: Bearer`,**零签名**;🔴 **HTTP 状态码恒 200** ⇒ 判失败**只能**读响应体 `Response.Error.Code`;`Pages` 是**JSON 字符串数组** ⇒ 需二次解析)与 **`metaso`**(秘塔搜索,`POST {base}/api/v1/search`;⚠️ 响应 schema 全部来自第三方来源且互相打架 ⇒ 实现走**宽松解析、未实测**)。对客户端的**唯一影响是两处名单**:`plugin.saveProvider` 的 `values.provider` 合法闭集(**六家 → 八家**)与 `plugin.providers` 的候选(多两条 `{value,label}`,中文名见 §6 本版条目)—— **端点形状 / 错误码 / 三态写回语义 / §2.0 计数(`14 + 25 + 40 = 79`)全部一字未改**,且前端候选本就是 `optionsFrom` 数据驱动 ⇒ **客户端零改动**。**零新配置键**:两家只走 `$DATA_DIR/config/plugins/web-search.json`,`.env` / `.env.example` **未动**。
@@ -37,9 +37,9 @@
> **v0.80 是三批增量版(均**已实现**,**纯增量、无破坏性变更**)**:① **链路图时间轴出口**(切片 W1,`design/19 §7`)—— 新增 1 条管理只读端点 **`GET /api/v1/admin/flow/timeline`**(§2.2 B18 / §4.11.1),把「链路图」从「**最近一轮** + 已落盘轮次的**键列表**」扩到**按时间正序的多轮时间轴**(每轮含该轮**每一路**),支持 `limit`(缺省 **50** / 硬上限 **200**)与 `before` 游标向**更早**翻页;响应另给 `retentionHours` / `retentionEnabled` / `truncated`,供面板**如实**标注「更早的轮次已过保留期」。修的是主人 2026-10-06 的原话「web 后台的全链路流程图我要的是**所有消息按时间顺序串起来**的流程图,而不是只有最新一个来回」。老端点 `GET /api/v1/admin/flow/graph`(B13)**逐字未改**(老客户端零改动)。② **搜索 provider 热重载 + 主 provider / 降级链**(切片 W3;裁决 2026-10-06 #1 / #2)—— `plugin.saveProvider` 成功落盘后,组合根**按落盘后的配置重建 web-search 工具并原子替换能力注册表里的条目**(`registry.ReplaceTool`,保留运行期策略)⇒ **下一轮对话即用新配置、不需要重启**;若这次改动会让 web-search **没有可用后端**(下次启动会 fail-fast)⇒ **文件按字节回滚** + **`409 config_unstartable`**。同一次提交可带两个**文件级**字段 `primary` / `fallback[]`(缺位 = 不改动);响应新增机器可读事实 **`hotReload:{applied,registered}`**;新增错误码 **`primary_not_configured`(400)** / **`config_unstartable`(409)**。🔴 **本版把三处已成假话的旧口径改正**(§4.14(b)(c) / §7.2)—— 此前写的「面板保存后**不**热替换运行中的工具、需重启」与「面板**无**主 provider / 降级链字段」**都不再成立**。⚠️ **未确证(如实)**:真机面板上「新增/改一家 provider → 下一次搜索真的走它」的端到端、以及**真机探针打真实上游**,都没跑。③ **入站语音 AMR 也可回放**(切片 W4;裁决 #11)—— 组合根补齐 `channelbridge.Options.VoiceTranscoder` 注入(`cmd/cyrene/wire_channels.go`)⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`,见 `internal/runtime/voice/playable.go`),客户端从此能拿到可回放的字节;此前走 `category=transcode_not_wired` 的诚实 WARN、**不落副本**。**形状零变化**(DTO `voice` 的键集合与语义一字未改;可播格式仍**原样落、一次转码都不发生**)。🔴 **同批改正 §4.5 / §7.1 / §7.2 / §7.4 里已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**(`transcode_not_wired` 从此只剩「引擎未装配 / 构造失败」一档;旧版本横幅保留当时状态,另加「已由 v0.80 修复」注)。⚠️ **未确证(如实)**:③ 的**生产端到端未跑**(红线:不重启后端)⇒ 本期证据是 `channelbridge` 的行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**。详见 **§6**。 > **v0.80 是三批增量版(均**已实现**,**纯增量、无破坏性变更**)**:① **链路图时间轴出口**(切片 W1,`design/19 §7`)—— 新增 1 条管理只读端点 **`GET /api/v1/admin/flow/timeline`**(§2.2 B18 / §4.11.1),把「链路图」从「**最近一轮** + 已落盘轮次的**键列表**」扩到**按时间正序的多轮时间轴**(每轮含该轮**每一路**),支持 `limit`(缺省 **50** / 硬上限 **200**)与 `before` 游标向**更早**翻页;响应另给 `retentionHours` / `retentionEnabled` / `truncated`,供面板**如实**标注「更早的轮次已过保留期」。修的是主人 2026-10-06 的原话「web 后台的全链路流程图我要的是**所有消息按时间顺序串起来**的流程图,而不是只有最新一个来回」。老端点 `GET /api/v1/admin/flow/graph`(B13)**逐字未改**(老客户端零改动)。② **搜索 provider 热重载 + 主 provider / 降级链**(切片 W3;裁决 2026-10-06 #1 / #2)—— `plugin.saveProvider` 成功落盘后,组合根**按落盘后的配置重建 web-search 工具并原子替换能力注册表里的条目**(`registry.ReplaceTool`,保留运行期策略)⇒ **下一轮对话即用新配置、不需要重启**;若这次改动会让 web-search **没有可用后端**(下次启动会 fail-fast)⇒ **文件按字节回滚** + **`409 config_unstartable`**。同一次提交可带两个**文件级**字段 `primary` / `fallback[]`(缺位 = 不改动);响应新增机器可读事实 **`hotReload:{applied,registered}`**;新增错误码 **`primary_not_configured`(400)** / **`config_unstartable`(409)**。🔴 **本版把三处已成假话的旧口径改正**(§4.14(b)(c) / §7.2)—— 此前写的「面板保存后**不**热替换运行中的工具、需重启」与「面板**无**主 provider / 降级链字段」**都不再成立**。⚠️ **未确证(如实)**:真机面板上「新增/改一家 provider → 下一次搜索真的走它」的端到端、以及**真机探针打真实上游**,都没跑。③ **入站语音 AMR 也可回放**(切片 W4;裁决 #11)—— 组合根补齐 `channelbridge.Options.VoiceTranscoder` 注入(`cmd/cyrene/wire_channels.go`)⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`,见 `internal/runtime/voice/playable.go`),客户端从此能拿到可回放的字节;此前走 `category=transcode_not_wired` 的诚实 WARN、**不落副本**。**形状零变化**(DTO `voice` 的键集合与语义一字未改;可播格式仍**原样落、一次转码都不发生**)。🔴 **同批改正 §4.5 / §7.1 / §7.2 / §7.4 里已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**(`transcode_not_wired` 从此只剩「引擎未装配 / 构造失败」一档;旧版本横幅保留当时状态,另加「已由 v0.80 修复」注)。⚠️ **未确证(如实)**:③ 的**生产端到端未跑**(红线:不重启后端)⇒ 本期证据是 `channelbridge` 的行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**。详见 **§6**。
> **v0.79 是「面板动作真可用 + 记忆审查/语音用户可见面 + 链路图回合标识全覆盖」版(四批,均**已实现**;**唯一一处形状变化**)**:① **插件面板动作真可用**(切片 L1/PLG-2 的执行面接线)—— `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501**:web-search 的四个动作 + 下拉候选来源(`plugin.providers`)都已登记**生产处理器**,**真读写** `$DATA_DIR/config/plugins/web-search.json`、探针**真发一次 HTTP**;失败回**结构化非 2xx**(错误码/文案/状态由插件侧给出,宿主只透传),新增 `invalid_payload` / `unknown_provider` / `not_configured` / `config_unreadable` / `config_write_failed` 与一批探针码(502);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。审计新增附加字段 `code`(**不进 DB 记录体、不含请求体**)。详见 **§2.1 A14 / §4.1 / §4.14 / §7.2**。② **「入库前审查」(D3)的管理面落地**(切片 L45)—— 新增 2 条 `writeAuth` 端点 **`GET /api/v1/admin/memories/reviews?limit=`**(留痕列表,**不含记忆正文**;`limit` 缺省 100 / `≤0` 同缺省 / >500 钳位 / 非整数 `400`)与 **`POST /api/v1/admin/memories/{id}/revert`**(撤销一次软合并,**无损可逆**,落审计 `memory.review.revert`);未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕)。详见 **§2.2 B16/B17 / §4.1**。③ **语音消息的用户可见面落地**(切片 L45)—— 消息 DTO 的 **`voice`** 现在真的填充(REST 读取面 + `message_edited`),新增 **`GET /api/v1/media/{name}`**(200/206/304/416/`HEAD`;`audio/*` 内联 + `nosniff` + `Accept-Ranges`;**无 `ETag`**;名字非法 / 穿越 / 缺失统一 `404 media_not_found`),副本真落 `$DATA_DIR`(`documents.SaveAudio`,内容寻址、0600)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`**;组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **AMR 这一项已由 v0.80(切片 W4)修复**。详见 **§1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5 / §7.2**。④ 🔴 **链路图的权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2)—— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)的图现在都带权威 `turnId`,取值 = 该轮消息行的 `chat_messages.turn_id` ⇒ `GET /sessions/{id}/messages` 的 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)⇒ 客户端**一律使用响应里给出的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍整体缺席;**v0.79 之前的单路历史行不回填**。详见 **§4.4 / §4.11 / §7.2 / §7.4-23**。**无破坏性变更**(新端点 / 新可选键 / 新错误码;老客户端若不自己拼 `graph.id` 则零改动)。 > **v0.79 是「面板动作真可用 + 记忆审查/语音用户可见面 + 链路图回合标识全覆盖」版(四批,均**已实现**;**唯一一处形状变化**)**:① **插件面板动作真可用**(切片 L1/PLG-2 的执行面接线)—— `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501**:web-search 的四个动作 + 下拉候选来源(`plugin.providers`)都已登记**生产处理器**,**真读写** `$DATA_DIR/config/plugins/web-search.json`、探针**真发一次 HTTP**;失败回**结构化非 2xx**(错误码/文案/状态由插件侧给出,宿主只透传),新增 `invalid_payload` / `unknown_provider` / `not_configured` / `config_unreadable` / `config_write_failed` 与一批探针码(502);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。审计新增附加字段 `code`(**不进 DB 记录体、不含请求体**)。详见 **§2.1 A14 / §4.1 / §4.14 / §7.2**。② **「入库前审查」(D3)的管理面落地**(切片 L45)—— 新增 2 条 `writeAuth` 端点 **`GET /api/v1/admin/memories/reviews?limit=`**(留痕列表,**不含记忆正文**;`limit` 缺省 100 / `≤0` 同缺省 / >500 钳位 / 非整数 `400`)与 **`POST /api/v1/admin/memories/{id}/revert`**(撤销一次软合并,**无损可逆**,落审计 `memory.review.revert`);未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕)。详见 **§2.2 B16/B17 / §4.1**。③ **语音消息的用户可见面落地**(切片 L45)—— 消息 DTO 的 **`voice`** 现在真的填充(REST 读取面 + `message_edited`),新增 **`GET /api/v1/media/{name}`**(200/206/304/416/`HEAD`;`audio/*` 内联 + `nosniff` + `Accept-Ranges`;**无 `ETag`**;名字非法 / 穿越 / 缺失统一 `404 media_not_found`),副本真落 `$DATA_DIR`(`documents.SaveAudio`,内容寻址、0600)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`** —— ✅ **该缺口已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **AMR 这一项已由 v0.80(切片 W4)修复**。详见 **§1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5 / §7.2**。④ 🔴 **链路图的权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2)—— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)的图现在都带权威 `turnId`,取值 = 该轮消息行的 `chat_messages.turn_id` ⇒ `GET /sessions/{id}/messages` 的 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)⇒ 客户端**一律使用响应里给出的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍整体缺席;**v0.79 之前的单路历史行不回填**。详见 **§4.4 / §4.11 / §7.2 / §7.4-23**。**无破坏性变更**(新端点 / 新可选键 / 新错误码;老客户端若不自己拼 `graph.id` 则零改动)。
> **v0.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面**已由 v0.79 补齐**);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **v0.78 时覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)—— **已由 v0.79 补齐**(见 §6 v0.79 ④)。详见 **§4.4 / §7.2 / §7.4-23**。② **插件管理面板接入点(管理面新增两条路由 + 一条 WS 帧)**(切片 PLG-2)—— 新增 `GET /api/v1/admin/panels`(**按调用者角色过滤**后的接入点快照,viewer 可读)与 `POST /api/v1/admin/plugins/{id}/actions/{action}`(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 `writeAuth` + 审计);WS 下行帧 **`panels_changed`**(插件生命周期变化后推,**只发 owner/admin**)。⚠️ **动作执行器在 v0.78 无生产实现 ⇒ 当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。详见 **§2.1 A13/A14 / §3.4.5 / §4.14**。③ **搜索 provider 可变源**(切片 PLG-4)—— 插件 `web-search` 的配置从 `.env` 迁到 `$DATA_DIR/config/plugins/web-search.json`(0600,文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 四个动作。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面)。详见 **§4.14(c)**。④ **账号上下线落事件 + 渠道事件实时帧**(切片 E7)—— 账号 online/offline 迁移落进 `internal/runtime/events`(复用迁移 `0021`,`tier=t2` / `category=lifecycle`),并经**既有进程内 bus** 推成 WS 下行帧 **`channel_event`**(**只发 owner/admin**,viewer 走 REST 只读档)。⚠️ **当前只覆盖账号状态迁移这一路**;覆盖其余全部渠道事件的装饰器**未接线**(见 §7.2)。详见 **§3.4.5**。⑤ **微信 iLink 账号状态三态**(切片 CODE-F3)—— `GET /api/v1/admin/channels/{platform}/login-status` 新增可选 `authState` / `authDetail`(**有效 / 被拒 / 未知**);🔴 **`errcode=-14` 是「被拒」不是「过期」**,**未知同样不许写成「过期」**。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。详见 **§2.4(c)**。⑥ **语音消息落库面**(切片 CODE-VOICE)—— 迁移 `0020` 给 `chat_messages` 加 `voice` 列(`TEXT NOT NULL DEFAULT ''`);**用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);残留缺口(WS 实时帧 / 握手回放不带 `voice`;AMR 那一项**已由 v0.80 修复**)见 **§4.5 / §7.2**。**无破坏性变更**(新端点 / 新可选键 / 新帧;老客户端零影响)。 > **v0.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面**已由 v0.79 补齐**);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **v0.78 时覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)—— **已由 v0.79 补齐**(见 §6 v0.79 ④)。详见 **§4.4 / §7.2 / §7.4-23**。② **插件管理面板接入点(管理面新增两条路由 + 一条 WS 帧)**(切片 PLG-2)—— 新增 `GET /api/v1/admin/panels`(**按调用者角色过滤**后的接入点快照,viewer 可读)与 `POST /api/v1/admin/plugins/{id}/actions/{action}`(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 `writeAuth` + 审计);WS 下行帧 **`panels_changed`**(插件生命周期变化后推,**只发 owner/admin**)。⚠️ **动作执行器在 v0.78 无生产实现 ⇒ 当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。详见 **§2.1 A13/A14 / §3.4.5 / §4.14**。③ **搜索 provider 可变源**(切片 PLG-4)—— 插件 `web-search` 的配置从 `.env` 迁到 `$DATA_DIR/config/plugins/web-search.json`(0600,文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 四个动作。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面)。详见 **§4.14(c)**。④ **账号上下线落事件 + 渠道事件实时帧**(切片 E7)—— 账号 online/offline 迁移落进 `internal/runtime/events`(复用迁移 `0021`,`tier=t2` / `category=lifecycle`),并经**既有进程内 bus** 推成 WS 下行帧 **`channel_event`**(**只发 owner/admin**,viewer 走 REST 只读档)。⚠️ **当前只覆盖账号状态迁移这一路**;覆盖其余全部渠道事件的装饰器**未接线**(见 §7.2)。详见 **§3.4.5**。⑤ **微信 iLink 账号状态三态**(切片 CODE-F3)—— `GET /api/v1/admin/channels/{platform}/login-status` 新增可选 `authState` / `authDetail`(**有效 / 被拒 / 未知**);🔴 **`errcode=-14` 是「被拒」不是「过期」**,**未知同样不许写成「过期」**。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。详见 **§2.4(c)**。⑥ **语音消息落库面**(切片 CODE-VOICE)—— 迁移 `0020` 给 `chat_messages` 加 `voice` 列(`TEXT NOT NULL DEFAULT ''`);**用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);残留缺口(WS 实时帧 / 握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 那一项**已由 v0.80 修复**)见 **§4.5 / §7.2**。**无破坏性变更**(新端点 / 新可选键 / 新帧;老客户端零影响)。
> **v0.75 是记忆整理试算版(已实现,纯增量)**:新增 1 条管理端点 **`POST /api/v1/admin/memories/tidy/dry-run`**(**只读语义的 POST**,`writeAuth` = owner/admin)—— 跑**同一套**记忆整理规则(召回门 + 包含判定 + 规则否决,与低峰定时轮共用代码路径),把候选**直接回在响应里**,**一行都不写**(不写 `memory_tidy_candidates` / `memory_tidy_runs`,绝不改 `memory_items`)。修的是「整理器只在 23:00–07:00 跑(design/23 §4),在它第一次开跑前面板只能空着」——主人要「现在就想看到在 736 条上它会建议合并哪些」。**零副作用是硬约束**(后端有逐行快照单测钉死);失败**一律如实作答**(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout`),**绝不回空候选数组假装「没有建议」**。**无破坏性变更**(新端点;既有端点/帧一字未改)。详见 **§2.2 B14 / §4.12**。 > **v0.75 是记忆整理试算版(已实现,纯增量)**:新增 1 条管理端点 **`POST /api/v1/admin/memories/tidy/dry-run`**(**只读语义的 POST**,`writeAuth` = owner/admin)—— 跑**同一套**记忆整理规则(召回门 + 包含判定 + 规则否决,与低峰定时轮共用代码路径),把候选**直接回在响应里**,**一行都不写**(不写 `memory_tidy_candidates` / `memory_tidy_runs`,绝不改 `memory_items`)。修的是「整理器只在 23:00–07:00 跑(design/23 §4),在它第一次开跑前面板只能空着」——主人要「现在就想看到在 736 条上它会建议合并哪些」。**零副作用是硬约束**(后端有逐行快照单测钉死);失败**一律如实作答**(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout`),**绝不回空候选数组假装「没有建议」**。**无破坏性变更**(新端点;既有端点/帧一字未改)。详见 **§2.2 B14 / §4.12**。
@@ -123,7 +123,7 @@
| U3 | GET | `/api/v1/personas` | 任意已认证 | 200 | 角色目录 `{data:[{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}]}`。`mood` 为空 = 该角色暂无被跟踪情绪(不伪造)。**不含** admin 专属 `thinking` 旋钮。 | | U3 | GET | `/api/v1/personas` | 任意已认证 | 200 | 角色目录 `{data:[{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}]}`。`mood` 为空 = 该角色暂无被跟踪情绪(不伪造)。**不含** admin 专属 `thinking` 旋钮。 |
| U4 | GET | `/api/v1/meta/enums` | 任意已认证 | 200 | 共享枚举权威表 `{data:{platforms,moods,modes,unknown}}`;每项 `{id,label}`。`unknown` 恒 `"display-raw"`。**未知取值原样显示,不要丢弃/报错**(服务端新增取值是非破坏性变更)。详见 [§4.7](#47-枚举权威表)。 | | U4 | GET | `/api/v1/meta/enums` | 任意已认证 | 200 | 共享枚举权威表 `{data:{platforms,moods,modes,unknown}}`;每项 `{id,label}`。`unknown` 恒 `"display-raw"`。**未知取值原样显示,不要丢弃/报错**(服务端新增取值是非破坏性变更)。详见 [§4.7](#47-枚举权威表)。 |
| U5 | POST | `/api/v1/audio/transcribe` | **owner** | 200 / 400 / 500 | STT。body `{audio,format?,deviceId?}` → `{data:{text, provider?}}`。`provider` 只在结果为空/降级时出现(如 `"silent"`)。空 audio → `400`;未接语音 → `500 voice_unavailable`;识别失败 → `500 stt_failed`。**owner-only + 审计**。 | | U5 | POST | `/api/v1/audio/transcribe` | **owner** | 200 / 400 / 500 | STT。body `{audio,format?,deviceId?}` → `{data:{text, provider?}}`。`provider` 只在结果为空/降级时出现(如 `"silent"`)。空 audio → `400`;未接语音 → `500 voice_unavailable`;识别失败 → `500 stt_failed`。**owner-only + 审计**。 |
| U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text,voice?,format?,instruction?}` → **`200` 原始音频字节**(`Content-Type: audio/<format>`,**非 JSON 信封**)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 | | U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text, voice?, format?, instruction?, sessionId?}` → **`200` 原始音频字节**(**非 JSON 信封**)。🔴 **鉴权 = 既有双凭据门(v0.97 明示)**:`Authorization: Bearer <owner JWT>` **或** `X-API-Key: <已签发 key>`,两分支同 `authorize(RoleOwner)`——Bearer 走完整 JWT 校验 + owner-only(**不因「带了 Bearer」就放行**);X-API-Key 一律 owner 级,未知/吊销 key 一律 `401` 且**不回落** Bearer(fail-closed);key 表未注入时只认 Bearer。无凭据/无效凭据 `401`,错误形状同 §4.1 信封。🔴 **v0.96 起发音人按 `sessionId` 的生效人格解析**(会话绑定 → 全局默认人格 → 空 ⇒ 配置的 `default`),**省略 `sessionId` 不等于没有角色**;请求体 `voice` 接受但不生效;产物恒 WAV,实际格式按字节嗅探(见 §6 v0.96)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;人格无映射 → `500 tts_persona_unmapped`;连生效人格都没有且没配 default → `500 tts_no_character`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 |
| U7 | POST | `/api/v1/audio/voice-clone` | **owner** | 200 / 400 / 500 | 声音复刻建音色。body `{url,prefix?}` → `{data:{voiceId}}`。参考 URL **绝不进日志/审计**。空 url → `400`;失败 → `500 voice_clone_failed`。 | | U7 | POST | `/api/v1/audio/voice-clone` | **owner** | 200 / 400 / 500 | 声音复刻建音色。body `{url,prefix?}` → `{data:{voiceId}}`。参考 URL **绝不进日志/审计**。空 url → `400`;失败 → `500 voice_clone_failed`。 |
| U8 | POST | `/api/v1/upload` | owner/admin | 201 / 400 / 413 / 415 / 500 | **图片上传**,`multipart/form-data` 字段 `file`(光栅图,**单文件字节 ≤ 5 MiB**,即 `maxUploadBytes = 5 << 20`)→ `{data:{url,size,mime,name}}`。`url` 是**绝对同源 URL**,可直接作 `chat.image`(该路径的 5 MiB 在**上传时**定死,与 `chat.image` 的字符长度判据无关,见 [§4.5](#45-图片--文档--语音链路))。嗅探裁决 png/jpeg/gif/webp/bmp(拒 SVG/HTML);原始文件名丢弃,用 128-bit 随机名。缺文件/空文件 `400`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`。**带审计**。 | | U8 | POST | `/api/v1/upload` | owner/admin | 201 / 400 / 413 / 415 / 500 | **图片上传**,`multipart/form-data` 字段 `file`(光栅图,**单文件字节 ≤ 5 MiB**,即 `maxUploadBytes = 5 << 20`)→ `{data:{url,size,mime,name}}`。`url` 是**绝对同源 URL**,可直接作 `chat.image`(该路径的 5 MiB 在**上传时**定死,与 `chat.image` 的字符长度判据无关,见 [§4.5](#45-图片--文档--语音链路))。嗅探裁决 png/jpeg/gif/webp/bmp(拒 SVG/HTML);原始文件名丢弃,用 128-bit 随机名。缺文件/空文件 `400`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`。**带审计**。 |
| U9 | GET | `/api/v1/files/{name}` | **无需**(能力 URL) | 200 / 404 | 上传文件读取。`name` 仅接受「32 位小写 hex + 白名单扩展名」;路径穿越全形态拒绝。**GET 不鉴权**是该 URL 为 128-bit 随机能力地址(`<img>`/provider 无法带 token);任何持有 URL 者可取,勿外泄。 | | U9 | GET | `/api/v1/files/{name}` | **无需**(能力 URL) | 200 / 404 | 上传文件读取。`name` 仅接受「32 位小写 hex + 白名单扩展名」;路径穿越全形态拒绝。**GET 不鉴权**是该 URL 为 128-bit 随机能力地址(`<img>`/provider 无法带 token);任何持有 URL 者可取,勿外泄。 |
@@ -155,11 +155,15 @@
| 档位 | 中间件 | 条数 | 说明 | | 档位 | 中间件 | 条数 | 说明 |
|---|:--:|:--:|---| |---|:--:|:--:|---|
| **只读监控档** | `viewerAuth`(owner/admin/viewer) | **14** | 非敏感投影:无凭据、无他人账号数据、无消息正文。**v0.78 新增两条**:面板接入点快照(A13)与插件动作分发(A14,**写门落在 handler 内**,见 A14 行注) | | **只读监控档** | `viewerAuth`(owner/admin/viewer) | **14** | 非敏感投影:无凭据、无他人账号数据、无消息正文。**v0.78 新增两条**:面板接入点快照(A13)与插件动作分发(A14,**写门落在 handler 内**,见 A14 行注) |
| **owner/admin 读档** | `writeAuth`(owner/admin) | **25** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71)、记忆整理试算(v0.75)、入库前审查留痕与回滚(**v0.79**,B16/B17)、链路图时间轴(**v0.80**,B18)、自排闸只读快照(**v0.82**,B19)、记忆整理应用面五条(**v0.83**,B20–B24)、**按动作批量拒绝候选(v0.85,B25)** | | **owner/admin 读档** | `writeAuth`(owner/admin) | **31** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71)、记忆整理试算(v0.75)、入库前审查留痕与回滚(**v0.79**,B16/B17)、链路图时间轴(**v0.80**,B18)、自排闸只读快照(**v0.82**,B19)、记忆整理应用面五条(**v0.83**,B20–B24)、**按动作批量拒绝候选(v0.85,B25)**、**记忆整理调优键读写(v0.92,B26/B27)**、**渠道非消息事件与静默窗口四条(v0.95 补登,B28–B31)** |
| **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 | | **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 |
| **合计** | — | **79** | `14 + 25 + 40`(v0.85) | | **合计** | — | **85** | `14 + 31 + 40`(v0.95) |
**管理路由总数 = 14 + 25 + 40 = 79 条。**(v0.85 起;`14 + 24 + 40 = 78` 是 v0.84 的读数) **管理路由总数 = 14 + 31 + 40 = 85 条(`routes.go` 逐条枚举;补齐 B28–B31 后与代码逐条相等)。**
> 🔴 **本节此前的「18 + 43 + 43 = 104」是一个口径错误,已改正**(2026-10-26 由切片 CONTRACTMATRIX 逐条复算):那三个数**不是本表三段的分组计数**,而是**整张表按中间件用量全文统计**的结果 —— `writeAuth` 全文 **43** = 本表 **31** + 用户面 12(`chat` / `upload` / `documents` / `media/{name}` / `history` 等);`ownerAuth` 全文 **43** = 本表 **40** + `audio/*` **3**;而「`viewerAuth` 18」**根本不是 viewerAuth**(它全文就是 **14**,且全部是本表路由),18 是「用户面被中间件包住」的条数(12 + 3 + 3)。把 18 与两个 43 相加 ⇒ 用户面那 18 条被**重复计入一次**。正确口径:被四种中间件包住的路由 = **85(管理)+ 18(用户面)= 103**,另有 6 条未包(4 条公开 + `GET /api/v1/files/{name}` + `/ws/chat`),字面量注册合计 109,再加 `mountWebhooks` 一条动态注册 ⇒ `grep -c` 得 110。
> ⚠️ 同理,此前「差 23 条遗漏行」也是巧合:**23 恰好是用户面路由总数**(§1.1 的 P1–P4 共 4 条 + §1.2 的 U1–U19 共 19 条)—— 用户面本来就一条不缺。真实缺口是 **4 条**(全在 `writeAuth`),已补为 B28–B31。
> ✅ **v0.94(MCP 管理页 + 按上游分节的 `origin`)与 v0.95(coalesce 两端点 + 窗口语义)的形状级条目已登记**,见 §6 v0.94 / v0.95。
> ✅ **本表现与 `routes.go` 逐条相等**(A14 / B31 / C40 = 85),此前的「已知文档债」警告已失效、已删除。反向也核过:81 行旧条目里**没有一条**是幽灵行,且**零条分档错误**。⚠️ 唯一一处**文档已自行标注**的差异是 B17(代码 `writeAuth`,`design/23 §11` 对同族标 owner-only)—— 那是对**设计文档**的差异,不在本节范围。
> **v0.78 计数变更**:`viewerAuth` 段新增两条(A13 `GET /admin/panels`、A14 `POST /admin/plugins/{id}/actions/{action}`)⇒ 12 → **14**、总数 66 → **68**。A14 走 `viewerAuth` 中间件,但**写动作在 handler 内按 owner/admin 收紧**(路由中间件只能有一道门)—— 因此它**是**本表的一行,读写面却不同档,详见 §2.1 A14 与 §4.14。 > **v0.78 计数变更**:`viewerAuth` 段新增两条(A13 `GET /admin/panels`、A14 `POST /admin/plugins/{id}/actions/{action}`)⇒ 12 → **14**、总数 66 → **68**。A14 走 `viewerAuth` 中间件,但**写动作在 handler 内按 owner/admin 收紧**(路由中间件只能有一道门)—— 因此它**是**本表的一行,读写面却不同档,详见 §2.1 A14 与 §4.14。
@@ -199,7 +203,7 @@
> **A13 / A14 的角色门依据**(`routes.go` 紧邻注释):快照端点对 viewer 开放是因为它回的**本来就是被裁掉写面的只读投影**;动作端点的读来源动作对 viewer 可见,**写门落在 handler 内**(路由中间件只能有一道门)。`panels_changed` 帧(§3.4.5)**不进** `frameReachesAll` 白名单 ⇒ **viewer 收不到该帧**,需自行刷新/轮询重拉 A13。 > **A13 / A14 的角色门依据**(`routes.go` 紧邻注释):快照端点对 viewer 开放是因为它回的**本来就是被裁掉写面的只读投影**;动作端点的读来源动作对 viewer 可见,**写门落在 handler 内**(路由中间件只能有一道门)。`panels_changed` 帧(§3.4.5)**不进** `frameReachesAll` 白名单 ⇒ **viewer 收不到该帧**,需自行刷新/轮询重拉 A13。
### 2.2 owner/admin 读(19 条,`writeAuth`) ### 2.2 owner/admin 读(31 条,`writeAuth`)
| # | 方法 | 路径 | 要点 | | # | 方法 | 路径 | 要点 |
|---|---|---|---| |---|---|---|---|
@@ -230,6 +234,13 @@
| B24 | POST | `/api/v1/admin/memories/tidy/run` | **手动真跑一轮整理(v0.83 新增,切片 TIDY-1)** —— 不等 23:00 的 panic button:按**同一套规则与配置**立刻跑一轮,**写候选 + 写运行留痕**,**不自动应用**(人工在环,能落地的只有 B21/B22)。**无请求体**。`200 {data:{runId,trigger:"manual",ruleVersion,durationMs,partitions,scanned,compared,pairs,vetoed,errors,truncated[],vetoReasons{},candidatesCreated,candidatesDuplicate,candidatesByAction{}}}`。🔴 **如实计数**:`candidatesCreated` = 本次**新建**候选数、`candidatesDuplicate` = 被**幂等键**挡下的重复数(同一批数据重复跑 ⇒ 全进 duplicate,**不是失败**);`errors` 非 0 面板必须显示;`truncated[]` 是**被护栏截断**的分区标签(扫描/比对/候选上限,或墙钟预算 `[budget]`)。失败:配置关闭 → `409 tidy_disabled`;超时 → `503 tidy_timeout`(**已跑部分仍写入了留痕**);只接线了试算(没接真跑)→ `500 tidy_run_unavailable`;未接线 → `500 tidy_unavailable`;失败 → `500 tidy_run_failed`。**审计不在这里写** —— 由领域侧记一条 `memory.tidy.run`(`trigger=manual` + run_id + 计数),**一次点击一条**。语义见 **§4.15(b)** | | B24 | POST | `/api/v1/admin/memories/tidy/run` | **手动真跑一轮整理(v0.83 新增,切片 TIDY-1)** —— 不等 23:00 的 panic button:按**同一套规则与配置**立刻跑一轮,**写候选 + 写运行留痕**,**不自动应用**(人工在环,能落地的只有 B21/B22)。**无请求体**。`200 {data:{runId,trigger:"manual",ruleVersion,durationMs,partitions,scanned,compared,pairs,vetoed,errors,truncated[],vetoReasons{},candidatesCreated,candidatesDuplicate,candidatesByAction{}}}`。🔴 **如实计数**:`candidatesCreated` = 本次**新建**候选数、`candidatesDuplicate` = 被**幂等键**挡下的重复数(同一批数据重复跑 ⇒ 全进 duplicate,**不是失败**);`errors` 非 0 面板必须显示;`truncated[]` 是**被护栏截断**的分区标签(扫描/比对/候选上限,或墙钟预算 `[budget]`)。失败:配置关闭 → `409 tidy_disabled`;超时 → `503 tidy_timeout`(**已跑部分仍写入了留痕**);只接线了试算(没接真跑)→ `500 tidy_run_unavailable`;未接线 → `500 tidy_unavailable`;失败 → `500 tidy_run_failed`。**审计不在这里写** —— 由领域侧记一条 `memory.tidy.run`(`trigger=manual` + run_id + 计数),**一次点击一条**。语义见 **§4.15(b)** |
| B25 | POST | `/api/v1/admin/memories/candidates/reject?action=&limit=` | **按动作批量拒绝整理候选(v0.85 新增,切片 TIDY-AUTO)** —— 把某一类 `pending` 候选一次性标成 `rejected`(**幂等**:同一调用再来一次 ⇒ `total:0`;**只改候选 status,绝不碰记忆行**)。`action` **必填**、**闭集** `merge\|link\|archive\|reclassify\|keep`(未知/空 → `400 bad_request`);`limit` 缺省 **1000**、硬顶 **10000**、非整数 → `400 bad_request`。`200 {data:{action,limit,total,rejected,truncated}}`。🔴 **两个计数口径不同**:`total` = 命中筛选的**真**条数,`rejected` 只覆盖 `limit` 之内 ⇒ 不等时 **`truncated:true`**(客户端必须如实显示)。**落审计** `memory.tidy.reject`(只带计数)。用法见 **§4.15(e)**(`link` 停产后的存量清理就靠它)。⚠️ **不是「删除」**:候选行仍在,只是不再待办 | | B25 | POST | `/api/v1/admin/memories/candidates/reject?action=&limit=` | **按动作批量拒绝整理候选(v0.85 新增,切片 TIDY-AUTO)** —— 把某一类 `pending` 候选一次性标成 `rejected`(**幂等**:同一调用再来一次 ⇒ `total:0`;**只改候选 status,绝不碰记忆行**)。`action` **必填**、**闭集** `merge\|link\|archive\|reclassify\|keep`(未知/空 → `400 bad_request`);`limit` 缺省 **1000**、硬顶 **10000**、非整数 → `400 bad_request`。`200 {data:{action,limit,total,rejected,truncated}}`。🔴 **两个计数口径不同**:`total` = 命中筛选的**真**条数,`rejected` 只覆盖 `limit` 之内 ⇒ 不等时 **`truncated:true`**(客户端必须如实显示)。**落审计** `memory.tidy.reject`(只带计数)。用法见 **§4.15(e)**(`link` 停产后的存量清理就靠它)。⚠️ **不是「删除」**:候选行仍在,只是不再待办 |
| B26 | GET | `/api/v1/admin/memories/tidy/tuning` | **记忆整理调优键读取(v0.92 新增,切片 M4)**,只读、**不审计**。**无参数**。`200 {data:{path,present,hot,hotError?,specs[],values{},envValues{}}}`。🔴 **三块分工(客户端按此编程)**:`specs[]` = **取值规则唯一真源**(`{key,kind,unit?,min?,max?,fallback}`;`fallback` 只给**来源** `env:<键名>` | `panel:<键名>` | `code-default`,**不给数值**);`values` = **盘上**的值,**键缺位 ⇒ 该键在对象里不出现**(= 跟随兜底),**不是 0**;`envValues` = 键缺位时**下一档(`.env`)**的进程生效值(连 `.env` 档也没有 ⇒ 该键不出现;网关绝不 fopen `.env`)。管 7 键:`intervalSec`/`quietIntervalSec`/`quietStartHour`/`quietEndHour`/`ingestGate`/`autoApply`/`tempTtlSec`;同文件不受管的键(`t1Judge` 等)不回显。🔴 **`hot` 恒为 false** —— 读不改变任何东西,这是**如实占位**,别读成能力声明。失败:数据目录未接线 → `500 tuning_unavailable`。语义见 **§4.17** |
| B27 | PUT | `/api/v1/admin/memories/tidy/tuning` | **记忆整理调优键写入(v0.92 新增,切片 M4)**。门同 B26(`writeAuth`)。请求体 = **扁平 JSON 对象**,只放要改的键;值 int(秒/钟点)或 bool;**`null` = 清除该键**(回到下一档兜底)。🔴 **三态(硬要求,逐字)**:键**缺位** ⇒ 不改动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节不动**;**未知键(含拼错)也归「非法」,绝不静默忽略**。`200` 与 B26 **同形状**,但值是**写完重新读盘**的真值(带 `hot`/`hotError`)。错误码:`400 invalid_payload`(值非法/未知键/体不是对象/体是 `null`)|`409 tuning_corrupt`(盘上那份运行期读不走 ⇒ **拒绝覆盖**)|`500 tuning_unavailable`|`500 tuning_write_failed`(落盘失败,盘上未改动)。**两条语义**:**值一个都没变时不写盘**(连 mtime 都不动);同文件**不受管的键原样保留**。🔴 **`hot` 诚实口径**:`hot:true` = 值已叠到运行期(整理档会重排心跳,否则新周期要等旧周期那一觉睡完);`hot:false` = **已落盘但运行期没接上**,`hotError` 给原因,面板文案必须是「**重启后端后生效**」—— **绝不把「落盘了」说成「生效了」**。**落审计** `memory.tidy.tuning`(`result` ∈ `ok\|error`)。语义见 **§4.17** |
| B28 | GET | `/api/v1/admin/channels/events` | **渠道非消息事件列表(倒序)**,只读、**不审计**。此前只登记在 §2.4(a) D1,本条是它的矩阵行。query:`platform`(闭集 `qq`/`kook`/`telegram`/`weixin-ilink`/`webhook`,未知 ⇒ `400`,**绝不降级成「不过滤」**)/ `tier`(`t1\|t2\|t3`,其它 ⇒ `400`)/ `category`(🔴 **不做白名单**:类别轴的权威在适配器层且会持续增加,未知类别是一次**精确匹配**(返回空),不是 `400`)/ `channelId` / `since` / `until`(RFC3339 或 Unix 毫秒;跨度 > **90 天** ⇒ `400`;**两个都不给就不注入任何时间条件** —— 端点绝不替客户端造下界)/ `limit`(`≤0` 或缺省 ⇒ 存储层默认 **100**;> **500** 钳到 500 **不报错**;非整数 ⇒ `400`)/ `offset`(非法整数 ⇒ `400`,负值归 0)。响应 `{data:{events:[…],total}}`,`events` **恒为 `[]` 不是 `null`**。元素键见 §2.4(a)。**不含正文、不含媒体 URL、不含凭据**。未接线或读库失败 ⇒ `500 channel_events_unavailable`(**绝不回全 0 假汇总**)。⚠️ `weixin-ilink` 事实上无此类事件 ⇒ 对它查询恒为空,**不得用别平台事件顶替** |
| B29 | GET | `/api/v1/admin/channels/events/counters` | **渠道事件逐类计数(近似值)**,只读、**不审计**。此前只登记在 §2.4(a) D2。query 仅 `platform`(空 = 全部平台;未知 ⇒ `400`,与 B28 同一把校验)。响应 `{data:{approx:true,counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}` —— `approx` 与 `counters` **同级**。🔴 **`approx` 恒为 `true` 且不是装饰**:计数是**内存聚合 + 每 30s flush**,读到的表**不含待 flush 部分** ⇒ 只少不多、是个**下界**;面板必须如实标注,别画成精确账。未接线或读库失败 ⇒ `500 channel_events_unavailable` |
| B30 | GET | `/api/v1/admin/channels/coalesce` | **渠道静默窗口合并的读(v0.95)**,只读、**不审计**。此前只登记在 §6 v0.95,本条是它的矩阵行。**无参数**。`200 {data:{path,present,hot,default{…六键…},patch{},conversations{},rules{…},ownerPrivate:"never"}}`。🔴 **三块分工(客户端按此编程)**:`default` = **生效值**(代码默认 + 盘上 `default` 段,**已 clamp**);`patch` = **盘上原值**,**键缺位 ⇒ 该键不出现**(= 跟随默认,**不是 0**);`conversations` 的键是 `platform\|channel`(可读形态,NUL 已换成 `\|`),值是**生效值**。`hot` 恒为 `true`(桥每次发车前重读同一个 store ⇒ **这是事实,不是承诺**)。🔴 `ownerPrivate` 恒为 `"never"` = **只读事实**:主人私聊的车道类型由 actor 元数据判定,**没有任何请求键能翻转它**,面板只显示一行说明、不给开关。取值区间:`windowMs` **300–15000**、`maxWaitMs` 下界**跟随窗口**且 ≤ **30000**、`maxBatchMessages` **1–50**;越界是 **clamp + WARN,不是 `400`**。`path` 是 `$DATA_DIR/config/coalesce.json` 的落点(**零新 `.env` 键**)。未接线 ⇒ `500 coalesce_unavailable` |
| B31 | PUT | `/api/v1/admin/channels/coalesce` | **渠道静默窗口合并的写(v0.95)**,门同 B30(`writeAuth` = owner/admin)。请求体 `{default?:{enabled?,windowMs?,maxWaitMs?,maxBatchMessages?,applyToStrangers?,strangerWindowMs?}, conversations?:{"<platform>\|<channel>": {…} 或 null}}`。🔴 **三态(硬要求)**:键**缺位** ⇒ 不动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节都不动**(校验全部发生在落盘**之前**,是结构性的、不靠回滚)。**严格解码**:未知键(含拼错)与「一个以上的 JSON 对象」都归 `400 invalid_payload`,**绝不静默忽略**;**负数**同样 `400 invalid_payload`(越界的合法数字**不是** 400 —— 由运行期 clamp + WARN)。`default` 缺位 ⇒ 全局不动;`conversations` 里值为 `null` ⇒ **删掉该会话覆盖**(回到全局 default),没提到的会话一概不动。**值一个都没变时不写盘**(连 mtime 都不动)但仍回 `200`。`200` 响应与 B30 **同形状**,是**写完重新读盘**的真值。错误码:`400 invalid_payload`(未知键/负数/体形态错)|`400 bad_request`(读体失败)|`500 coalesce_unavailable`|`500 coalesce_write_failed`(落盘失败,**盘上未改动**)。**落审计** `channels.coalesce`(`result` ∈ `ok\|error`;⚠️ **空补丁也记一条 `ok`**) |
> **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。 > **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。
@@ -293,7 +304,7 @@
### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**;v0.78 追加 **(c) iLink 凭据三态**;v0.90 追加 **(d) 渠道连接态三态** ### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**;v0.78 追加 **(c) iLink 凭据三态**;v0.90 追加 **(d) 渠道连接态三态**
> ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数(12 + 11 + 40 = 63)原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档),下版改文档时应把它们并入 §2.1/§2.2 并更新计数。**本行留在此处仅为与 v0.67 冻结版对照**。 > ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档)。✅ **已并入矩阵(v0.95 补登)**:B28 = `GET …/channels/events`、B29 = `GET …/channels/events/counters`。🔴 **形状级真源从此以 B28/B29 为准**,下面 D1/D2 两行**保留仅作 v0.67 冻结版对照**,改端点时只改 B28/B29,别在四处一起改。
#### (a) 渠道事件(非消息事件)只读查询 —— **已实现** #### (a) 渠道事件(非消息事件)只读查询 —— **已实现**
@@ -364,7 +375,7 @@
{"type":"history","sessionId":"web","data":{"messages":[ <historyMessage> ... ]}} {"type":"history","sessionId":"web","data":{"messages":[ <historyMessage> ... ]}}
``` ```
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。🔴 **`voice` 不在握手回放里**(v0.67 登记时曾计划覆盖):v0.79 的 `voice` 只填在 4 个 REST 读取面 + `message_edited`,握手 `history` 回放**不带该键**(见 §6 v0.79 ③ / §4.4 / §7.2)。 - 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。✅ **`voice` 在握手回放里(v0.97 起,切片 M44)**:与 REST 读面同一收口点,形状与 §4.4 一致,非语音消息键缺席(v0.67~v0.96 期间曾长期缺席,那时的「不要在握手回放上等 `voice`」口径已作废)。
- **viewer 连接完成握手时完全不做回放**(其只读窗口是 REST 监控面)。 - **viewer 连接完成握手时完全不做回放**(其只读窗口是 REST 监控面)。
- 回放是尽力而为:存储未接或读出错静默跳过。 - 回放是尽力而为:存储未接或读出错静默跳过。
@@ -478,7 +489,7 @@
> **思维链的唯一出口是实时帧**:§3.4.2 的 `thinking.start` / `thinking.delta` / `thinking.end`(owner/admin 档)。客户端**不要**为「从历史里读回思维链」写任何代码。 > **思维链的唯一出口是实时帧**:§3.4.2 的 `thinking.start` / `thinking.delta` / `thinking.end`(owner/admin 档)。客户端**不要**为「从历史里读回思维链」写任何代码。
> ⚠️ 本行**更正**本文档 v0.65 及更早的写法(旧版称「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」)—— **那是文档写错了**,与实现不符。见 §7.3。 > ⚠️ 本行**更正**本文档 v0.65 及更早的写法(旧版称「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」)—— **那是文档写错了**,与实现不符。见 §7.3。
> 另注:管理面板人格清单里的 `thinking` 是**人格旋钮**(`GET /admin/personas` 的 `thinking` 块),与消息 DTO 的 `thinking` 不是同一个字段。 > 另注:管理面板人格清单里的 `thinking` 是**人格旋钮**(`GET /admin/personas` 的 `thinking` 块),与消息 DTO 的 `thinking` 不是同一个字段。
> **`voice` 覆盖同一批读取面(v0.67 登记;🔴 **v0.79 只落地了其中一部分**)**:消息 DTO 的可选 `voice` 对象**设计上**一次覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。🔴 **v0.79 实测覆盖**:只有 `message_edited`(与 §4.4 列出的 4 个 REST 读取面)会带它;**`message_persisted` / `channel_persisted` 与握手 `history` 回放仍不带 `voice`**(补齐需给 `history.Message` 加字段,**未做**)⇒ 客户端**不要在实时帧 / 握手回放上等 `voice`**。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的新可选键。 > **`voice` 覆盖同一批读取面(v0.67 登记;✅ **v0.79 落地 REST 面,v0.97 补齐 WS 面**)**:消息 DTO 的可选 `voice` 对象覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。🔴 **v0.97 起全部在读面上填充**:`message_edited` 与 4 个 REST 读取面自 v0.79 起;`message_persisted` / `channel_persisted` 帧与握手回放自 v0.97(切片 M44,落库顺序改为 INSERT→写列→notify,**首帧即带**)⇒ 客户端可以统一按「`voice` 键存在 = 语音消息」处理所有读面。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的可选键。
#### 3.4.5 子代理 / 任务 / 系统帧(owner+admin) #### 3.4.5 子代理 / 任务 / 系统帧(owner+admin)
@@ -648,7 +659,7 @@
- 落库形状 = DTO 形状**减去** `url`(列 `chat_messages.voice TEXT NOT NULL DEFAULT ''`,JSON **单对象**;**用空串不用 NULL**,与 `turn_id` 同一口径)。音频原件**复用文档那一套存储 / 元数据表 / 引用计数 / GC**(同一 `<DataDir>/uploads/documents` 目录),但**不进文档的抽取 / 注入链路**:不被抽取、不进 `documents` DTO 数组、不占注入预算、不出现在附件清单行里。 - 落库形状 = DTO 形状**减去** `url`(列 `chat_messages.voice TEXT NOT NULL DEFAULT ''`,JSON **单对象**;**用空串不用 NULL**,与 `turn_id` 同一口径)。音频原件**复用文档那一套存储 / 元数据表 / 引用计数 / GC**(同一 `<DataDir>/uploads/documents` 目录),但**不进文档的抽取 / 注入链路**:不被抽取、不进 `documents` DTO 数组、不占注入预算、不出现在附件清单行里。
- **一条消息只落一份音频副本**(= 被转写的那一条语音);同一条消息里的第 2..N 条语音既不转写也不落盘,DTO 里也没有它们(**刻意的边界**,不是遗漏)。 - **一条消息只落一份音频副本**(= 被转写的那一条语音);同一条消息里的第 2..N 条语音既不转写也不落盘,DTO 里也没有它们(**刻意的边界**,不是遗漏)。
- ⚠️ **微信(`weixin-ilink`)语音没有音频副本、不可回放**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `source:"platform"` 且**无 `name`/`mime`/`size`/`url`**,只保留 🎤 标记。 - ⚠️ **微信(`weixin-ilink`)语音没有音频副本、不可回放**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `source:"platform"` 且**无 `name`/`mime`/`size`/`url`**,只保留 🎤 标记。
- ✅ **覆盖率(v0.79,如实)**:`voice` 已在**这 4 个读取面**填充 —— `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的**响应与 `message_edited` 广播帧**。🔴 **尚未填充**:`message_persisted` / `channel_persisted`(`broadcast.go`)与握手 `history` 回放(`ws_history.go`)—— 需要给 `history.Message` 加 `voice` 字段(**未做**);且入站语音的 `voice` 列是**消息落库之后**回填的,`message_persisted` 那一帧在**落库那一刻**发出,即使加了字段也拿不到值。⇒ **客户端不要在实时帧上等 `voice`**(见 §7.2、§7.4-29)。 - ✅ **覆盖率(v0.97,如实)**:`voice` 已在**全部读面**填充 —— `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的**响应与 `message_edited` 广播帧**(v0.79 起),以及 **`message_persisted` / `channel_persisted` 帧与握手 `history` 回放(v0.97,切片 M44)**。首帧即带的保证来自落库顺序 **INSERT → 写 `voice` 列 → notify**(两条实现前提见 §6 v0.97);帧内值是**本帧发出那一刻**的即时列读(每帧 +1 次读列、亚毫秒同步)。
- ⚠️ **列被外部改坏时可见地缺席**:非法 JSON / 闭集破坏时**不静默当「不是语音」** —— 记一条带来源与消息 id(**不含正文**)的 WARN,并让该字段缺席。契约的 `voice` 没有「读不出」这一态,因此缺席是唯一诚实的表达(与 `documents` 回一条 `unreadable` 不同)。 - ⚠️ **列被外部改坏时可见地缺席**:非法 JSON / 闭集破坏时**不静默当「不是语音」** —— 记一条带来源与消息 id(**不含正文**)的 WARN,并让该字段缺席。契约的 `voice` 没有「读不出」这一态,因此缺席是唯一诚实的表达(与 `documents` 回一条 `unreadable` 不同)。
**`documents` 数组语义(核心)**: **`documents` 数组语义(核心)**:
@@ -702,10 +713,11 @@
- `/voice-clone`:`{url,prefix?}` → `{data:{voiceId}}`。⚠️ 真建音色需公网参考音频 URL(当前无,待主人提供)+ 北京地域 API Key。 - `/voice-clone`:`{url,prefix?}` → `{data:{voiceId}}`。⚠️ 真建音色需公网参考音频 URL(当前无,待主人提供)+ 北京地域 API Key。
- WS 语音回合(`voice_input`):**与文本回合走同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;`deviceId`/`clientMsgId` 与 `chat` 帧同形。 - WS 语音回合(`voice_input`):**与文本回合走同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;`deviceId`/`clientMsgId` 与 `chat` 帧同形。
- `voice_output` 在**该回合结算时**才定向回发起连接(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生)。空正文 / TTS 失败/降级都走 `provider` 降级信号(`silent` 等),**不把 `stopReason` 当语音内容**;连接断开不补投,客户端带回同一 `clientMsgId` 可安全重试。 - `voice_output` 在**该回合结算时**才定向回发起连接(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生)。空正文 / TTS 失败/降级都走 `provider` 降级信号(`silent` 等),**不把 `stopReason` 当语音内容**;连接断开不补投,客户端带回同一 `clientMsgId` 可安全重试。
- **emotionTTS 自启(v0.97,切片 M47,默认关)**:后端启动时探活本机 emotionTTS,未起则异步拉起(失败只 WARN 不阻塞启动)。开关 = `$DATA_DIR/config/voice-tts-launch.json` 的 `{"enabled": true}`(面板配置文件、零新 `.env` 键;`probeUrl` 空 = `http://127.0.0.1:9880`,且**单向兜底**取 `voice-tts.json` 的 `baseUrl`)。⚠️ 快速 restart 两次会拉起两个 TTS 进程(跨进程去重不生效,记档不修)。见 §6 v0.97。
**入站语音的音频副本与播放端点(v0.67 登记;✅ **v0.79 已实现**,切片 L45;残留缺口见下)**: **入站语音的音频副本与播放端点(v0.67 登记;✅ **v0.79 已实现**,切片 L45;残留缺口见下)**:
> ✅ **v0.79 落地进展(切片 L45,如实登记)**:**已实现** —— ① `documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 同字节去重 / 目录 0700、**文件 0600** / 元数据写同一张 `documents` 表、引用计数并入同一套 `documents.ref_count`);② `history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列的**读写两条路**)⇒ channelbridge 的入站副本链**端到端可落地**;③ **网关 DTO `voice`(含唯一派生字段 `url`)** 与 **`GET /api/v1/media/{name}`**(§1.2 U19 / 下表)。🔴 **残留缺口(如实,不美化)**:① ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入** `VoiceTranscoder`(`cmd/cyrene/wire_channels.go` 的 `channelbridge.Options`)⇒ **不可播格式(`amr`)也落副本**(落盘前先转 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档。⚠️ **生产端到端仍未跑**;**可播格式(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍原样落、不转码**;② **WS 实时帧 / 握手回放不带 `voice`**(见 §3.4.4 与 §4.4 覆盖率);③ **消息 / 会话被删除时音频副本的引用计数不被释放**(`refs.go` 的增减点只认 `document` 列)⇒ 副本 `ref_count` **偏高**(后果是**晚回收 / 不回收**,绝不早删);④ **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上 0600 副本 → 客户端取到字节」只有替身 + 临时库/临时目录级证据。 > ✅ **v0.79 落地进展(切片 L45,如实登记)**:**已实现** —— ① `documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 同字节去重 / 目录 0700、**文件 0600** / 元数据写同一张 `documents` 表、引用计数并入同一套 `documents.ref_count`);② `history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列的**读写两条路**)⇒ channelbridge 的入站副本链**端到端可落地**;③ **网关 DTO `voice`(含唯一派生字段 `url`)** 与 **`GET /api/v1/media/{name}`**(§1.2 U19 / 下表)。🔴 **残留缺口(如实,不美化)**:① ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入** `VoiceTranscoder`(`cmd/cyrene/wire_channels.go` 的 `channelbridge.Options`)⇒ **不可播格式(`amr`)也落副本**(落盘前先转 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档。⚠️ **生产端到端仍未跑**;**可播格式(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍原样落、不转码**;② **WS 实时帧 / 握手回放不带 `voice`**(✅ **已由 v0.97(切片 M44)修复**,见 §3.4.4 与 §4.4 覆盖率);③ **消息 / 会话被删除时音频副本的引用计数不被释放**(`refs.go` 的增减点只认 `document` 列)⇒ 副本 `ref_count` **偏高**(后果是**晚回收 / 不回收**,绝不早删);④ **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上 0600 副本 → 客户端取到字节」只有替身 + 临时库/临时目录级证据。
> **v0.78 已落地的基础件(切片 CODE-VOICE;v0.79 已在其上补齐用户可见面)**:① 迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''` 列(JSON 单对象,形状 = 下面的 DTO `voice` **减去 `url`**);② `internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);③ `channelbridge` 入站语音「归一(最前端)→ 闸门**之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC,但**不进文档抽取/注入链路**)。唯一已生效且**客户端可见**的既有变化:IM 语音在「音频格式不在可转写白名单内」时正文诚实提示由「…(需要先转码,当前未做)」改为「…(服务端只把白名单内的音频送语音识别),这条语音没有转写。」—— **形状不变**(正文仍是转写文本或一行诚实提示;客户端本就**不该**按正文文案分支)。 > **v0.78 已落地的基础件(切片 CODE-VOICE;v0.79 已在其上补齐用户可见面)**:① 迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''` 列(JSON 单对象,形状 = 下面的 DTO `voice` **减去 `url`**);② `internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);③ `channelbridge` 入站语音「归一(最前端)→ 闸门**之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC,但**不进文档抽取/注入链路**)。唯一已生效且**客户端可见**的既有变化:IM 语音在「音频格式不在可转写白名单内」时正文诚实提示由「…(需要先转码,当前未做)」改为「…(服务端只把白名单内的音频送语音识别),这条语音没有转写。」—— **形状不变**(正文仍是转写文本或一行诚实提示;客户端本就**不该**按正文文案分支)。
@@ -1064,7 +1076,7 @@
| 请求体 | **可为空**(端点不读 body)。没有参数:阈值/上限一律取**进程生效的既有配置**(`MEMORY_TIDY_RECALL_THRESHOLD` / `_SCAN_LIMIT` / `_CANDIDATE_LIMIT` / **`_COMPARE_LIMIT`(v0.77 新增)**),端点**自己不带任何旋钮** | | 请求体 | **可为空**(端点不读 body)。没有参数:阈值/上限一律取**进程生效的既有配置**(`MEMORY_TIDY_RECALL_THRESHOLD` / `_SCAN_LIMIT` / `_CANDIDATE_LIMIT` / **`_COMPARE_LIMIT`(v0.77 新增)**),端点**自己不带任何旋钮** |
| 响应体 | `{data:{dryRun,recallMode,durationMs,ruleVersion,partitions,scanned,compared,pairs,vetoed,vetoReasons,truncated,candidates[]}}` | | 响应体 | `{data:{dryRun,recallMode,durationMs,ruleVersion,partitions,scanned,compared,pairs,vetoed,vetoReasons,truncated,candidates[]}}` |
| `dryRun` | 恒为 `true`。它是**结构化**判据:这份响应**没有**改动任何记忆(客户端不必只靠本地硬编码文案说明这件事) | | `dryRun` | 恒为 `true`。它是**结构化**判据:这份响应**没有**改动任何记忆(客户端不必只靠本地硬编码文案说明这件事) |
| `recallMode` | `"embedding"`(语义向量召回)或 `"lexical"`(零依赖词项余弦,仅当进程没有接 embedder) | | `recallMode` | **如实上报本轮实际选用的召回路径**(不再是「配置态」):`"embedding"`(语义向量召回)|`"lexical"`(零依赖词项余弦)|`"mixed"`(**部分分区降级**、其余走向量)|`""`(**没有候选可比** ⇒ 本轮没做召回,不是降级、也不该显示成空)。⚠️ v0.91 起语义收紧:旧实现把「全量降级」静默报成 `embedding`,面板因此看不出降级 |
| `durationMs` | 本次试算的墙钟耗时(毫秒) | | `durationMs` | 本次试算的墙钟耗时(毫秒) |
| `ruleVersion` | 规则集版本(`internal/runtime/memory` 的 `TidyRuleVersion`,进候选幂等键;**改判定规则必须同时升它**) | | `ruleVersion` | 规则集版本(`internal/runtime/memory` 的 `TidyRuleVersion`,进候选幂等键;**改判定规则必须同时升它**) |
| 观测口径 | `partitions`(含在册条目的分区数)/ `scanned`(扫描条数)/ `compared`(**真正参与两两比对**的条数,受 **`MEMORY_TIDY_COMPARE_LIMIT`** 与**时间预算**两道闸约束 —— 因此 `compared < scanned` 时要看 `truncated` 是不是指出了原因)/ `pairs`(相似度 ≥ 召回门的对数)/ `vetoed` 与 `vetoReasons`(否决数与原因分布,如 `scope`/`persona`/`project`/`source`/`temporal`/`numeric`/`conflict`/`low_confidence`) | | 观测口径 | `partitions`(含在册条目的分区数)/ `scanned`(扫描条数)/ `compared`(**真正参与两两比对**的条数,受 **`MEMORY_TIDY_COMPARE_LIMIT`** 与**时间预算**两道闸约束 —— 因此 `compared < scanned` 时要看 `truncated` 是不是指出了原因)/ `pairs`(相似度 ≥ 召回门的对数)/ `vetoed` 与 `vetoReasons`(否决数与原因分布,如 `scope`/`persona`/`project`/`source`/`temporal`/`numeric`/`conflict`/`low_confidence`) |
@@ -1423,6 +1435,81 @@ curl -s -X POST -H "X-API-Key: $KEY" \
**一个保守处置(待设计裁决,生产库 0 例)**:`<p>A\n\n<p>B</p>`(未闭合段落之间夹正文)产出侧选择**合并** ⇒ `<p>A\n\nB</p>`,**不**做「插入式隐式收尾」(那要猜作者意图)。客户端不必为此适配。 **一个保守处置(待设计裁决,生产库 0 例)**:`<p>A\n\n<p>B</p>`(未闭合段落之间夹正文)产出侧选择**合并** ⇒ `<p>A\n\nB</p>`,**不**做「插入式隐式收尾」(那要猜作者意图)。客户端不必为此适配。
### 4.17 记忆整理调优键的读写面(B26 / B27;**v0.92 新增,切片 M4**)
| 项 | 值 |
|---|---|
| 方法 / 路径 | `GET` 与 `PUT` `/api/v1/admin/memories/tidy/tuning` |
| 鉴权档 | `writeAuth`(**owner / admin**;`viewer` → `403`,未认证 → `401`)。与同族 `GET /admin/memories`、`POST …/tidy/dry-run` 同档 |
| 审计 | GET **不审计**;PUT 落 **`memory.tidy.tuning`**(`result` ∈ `ok\|error`) |
| 落盘 | `$DATA_DIR/config/memory-tidy.json`(原子写:temp + rename;**值一个都没变时不写盘**) |
它救的是「这些键只能改文件 + 重启后端」——面板从此是配置的一等来源(`.env` 只做兜底)。
#### 管的 7 个键
| key | kind | unit | 取值 | 兜底来源 |
|---|---|---|---|---|
| `intervalSec` | int | sec | ≥ 1 | `env:MEMORY_TIDY_INTERVAL_SEC` |
| `quietIntervalSec` | int | sec | ≥ 1 | `panel:intervalSec` |
| `quietStartHour` | int | hour | 0–23 | `env:MEMORY_TIDY_QUIET_START_HOUR` |
| `quietEndHour` | int | hour | 0–23 | `env:MEMORY_TIDY_QUIET_END_HOUR` |
| `ingestGate` | bool | — | — | `code-default` |
| `autoApply` | bool | — | — | `code-default` |
| `tempTtlSec` | int | sec | ≥ 1 | `code-default` |
🔴 **`specs` 是取值规则唯一真源**:面板据此渲染与就地校验 ⇒ 不会出现两套规则。`fallback` **只给来源**(`env:<键名>` | `panel:<键名>` | `code-default`)、**不给数值** —— 数值真源仍在领域侧,网关不另抄一份默认值。
#### 响应(GET 与 PUT 的 200 同形状)
```json
{
"path": "config/memory-tidy.json",
"present": true,
"hot": false,
"hotError": "…(仅 hot=false 时出现)",
"specs": ["…见上表,每项 {key,kind,unit?,min?,max?,fallback}…"],
"values": {"intervalSec": 3600, "quietStartHour": 23, "quietEndHour": 7, "ingestGate": false},
"envValues": {"intervalSec": 86400, "quietStartHour": 23, "quietEndHour": 7}
}
```
🔴 **`values` 的缺位语义**:**键缺位 ⇒ 该键在对象里不出现**(= 跟随下一档兜底),**不是 0** ——「没配」与「配成 0」必须分得开。只回这 7 键;同文件里**不受管**的键(`t1Judge` / `t1PerWindow` / `autoApplyPerRound` 等)**不回显**,但 PUT 时**原样保留**。
🔴 **`envValues`**:键缺位时的**下一档(`.env`)进程生效值**(与 `/admin/config-facts` 同源 `s.cfg`,网关**绝不 fopen `.env`**);某键连 `.env` 档也没有值 ⇒ 该键不出现(例:`intervalSec ≤ 0`、安静时段两端都为 0 = 未填)。
#### 写侧三态(硬要求,逐字)
| 请求体里 | 结果 |
|---|---|
| 键**缺位** | 不改动 |
| **合法值** | 采用 |
| **非法值** | `400 invalid_payload`,且**盘上一个字节不动** |
| **未知键**(含拼错) | 归「非法」⇒ `400`,**绝不静默忽略** |
| 值 `null` | **清除该键**(回到下一档兜底) |
- 请求体 = **扁平 JSON 对象**(值类型 int 秒/钟点 或 bool)。
- **PUT 的 200 是「写完重新读盘」的真值**,不是回显请求体。
#### 错误码
| code | 何时 |
|---|---|
| `400 invalid_payload` | 值非法 / 未知键 / 体不是对象 / 体是 `null` |
| `409 tuning_corrupt` | 盘上那份**运行期读不走** ⇒ **拒绝覆盖**(文案说明此刻正回落 `.env`/代码默认) |
| `500 tuning_unavailable` | 数据目录未接线 |
| `500 tuning_write_failed` | 落盘失败(盘上未改动) |
#### 🔴 `hot` 的诚实口径(客户端按此渲染)
- `hot:true` = 值**已叠到运行期**:整理档会 `Stop→写→Start` **重排心跳**(否则新周期要等旧周期那一觉睡完 —— 主人把 24h 改成 3h 就「等于没改」);审查档重算 `ingestGate` / `tempTtlSec`。
- `hot:false` = **已落盘但运行期没接上**,`hotError` 给原因,面板文案必须是「已保存到面板配置文件,**重启后端后生效**」。
- **绝不把「落盘了」说成「生效了」。**
#### `recallMode` 的渲染(前端,承接 v0.91)
试算卡片(B14)的 `recallMode` 现在四态如实渲染:`embedding` ⇒「召回来源:语义向量」|`lexical` ⇒「召回来源:词项余弦(未接向量,已降级)」|`mixed` ⇒「召回来源:部分分区降级(其余走向量)」|`""` ⇒「召回来源:无候选可比(本轮未做召回)」——**不再渲染成空标签**。未登记取值 ⇒「…(未登记取值)」(不吞信息)。「尝试过但向量源不可用」走**错误路径**(`error.code === "tidy_recall_unavailable"`),面板额外渲染一句解释,**不套在别的错误码上**。
## 5. env 与模型配置(与契约相关) ## 5. env 与模型配置(与契约相关)
> **`.env` vs 面板文件**:面板「LLM 模型」页保存写 `<DATA_DIR>/models.json`(默认 `./data/models.json`);「渠道管理」页写 `<DATA_DIR>/channels.json`。**只要文件存在且有效,运行时优先用它**,`.env` 只作兜底/播种层。`.env.example` 与实现同步(键名逐字一致、值占位符)。 > **`.env` vs 面板文件**:面板「LLM 模型」页保存写 `<DATA_DIR>/models.json`(默认 `./data/models.json`);「渠道管理」页写 `<DATA_DIR>/channels.json`。**只要文件存在且有效,运行时优先用它**,`.env` 只作兜底/播种层。`.env.example` 与实现同步(键名逐字一致、值占位符)。
@@ -1451,7 +1538,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
| `DOCUMENT_MAX_INJECT_CHARS` | 空 → 预算 1/8 | 单份附件注入上限 | | `DOCUMENT_MAX_INJECT_CHARS` | 空 → 预算 1/8 | 单份附件注入上限 |
| `GUARDRAIL_MAX_INPUT_CHARS` / `GUARDRAIL_KEYWORDS` | 空 | 请求侧字符上限 / 关键词护栏 | | `GUARDRAIL_MAX_INPUT_CHARS` / `GUARDRAIL_KEYWORDS` | 空 | 请求侧字符上限 / 关键词护栏 |
| `VOICE_STT_TIMEOUT_SEC` | `20` | **只约束 IM 渠道入站语音转写**;Web 客户端 `voice_input` 不受它约束 | | `VOICE_STT_TIMEOUT_SEC` | `20` | **只约束 IM 渠道入站语音转写**;Web 客户端 `voice_input` 不受它约束 |
| `VOICE_TTS_VOICE_ID` / `VOICE_TTS_FORMAT` / `VOICE_TTS_MODEL` | 空 | `POST /audio/speech` 当前遵循的服务端配置(请求体 `voice`/`format` 为前向兼容接受但暂不生效) | | `VOICE_TTS_VOICE_ID` / `VOICE_TTS_FORMAT` / `VOICE_TTS_MODEL` | 空 | ⚠️ **v0.96 起不再是发音人的真源**:发音人由**生效人格 → `$DATA_DIR/config/voice-tts.json` 的 `personas` 映射**决定(面板文件,零新 `.env` 键);请求体 `voice`/`format` 仍为前向兼容接受但不生效,产物恒 WAV |
| `VOICE_TTS_INSTRUCTION` | 空 | 系统音色 `longxiaochun_v3` 固定格式、**不使用自由 instruction** | | `VOICE_TTS_INSTRUCTION` | 空 | 系统音色 `longxiaochun_v3` 固定格式、**不使用自由 instruction** |
| `LLM_PROVIDER` / `LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` / `LLM_FALLBACK_MODEL` / `LLM_REASONING_KIND` 等 | 见模板 | LLM provider 组的 `.env` 合成组兜底(有有效 `models.json` 时被其覆盖) | | `LLM_PROVIDER` / `LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` / `LLM_FALLBACK_MODEL` / `LLM_REASONING_KIND` 等 | 见模板 | LLM provider 组的 `.env` 合成组兜底(有有效 `models.json` 时被其覆盖) |
| `DOCUMENT_GC_INTERVAL` / `DOCUMENT_RETENTION` | `1h` / `168h` | 文档 GC(`0`/负 = 关闭 GC) | | `DOCUMENT_GC_INTERVAL` / `DOCUMENT_RETENTION` | `1h` / `168h` | 文档 GC(`0`/负 = 关闭 GC) |
@@ -1466,7 +1553,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
| `MEMORY_REVIEW_ENABLED` | `true` | **v0.77 登记**:记忆「入库前审查」(`design/23 §15.1 D3`,切片 MT-B)总开关。新记忆落库后由**独立的记忆管理会话**(复用 `sess-memorytidy` 装配)在有界后台队列里判定 `skip`/`merge`/`modify`/`create`,结论以**软合并**回写(`status` + `merged_into`/`merged_from`/`merge_count`,**可 revert、绝不改既有行 `content`**)。`false` ⇒ 组合根不建、不接,写入路径**逐字回到**改动之前。默认开启是因为判定是**纯规则、零模型调用**(T1 灰区模型判定**未实现**) | | `MEMORY_REVIEW_ENABLED` | `true` | **v0.77 登记**:记忆「入库前审查」(`design/23 §15.1 D3`,切片 MT-B)总开关。新记忆落库后由**独立的记忆管理会话**(复用 `sess-memorytidy` 装配)在有界后台队列里判定 `skip`/`merge`/`modify`/`create`,结论以**软合并**回写(`status` + `merged_into`/`merged_from`/`merge_count`,**可 revert、绝不改既有行 `content`**)。`false` ⇒ 组合根不建、不接,写入路径**逐字回到**改动之前。默认开启是因为判定是**纯规则、零模型调用**(T1 灰区模型判定**未实现**) |
| `MEMORY_REVIEW_QUEUE_SIZE` | `256` | **v0.77 登记**:有界审查队列长度。⚠️ **队列满 ⇒ 该条不做审查**(记忆本身早已落库,**绝不因队列满而丢记忆**),只记一条带来源的 WARN | | `MEMORY_REVIEW_QUEUE_SIZE` | `256` | **v0.77 登记**:有界审查队列长度。⚠️ **队列满 ⇒ 该条不做审查**(记忆本身早已落库,**绝不因队列满而丢记忆**),只记一条带来源的 WARN |
| `MEMORY_REVIEW_SCAN_LIMIT` | `200` | **v0.77 登记**:每条审查在同一分区内最多看多少条既有记忆(top-K 上限) | | `MEMORY_REVIEW_SCAN_LIMIT` | `200` | **v0.77 登记**:每条审查在同一分区内最多看多少条既有记忆(top-K 上限) |
| `MEMORY_REVIEW_TIMEOUT_SEC` | `20` | **v0.77 登记**:单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**(记忆照旧保留)+ WARN | | `MEMORY_REVIEW_TIMEOUT_SEC` | **`90`**(v0.93 起;v0.77–v0.92 为 `20`) | 单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**(记忆照旧保留)+ WARN。🔴 **默认值的唯一真源在 memory 包**(`internal/runtime/memory` 的 `defaultReviewTimeout`)—— 该 env 键**不设自带默认值**,未配置读出 `0` = 「没配」,由 `normalizeReviewConfig` 单点落地;**显式写正数才压过它**(不做「保底抬到 90」的加码)。⚠️ `20s` 已被实测证伪:批量 8 时一条完整召回链墙钟 **21.5–28.2s** ⇒ 20s 预算会让审查**整条失败且不写留痕**(见 §6 v0.93) |
| (`MEMORY_REVIEW_*` 的共用口径) | — | 上面四个键 **`≤0` 一律视为「用默认」**(由 memory 包 normalize 收敛,避免一个笔误让进程起不来);召回门**复用 `MEMORY_TIDY_RECALL_THRESHOLD`**,不另立键。⚠️ **本版只登记已实现的部分**:审查的**读侧 / 回滚目前没有 HTTP 端点**(见 §7.2) | | (`MEMORY_REVIEW_*` 的共用口径) | — | 上面四个键 **`≤0` 一律视为「用默认」**(由 memory 包 normalize 收敛,避免一个笔误让进程起不来);召回门**复用 `MEMORY_TIDY_RECALL_THRESHOLD`**,不另立键。⚠️ **本版只登记已实现的部分**:审查的**读侧 / 回滚目前没有 HTTP 端点**(见 §7.2) |
> **上面 13 个键(记忆整理 8 + 后台思考心跳 3 + 安静时段 2)自 v0.68 起可经 `GET /api/v1/admin/config-facts` 读到**「进程生效值 + 声明默认值」(3 个心跳键自 v0.75 起并入、`MEMORY_TIDY_COMPARE_LIMIT` 自 v0.77 起并入);在此之前面板没有任何 env 读取面。⚠️ `MEMORY_REVIEW_*` 四个键**不在**事实面里(那是「入库前审查」的另一组配置)。 > **上面 13 个键(记忆整理 8 + 后台思考心跳 3 + 安静时段 2)自 v0.68 起可经 `GET /api/v1/admin/config-facts` 读到**「进程生效值 + 声明默认值」(3 个心跳键自 v0.75 起并入、`MEMORY_TIDY_COMPARE_LIMIT` 自 v0.77 起并入);在此之前面板没有任何 env 读取面。⚠️ `MEMORY_REVIEW_*` 四个键**不在**事实面里(那是「入库前审查」的另一组配置)。
@@ -1496,7 +1583,126 @@ curl -s -X POST -H "X-API-Key: $KEY" \
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。 > 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。** > 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.90(当前契约版本) ### v0.97(当前契约版本)
**「语音帧补全 + 上游错误出口收敛」版(M44 / M47 / M49 / M50;以纯增量为主,一处行为变更如实标注)**
- **`voice` 进 WS 实时帧与握手回放(M44,纯增量)**:`message_persisted` / `channel_persisted` 帧内的 `message` 对象、以及握手 `history` 回放的每条消息,现在携带可选 `voice`(形状与语义与 §4.4 的 DTO **完全一致**,同一收口点 `attachVoiceTo → historyVoiceOf`;非语音消息整键缺席)。🔴 **首帧就带**:入站语音的落库顺序已改为 **INSERT → 写 `voice` 列 → notify** ⇒ 这条语音消息的**第一条** `message_persisted` / `channel_persisted` 帧就带 `voice`,客户端不用等回读。
- 🔴 **两条实现前提(新写入路径必须保持,否则首帧拿不到值)**:① `voice` 列的写入必须**先于** notify;② 帧内 `voice` 是**本帧发出那一刻**的即时列读(每帧 +1 次读列,被写列同步阻塞,亚毫秒级),不是消息结构体的缓存字段。
- ✅ **本版改正正文中已成假话的「WS 实时帧 / 握手回放不带 `voice` ⇒ 客户端不要在实时帧上等 `voice`」**(§3.2 / §4.4 / §7.2 / §7.4-29 及 v0.79 / v0.80 版本横幅已加注)。安卓端为此做的容忍逻辑(回读兜底)可以撤了。
- ⚠️ **不在本批范围**:`voice_output`(语音回合的定向回帧)形状未动;删除消息 / 会话时音频副本引用计数不释放的旧缺口仍在(见 §7.2)。
- **emotionTTS 自启(M47,默认关,运维面)**:后端启动时探活本机 emotionTTS,未起则异步拉起(`一键启动.bat`),**失败只 WARN 不阻塞启动**;探活在线则不重复拉起。
- 开关 = `$DATA_DIR/config/voice-tts-launch.json` 的 `{"enabled": true}`(**面板配置文件,零新 `.env` 键**);键:`enabled`(bool,**缺省/文件缺失 = 不启用**)、`probeUrl`(探活地址,空 = `http://127.0.0.1:9880`)。
- `probeUrl` 为空时**单向兜底**取 `voice-tts.json` 的 `baseUrl`(只降级不回写,方向唯一)。非法探活地址 = 不启用(宁可不自启,不拉错目标)。
- ⚠️ **已知限制(记档不修)**:快速 restart 两次会拉起两个 TTS 进程(跨进程去重不生效——防得住本进程并发,防不住上一进程拉起的孤儿)。
- **上游错误出口「构造即安全」(M49 / M50 / M48 / M52;🔴 一处行为变更)**:`trace.UpstreamError` 构造函数本身就是清洗边界——`error.code` / `error.type` **固定过令牌守卫**(≤64 字节、仅 `[A-Za-z0-9_.-]`,构造后注入走 `SetCode`/`SetType` 同一道守卫);**上游响应体的任何字节都不进错误**(只留长度 + sha256 前缀,仍能做相关性比对)。
- 🔴 **行为变更(如实)**:上游错误的 `error.message` **默认缺席**(此前部分出口会带上游自由文本)。要它必须调用点显式 opt-in(`WithUpstreamMessage`),且仍过凭据打码 + 128 rune 截断。理由:`{"error":{"message":"Invalid value: <用户原文>"}}` 这类回显真实存在,默认带 = 把她的原话进日志。分类排障用 `status/code/type/bodyLen/sha256` 足够。
- `trace.URLCause` 统一剥 `*url.Error` 的「方法 + 地址段」因果链(**层数上限 8**,环不会栈溢出);全仓 19 处 `%v` 直打已清零。URL 查询串里的凭据从此不出现在错误出口。
- **契约形状零变化**:REST / WS 错误信封(`{error:{code,message}}`)的形状、错误码集合都没动;这是**内容脱敏口径**的收敛,客户端按既有信封编程即可。
- **同批已核实、无契约面变化的内部项**:M45 `temporal` 列只观测不判定(`IngestRuleVersion=d3.5`,`resolveRetention` 不读它);M51 MCP keepalive 日志面收口(redactingLogger);M46/M48 `URLCause` 五处收敛与防环。
- **U6 `/audio/speech` 鉴权双凭据明示(本版补写,既有能力非新增)**:该端点自诞生起挂的就是 `authorize(RoleOwner)` 双分支门——**owner Bearer JWT 与 `X-API-Key` 都能用**(与全部 owner 端点同一扇门,见 §1.2 U6 更新)。裁决 2026-10-26(主人选 B):Android 不内置 API Key 入口、用现有 owner JWT 调 TTS。⚠️ Bearer 路径的真机 curl 属「同门已验证」的结构性结论(`auth` 层 `WithAPIKey` 双分支有核心断言;`/audio/speech` 与聊天端点共用同一门),**独立真机 Bearer 样例待安卓首次接入时顺手确证**。
### v0.96
**「语音发音人绑人格 + 管理面脱敏投影」版(M35 / M42;纯增量,无破坏性变更)**
- **`POST /api/v1/audio/speech` 新增可选 `sessionId`**(纯增量,`omitempty`)。发音人不再由请求体决定,而是按该会话的**生效人格**解析:**会话绑定 → 全局默认人格 → 空**。
- 🔴 **省略 `sessionId` 不等于「没有角色」**:此时按**全局默认人格**选发音人(常规安装必有全局默认人格 ⇒ 仍走 `personas` 映射)。只有**连全局默认人格都没有**时才落到 `voice-tts.json` 的 `default`(没配则不合成)。
- ⚠️ **不要**写成「`sessionId` 为空 ⇒ 发音人置空」——那会让本口永远用 `default` 那个声音,正是「换了人格还用老角色」的病害。
- 请求体的 `voice` 仍**接受但不生效**(前向兼容);音色只能由服务端 `personas` 映射决定。
- 产物**恒为 WAV**(服务端无转码),实际格式一律按**字节嗅探**判定,客户端**不要**照抄 `Content-Type`。请求体 `format` 仅为前向兼容接受。
- **新增两个错误码**(状态码仍是 `500` —— 这是**服务端配置缺失**,不是客户端的错):
| `error.code` | 含义 |
| --- | --- |
| `tts_persona_unmapped` | 这个生效人格在 `personas` 里没有映射(去面板补) |
| `tts_no_character` | 连生效人格都没有,且没配 `default` |
| `tts_failed` | 其余合成失败(原口径) |
- 🔴 **管理面两条读端点是「脱敏投影」,不是原始数据**(M42):
- `GET /api/v1/admin/observability/logs`(A10):每行 `msg` 先过凭据打码,**上限 4096 rune**(够一行日志全文,但不会把 MiB 级行塞进 JSON)。`time`/`level`/`source` **原样**,条目数与最新优先序不变。
- `GET /api/v1/admin/observability/trace`(A11):每个 span 先过 `SanitizeRecord` ⇒ `props` 变**白名单投影**,表外键折成 `omitted: {键名 → {bytes, runes, sha256 前缀}}`(**仍能回答「这次输出了多大、两次是不是同一份」**);事件里按定义是正文的键同样折成 Digest。**字段名与帧形状不变**,客户端**不得依赖**未声明的 props。
- `id/name/kind/status/startedAt/endedAt/durationNs`、`level/source/time`、条数与排序——**排障字段一律保留**。
### v0.95
**「渠道静默窗口可写」版 —— 群里「一鼓作气打三句」只回一次,且参数在面板上调(切片 M23)**
- **新增 `GET /api/v1/admin/channels/coalesce`(读)** 与 **`PUT` 同路径(写)**。门 = `writeAuth`(owner|admin;`viewer` → 403、未认证 → 401),与 `admin_memory_tuning` 同档:它写的是**面板配置文件**,不是身份/凭据面。GET 只读不审计;PUT 落审计 **`channels.coalesce`**(`result` ∈ `ok|error`)。
- **写面是「三态补丁」**(键缺位 ⇒ 不动;合法值 ⇒ 采用;非法值 ⇒ **`400 invalid_payload` 且盘上一个字节都不动** —— 校验全部发生在落盘之前):
| 键 | 含义 |
| --- | --- |
| `enabled` | 是否启用静默窗口合并(bool) |
| `windowMs` | 首条到达后再等多久收批(ms) |
| `maxWaitMs` | **硬上限**:从首条算起最多等多久,到点就发车 |
| `maxBatchMessages` | 一批最多攒几条,够数立即发车 |
| `applyToStrangers` | 是否对陌生人/成员车道也应用(bool) |
| `strangerWindowMs` | 陌生人/成员车道的独立窗口(ms) |
- **每会话覆盖**:请求/响应带 `conversations` 映射(会话 id → 同一组键的补丁),因此窗口是**按会话**算的,不是适配器顶层一个值。
- **热生效是事实不是承诺**:桥每次发车从**同一个** store 实例读生效值 ⇒ 保存后下一次发车即用新值,响应里 `hot:true`。
- 🔴 **主人私聊不受此设置影响,且没有任何键能翻转它**:车道类型由 actor 元数据决定(`!msg.IsGroup() && actor.IsOwner()` ⇒ direct 车道、**没有窗口对象、拿不到定时器工厂**)。响应里下发 `ownerPrivate:"never"` 是**只读事实**,面板据此只显示一行说明、不给开关。
- 🔴 **一批 = 一个 `turnID` = 一次 `loop.Run` = 一条助手回复**,但批内 N 条仍是**各自一行 `user`**(同 `turnID`、顺序 = 到达顺序),助手回复与它们同 `turnID`。⇒ 客户端按 `turnID` 聚合时,一个回合会看到 N 条用户消息 + 1 条助手消息。
- **零新 `.env` 键**(design/13 §2.1):配置落 `$DATA_DIR/config/coalesce.json`。
### v0.94
**「MCP 管理面 + 工具来源」版 —— 新增一页声明式管理面板,并给能力条目加可选 `origin`(切片 M20 / M30)**
- **前端路由 `/admin/plugin/mcp/servers`**。它**不是新路由/新端点**:这是在既有面板目录里声明的一页(`id = mcp`,`panel = servers`),字段与动作都由 `pkg/plugins/sdk` 的 `Panel` 描述,前端用**既有通用渲染器**画。
- **所有动作走既有的通用分发端点 `POST /api/v1/admin/plugins/mcp/actions/<action>`**(插件只给动作名、不给 URL)。**7 个动作**:
| 动作名 | 档 | 作用 |
| --- | --- | --- |
| `plugin.mcpServers` | 读 | 全部 server 的状态快照(表格数据源) |
| `plugin.mcpTransports` | 读 | 传输形态候选(`stdio` / `sse` / `http`) |
| `plugin.installMcpServer` | 写 | 安装(或刷新)一台 server |
| `plugin.reconnectMcpServer` | 写 | 断开重连并重新发现工具 |
| `plugin.enableMcpServer` | 写 | 启用一台被停用的 server |
| `plugin.disableMcpServer` | 写 | 停用一台 server |
| `plugin.uninstallMcpServer` | 写 | 卸载一台 server |
- **鉴权在两处**:路由挂 `viewerAuth`(读动作 viewer 可用),**写动作由分发器再查 `callerHasWrite`(owner|admin)**,每次写动作落审计。失败码是**稳定闭集**(前端按码本地化,不解析文案):`invalid_config` / `install_failed` / `reconnect_failed` / `enable_failed` / `disable_failed` / `uninstall_failed` / `server_not_found` / `server_disabled`。
- 🔴 **安全提示(客户端/运维必读)**:`transport=stdio` 的 `command` + `args` 等于**在本机执行任意命令**,安装即生效。⚠️ **本版存在一处档位不对称(已 file finding,未在本版修)**:面板的写动作是 **owner|admin**,而 REST 的 `POST /api/v1/admin/plugins/install` 与 `/uninstall` 是 **owner-only**(`ownerAuth`)⇒ 一个非 owner 的 `admin` 可以通过面板动作装上一台 stdio server,却走不通 REST 安装口。单 admin 部署无实际差别,多账号部署请把 admin 视同 owner 收敛。
- **工具条目新增可选 `origin` 对象**:`{ "kind": "mcp", "server": "<server 名>", "transport": "stdio|sse|http" }`。**宿主内置能力整键缺席**(不是空对象)—— 「没有 `origin`」正是「宿主自己提供」的表达。
- 🔴 **归属只来自注册那一刻的元数据,绝不解析工具名字前缀**:MCP server 的工具名由它自己给,宿主不加前缀;实测一台 `halo` server 的工具分属 4 个不同前缀族 ⇒ 按前缀猜归属必错。
- **这是呈现隔离,不是权限隔离**:`tool_policies` 没有 server 维度,`mcp:<server>` 策略行是惰性的 ⇒ **本版不提供「按 server 开关工具」,也不给假开关**。
### v0.93
**「审查预算的唯一真源」版 —— 一个 env 默认值两处副本导致的静默失效**(实现切片 M16;起因 = **塔在部署后读真实启动行时发现** v0.92 那批修复里的 F1 **根本没生效**)
- 🔴 **`MEMORY_REVIEW_TIMEOUT_SEC` 的默认值 `20` → `90`**,并把默认值**收到唯一真源**:它**不再自带 env tag 默认值**,未配置读出 `0` = 「没配」,由 memory 包 `normalizeReviewConfig` 单点落地 `defaultReviewTimeout`。
- **未配置 ⇒ `90s`**;**显式写正数 ⇒ 原样生效**(**不做**「保底抬到 90」的加码);**写 `0` / 负数 ⇒ `90s`**。
- 🔴 **它修的是一个「看起来改了、其实没改」的事故**:v0.92 那批切片把 memory 包的 `defaultReviewTimeout` 从 `20s` 改成 `90s`,但 config 包的 env tag `default:"20"` **永远产出非零的 20** ⇒ `normalizeReviewConfig` 的「`≤ 0` 才填默认」分支**永远不走** ⇒ **90s 是死代码**,线上启动行仍打印 `timeout=20s`。
- **为什么必须修**:批量上限抬到 8 之后,一条完整召回链实测 **21.5–28.2s** ⇒ 20s 预算下审查**整条失败、且不写留痕** ⇒ 召回降级、灰区恒空、T1 发不出调用 ⇒ 「重复记忆收不动」这条链**只修了一半**。
- **为什么旧断言拦不住**:既有跨包断言比的是 **memory 包的常量**(「我打算用多少」),而病害发生在**另一个包**(config 的 tag),单包自洽、跨包漂移看不见。本版新增一条**走真实组装路径**的断言(`LoadMemoryReviewConfig` → `ReviewConfig` → `normalizeReviewConfig` → **生效值**,再断言它 > 实测最坏链长 + T1 子超时),并在注释里写明「**比常量 vs 比生效值**」的区别。
- **兼容性**:**纯行为修复,无 API 形状变化**;`.env` / `.env.example` 文件内容**一字未动**(该键从未进过模板,符合 `.env` 冻结口径);受影响的只有「没显式配这个键」的部署 —— 它们的单条审查预算由 20s 变 90s。
- **诚实边界**:若某部署**显式**写了 `MEMORY_REVIEW_TIMEOUT_SEC=20`,本版**不会**替你抬到 90(那是显式配置,尊重它)。另有两处**散文副本**仍写着旧值(`internal/runtime/memory/preinsert_t1.go` 的一处注释),已 file finding、不在本版改动面内。
### v0.92
**「记忆整理调优键可写」版 —— 管理面新增 2 条 `writeAuth` 端点,把面板从「只能改文件 + 重启」救出来**(依据:主人 2026-10-13 口径「`.env` 只做兜底…这些键优先从面板配置上取」「频率必须调…白天间隔短一点、静默期长一点」;实现切片 M4)
- **新增 `GET /api/v1/admin/memories/tidy/tuning`(B26)** 与 **`PUT` 同路径(B27)**。管 7 个键:`intervalSec` / `quietIntervalSec` / `quietStartHour` / `quietEndHour` / `ingestGate` / `autoApply` / `tempTtlSec`。门 = `writeAuth`(owner|admin;`viewer` → 403,未认证 → 401),与同族 `GET /admin/memories`、`POST …/tidy/dry-run` 同档;GET 只读**不审计**,PUT 落审计 **`memory.tidy.tuning`**(`result` ∈ `ok|error`)。
- 🔴 **响应三块的分工(客户端按此编程,别自己算默认值)**:
- `specs[]` 是**取值规则唯一真源**(`kind` / `unit` / `min` / `max` / `fallback`),面板据此渲染与就地校验 ⇒ 不会出现两套规则;`fallback` **只给来源**(`env:<键名>` | `panel:<键名>` | `code-default`)、**不给数值** —— 数值真源仍在领域侧,网关不另抄一份默认值。
- `values` 是**盘上**的值:**键缺位 ⇒ 该键在对象里不出现**(= 跟随下一档兜底),**不是 0** ——「没配」与「配成 0」必须分得开。只回这 7 键,同文件里不受管的键(`t1Judge` / `t1PerWindow` / `autoApplyPerRound` 等)**不回显**。
- `envValues` 是键缺位时**下一档(`.env`)**的进程生效值(与 `/admin/config-facts` 同源,网关**绝不 fopen `.env`**);连 `.env` 档也没有值 ⇒ 该键不出现。
- 🔴 **写侧三态(硬要求,逐字)**:键**缺位** ⇒ 不改动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节不动**。**未知键(含拼错)也归「非法」,绝不静默忽略**。值 `null` = **清除该键**(回到下一档兜底)。错误码:`400 invalid_payload` | `409 tuning_corrupt`(盘上那份运行期读不走 ⇒ **拒绝覆盖**)| `500 tuning_unavailable`(数据目录未接线)| `500 tuning_write_failed`(落盘失败,盘上未改动)。
- **两条语义**:**值一个都没变时不写盘**(连 mtime 都不动);同文件**不受管的键原样保留**。PUT 的 200 与 GET **同形状**,但值是**写完重新读盘**的真值。
- 🔴 **`hot` 语义(绝不把「落盘了」说成「生效了」)**:`hot:true` = 值已叠到运行期(整理档会 `Stop→写→Start` **重排心跳**,否则新周期要等旧周期那一觉睡完;审查档重算 `ingestGate`/`tempTtlSec`);`hot:false` = 已落盘但运行期没接上,`hotError` 给原因,面板文案必须是「**重启后端后生效**」。GET 的 `hot` **恒为 false**(读不改变任何东西)——那是如实占位,**别读成能力声明**。
- **兼容性**:**纯增量**(新端点;既有端点/帧一字未改),老客户端零影响。
- **计数**:`writeAuth` 段 25 → **27**;管理路由总数 79 → **81**(`14 + 27 + 40`)。字段级说明见 **§4.17**。
### v0.91
**「召回来源如实上报」版 —— `recallMode` 取值域扩两个 + 语义收紧**(实现 `5967272`,切片 M9;起因 = M6 诊断发现旧实现把「全量降级」静默报成 `embedding`,见 `DevDocs/audit/去重失效诊断-2026-10-13.md`)
- `recallMode` 现在**如实上报本轮实际选用的召回路径**,取值域:`"embedding"` | `"lexical"` | **`"mixed"`(部分分区降级、其余走向量 —— v0.91 新增)** | **`""`(没有候选可比 ⇒ 本轮未做召回 —— v0.91 新增;它不是降级,也不该被显示成空标签)**。
- 🔴 **行为变更(客户端须跟进)**:旧实现报的是**配置态**(进程接没接 embedder),因此「向量路径实际失败、回退到词项」会被显示成 `embedding` —— 面板**看不出降级**。现改为实际态。**试算端点(B14)与整理轮日志/审计同源**;整理轮另新增 `recall_degraded_partitions` 计数,用于回答「哪几个分区降级了」。
- **兼容性**:按 `embedding|lexical` 二分读的老客户端需自行兜底新值(`mixed` → 「部分降级」;`""` → 「无候选可比」)。前端渲染由后续切片修正(`MemoryTidyDryRunCard`)。
- **附带的稳定性修复(对客户端透明,无契约影响)**:embedding 批上限改为按「单次请求预算 = 客户端超时的一半」**推演**得出,并在 embedder 内部强制切片(任何调用方给多大的批都安全);**客户端 5s 超时值本身一字未改**。它修的是「客户端先放弃、服务端继续算 ⇒ 队列堆积级联」这条真机制(M9 实测:空闲态单条 ~0.03s(ASCII)/ ~0.16s(40 汉字),饱和态可放大 70~90 倍)。
- **计数不变**:无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`。字段级说明见 **§4.12** 的 `recallMode` 行。
### v0.90
**「渠道连接态三态」版 —— 管理面新增三个可选键,并把 `connected` 的语义从「动作被受理」纠正为「真的连上了」**(依据:2026-10-13 主人报「改了 QQ 适配器配置后点连接,卡片仍显示未连接,却提示已连接」;实现推库 `b4d55f4`。) **「渠道连接态三态」版 —— 管理面新增三个可选键,并把 `connected` 的语义从「动作被受理」纠正为「真的连上了」**(依据:2026-10-13 主人报「改了 QQ 适配器配置后点连接,卡片仍显示未连接,却提示已连接」;实现推库 `b4d55f4`。)
@@ -1651,7 +1857,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **③ 入站语音 AMR 也可回放(切片 W4;裁决 #11)**:组合根补齐 `channelbridge.Options.VoiceTranscoder`(`cmd/cyrene/wire_channels.go`)—— 注入的是 `wireTranscode` 装配的**同一个**引擎实例(与工具面 `transcode` 工具同源,不是第二条降级链)。⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`),客户端的 `voice` 从此带副本事实(`name` / `mime` / `size` / `durationMs` / `url`);**此前这些键缺席**(走 `category=transcode_not_wired` 的诚实 WARN、不落副本)。**形状零变化**(DTO `voice` 的键集合与语义一字未改);**可播格式**(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍**原样落、一次转码都不发生**。稳定 `category` 列表**不变**(10 类),但 `transcode_not_wired` 在生产装配下**不再出现**(它现在只剩「引擎未装配 / 构造失败」这一档)。🔴 **本版改正 6 处已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**:§4.5(缺口① + 现状行)/ §7.1(对照注)/ §7.2(语音面行 ②)/ §7.4-16(前向提示)/ §7.4-29(②)/ 旧版本横幅与 §6 旧版条目(v0.67 / v0.78 / v0.79)。**§4.5 / §7.2 / §7.4-29**。 - **③ 入站语音 AMR 也可回放(切片 W4;裁决 #11)**:组合根补齐 `channelbridge.Options.VoiceTranscoder`(`cmd/cyrene/wire_channels.go`)—— 注入的是 `wireTranscode` 装配的**同一个**引擎实例(与工具面 `transcode` 工具同源,不是第二条降级链)。⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`),客户端的 `voice` 从此带副本事实(`name` / `mime` / `size` / `durationMs` / `url`);**此前这些键缺席**(走 `category=transcode_not_wired` 的诚实 WARN、不落副本)。**形状零变化**(DTO `voice` 的键集合与语义一字未改);**可播格式**(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍**原样落、一次转码都不发生**。稳定 `category` 列表**不变**(10 类),但 `transcode_not_wired` 在生产装配下**不再出现**(它现在只剩「引擎未装配 / 构造失败」这一档)。🔴 **本版改正 6 处已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**:§4.5(缺口① + 现状行)/ §7.1(对照注)/ §7.2(语音面行 ②)/ §7.4-16(前向提示)/ §7.4-29(②)/ 旧版本横幅与 §6 旧版条目(v0.67 / v0.78 / v0.79)。**§4.5 / §7.2 / §7.4-29**。
- **行为变更**:① 无(新端点;老端点逐字未改);② **有**(面板保存由「需重启生效」变为**下一轮即生效**;新增两个可配字段与两个错误码;`plugin.saveProvider` / `plugin.reauthProvider` 的响应形状**加键**);③ 无(DTO `voice` 的键集合与语义未变,只是**此前缺席的键现在会出现**)。 - **行为变更**:① 无(新端点;老端点逐字未改);② **有**(面板保存由「需重启生效」变为**下一轮即生效**;新增两个可配字段与两个错误码;`plugin.saveProvider` / `plugin.reauthProvider` 的响应形状**加键**);③ 无(DTO `voice` 的键集合与语义未变,只是**此前缺席的键现在会出现**)。
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;新增的响应键都是**加性**的,老客户端不读即行为不变)。 - **破坏性**:**无**(新端点 / 新可选键 / 新错误码;新增的响应键都是**加性**的,老客户端不读即行为不变)。
- ⚠️ **诚实边界**:① 时间轴的**前端渲染 / 真机面板联调**不在后端出口的证据内;`graph_json` 破损行的 `500` 路径**无测试**;游标精度到**毫秒**(同一毫秒起跑的两个不同轮次在极端情况下可能被一并跳过 —— 理论边界,已写进 §4.11.1(b)-4)。② 热重载的**真机面板端到端未跑**、**真机探针未打真实上游**;**启用态不跨重启持久化**。③ 语音**生产端到端未跑**(红线:不重启后端)⇒ 证据是 `channelbridge` 行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**;WS 实时帧 / 握手回放**仍不带 `voice`**;删除消息 / 会话时音频副本引用计数**仍不释放**。逐条见 **§7.2 / §7.3 / §7.4**。 - ⚠️ **诚实边界**:① 时间轴的**前端渲染 / 真机面板联调**不在后端出口的证据内;`graph_json` 破损行的 `500` 路径**无测试**;游标精度到**毫秒**(同一毫秒起跑的两个不同轮次在极端情况下可能被一并跳过 —— 理论边界,已写进 §4.11.1(b)-4)。② 热重载的**真机面板端到端未跑**、**真机探针未打真实上游**;**启用态不跨重启持久化**。③ 语音**生产端到端未跑**(红线:不重启后端)⇒ 证据是 `channelbridge` 行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**;WS 实时帧 / 握手回放**仍不带 `voice`** —— ✅ **已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);删除消息 / 会话时音频副本引用计数**仍不释放**。逐条见 **§7.2 / §7.3 / §7.4**。
### v0.79(上一版) ### v0.79(上一版)
@@ -1659,11 +1865,11 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **① 插件面板动作真可用(切片 L1/PLG-2)**:插件管理器新增**动作处理器登记面**(`Manager.RegisterActions` → `Panels.RegisterActions`,取代单一全局执行器;**声明仍是唯一闸门** —— 登记≠可执行);组合根在 `wirePanelHub` 里登记内置 `web-search` 的**四个动作 + 下拉候选来源**(`plugin.providers`)⇒ `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501** —— 真读写 `$DATA_DIR/config/plugins/web-search.json`(0600 + temp+rename,**重启仍生效**;**v0.80 起保存后即热重载**,见 §6 v0.80 ②)、探针**真发一次最小 HTTP 检索**。失败回**结构化非 2xx**(码/文案/状态由动作处理器给出,宿主只透传、状态越界回落 502):新增 `invalid_payload`(400) / `unknown_provider`(400) / `not_configured`(400) / `config_unreadable`(500) / `config_write_failed`(500),以及探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(502);**`501 action_not_implemented` 只在「已声明但宿主未登记处理器」时出现**(**不再是兜底档**);兜底执行失败仍是 `502 action_failed`。审计仍 `what="plugin.action"`,**服务端日志新增附加字段 `code`**(**不进 DB 记录体、不含请求体**)。**§2.1 A14 / §4.1 / §4.14**。 - **① 插件面板动作真可用(切片 L1/PLG-2)**:插件管理器新增**动作处理器登记面**(`Manager.RegisterActions` → `Panels.RegisterActions`,取代单一全局执行器;**声明仍是唯一闸门** —— 登记≠可执行);组合根在 `wirePanelHub` 里登记内置 `web-search` 的**四个动作 + 下拉候选来源**(`plugin.providers`)⇒ `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501** —— 真读写 `$DATA_DIR/config/plugins/web-search.json`(0600 + temp+rename,**重启仍生效**;**v0.80 起保存后即热重载**,见 §6 v0.80 ②)、探针**真发一次最小 HTTP 检索**。失败回**结构化非 2xx**(码/文案/状态由动作处理器给出,宿主只透传、状态越界回落 502):新增 `invalid_payload`(400) / `unknown_provider`(400) / `not_configured`(400) / `config_unreadable`(500) / `config_write_failed`(500),以及探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(502);**`501 action_not_implemented` 只在「已声明但宿主未登记处理器」时出现**(**不再是兜底档**);兜底执行失败仍是 `502 action_failed`。审计仍 `what="plugin.action"`,**服务端日志新增附加字段 `code`**(**不进 DB 记录体、不含请求体**)。**§2.1 A14 / §4.1 / §4.14**。
- **② 「入库前审查」(D3)的管理面(切片 L45)**:新增 2 条 `writeAuth`(owner\|admin)端点 —— `GET /api/v1/admin/memories/reviews?limit=`(留痕列表:`limit` 缺省 100、`≤0` 同缺省、>500 钳位、非整数 `400`;元素含 `decision ∈ skip\|merge\|modify\|create`、`mergedIds` 恒为非 null 数组、`createdAt` UTC RFC3339;🔴 **不含记忆正文**)与 `POST /api/v1/admin/memories/{id}/revert`(撤销一次**软合并**:被吸收行复原 `active`、从存活行 `merged_from` 摘除、`merge_count` 减 1;**行永不物理删除**)。未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**);回滚落审计新动词 **`memory.review.revert`**(只记 item id)。权限矩阵:`writeAuth` 14 → **16**、总数 68 → **70**。**§2.0 / §2.2 B16/B17 / §4.1**。 - **② 「入库前审查」(D3)的管理面(切片 L45)**:新增 2 条 `writeAuth`(owner\|admin)端点 —— `GET /api/v1/admin/memories/reviews?limit=`(留痕列表:`limit` 缺省 100、`≤0` 同缺省、>500 钳位、非整数 `400`;元素含 `decision ∈ skip\|merge\|modify\|create`、`mergedIds` 恒为非 null 数组、`createdAt` UTC RFC3339;🔴 **不含记忆正文**)与 `POST /api/v1/admin/memories/{id}/revert`(撤销一次**软合并**:被吸收行复原 `active`、从存活行 `merged_from` 摘除、`merge_count` 减 1;**行永不物理删除**)。未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**);回滚落审计新动词 **`memory.review.revert`**(只记 item id)。权限矩阵:`writeAuth` 14 → **16**、总数 68 → **70**。**§2.0 / §2.2 B16/B17 / §4.1**。
- **③ 语音消息的用户可见面(切片 L45)**:消息 DTO 的 **`voice`**(含服务端唯一派生的 `url`)在 **4 个 REST 读取面 + `message_edited`** 真实填充;新增 **`GET /api/v1/media/{name}`**(`writeAuth`;`audio/*` 内联 + `nosniff` + `Accept-Ranges` + `Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**;`Range` ⇒ 206、条件请求 ⇒ 304、支持 `HEAD`、不可满足 `Range` ⇒ 416(**非 JSON 信封**);名字非法 / 穿越 / 缺失统一 `404 media_not_found`);副本落盘走新增 `documents.Store.SaveAudio`(内容寻址、0600、元数据进同一张 `documents` 表、并入同一套引用计数),`chat_messages.voice` 列读写两条路补齐(`history.Store.UpdateMessageVoice` / `VoiceColumns`)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`**(需给 `history.Message` 加字段,未做;且 `message_persisted` 在落库那一刻发出,天生拿不到落库后才回填的值);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **该缺口已由 v0.80(切片 W4)修复**(组合根已注入,见 §6 v0.80 ③ / §4.5 / §7.2)。**§0.3 / §1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5**。 - **③ 语音消息的用户可见面(切片 L45)**:消息 DTO 的 **`voice`**(含服务端唯一派生的 `url`)在 **4 个 REST 读取面 + `message_edited`** 真实填充;新增 **`GET /api/v1/media/{name}`**(`writeAuth`;`audio/*` 内联 + `nosniff` + `Accept-Ranges` + `Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**;`Range` ⇒ 206、条件请求 ⇒ 304、支持 `HEAD`、不可满足 `Range` ⇒ 416(**非 JSON 信封**);名字非法 / 穿越 / 缺失统一 `404 media_not_found`);副本落盘走新增 `documents.Store.SaveAudio`(内容寻址、0600、元数据进同一张 `documents` 表、并入同一套引用计数),`chat_messages.voice` 列读写两条路补齐(`history.Store.UpdateMessageVoice` / `VoiceColumns`)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`** —— ✅ **该缺口已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **该缺口已由 v0.80(切片 W4)修复**(组合根已注入,见 §6 v0.80 ③ / §4.5 / §7.2)。**§0.3 / §1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5**。
- **④ 🔴 链路图权威回合标识覆盖到全部编排回合(切片 CODE-TC-2)**:新增 `loop.FlowPublisher.StartRoundWithTurnID(ctx, owner, text)`(权威回合标识取自 ctx 的 `TurnIDFrom`),`run.go` 的**唯一生产建图点**改走它 ⇒ 编排器驱动的**每一路**(**普通单路回合**、队首续跑路、真并行路)的图都带与 `chat_messages.turn_id` **同值的 `turnId`** ⇒ 消息面 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{权威回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)—— 该键仍**不透明**、仍可能含 `#`;客户端**一律使用响应里的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`(自拼的老客户端**查不到新行**)。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍**整体缺席**(**绝不回空数组**);**v0.79 之前的单路历史行不回填**(`lane_turn_id` 为空且无可回填来源)。**§4.4 / §4.11 / §7.2 / §7.4-23**。 - **④ 🔴 链路图权威回合标识覆盖到全部编排回合(切片 CODE-TC-2)**:新增 `loop.FlowPublisher.StartRoundWithTurnID(ctx, owner, text)`(权威回合标识取自 ctx 的 `TurnIDFrom`),`run.go` 的**唯一生产建图点**改走它 ⇒ 编排器驱动的**每一路**(**普通单路回合**、队首续跑路、真并行路)的图都带与 `chat_messages.turn_id` **同值的 `turnId`** ⇒ 消息面 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{权威回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)—— 该键仍**不透明**、仍可能含 `#`;客户端**一律使用响应里的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`(自拼的老客户端**查不到新行**)。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍**整体缺席**(**绝不回空数组**);**v0.79 之前的单路历史行不回填**(`lane_turn_id` 为空且无可回填来源)。**§4.4 / §4.11 / §7.2 / §7.4-23**。
- **行为变更**:① **有**(面板动作由「全 501」变为**真执行**)+ 新增一批错误码;② 无(新端点;老客户端零影响);③ 无(`voice` 是既有 DTO 的可选键、`GET /media/{name}` 是新端点);④ **有(唯一一处形状变化)**:`graph.id` 对单路也追加后缀 ⇒ 自拼 id 的读取方须改用响应值(**其余形状与缺席语义一字未改**)。 - **行为变更**:① **有**(面板动作由「全 501」变为**真执行**)+ 新增一批错误码;② 无(新端点;老客户端零影响);③ 无(`voice` 是既有 DTO 的可选键、`GET /media/{name}` 是新端点);④ **有(唯一一处形状变化)**:`graph.id` 对单路也追加后缀 ⇒ 自拼 id 的读取方须改用响应值(**其余形状与缺席语义一字未改**)。
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;④的形状变化只影响「自己拼 `graph.id`」的读取方,而本文档自 v0.73 起就写明该键**不透明、应从响应取**)。 - **破坏性**:**无**(新端点 / 新可选键 / 新错误码;④的形状变化只影响「自己拼 `graph.id`」的读取方,而本文档自 v0.73 起就写明该键**不透明、应从响应取**)。
- ⚠️ **诚实边界**:① 面板动作**热重载未做**(保存后运行中的 web-search 工具不换接线,需重启)—— ✅ **已由 v0.80(切片 W3)修复**(见 §6 v0.80 ②)、**探针只用 `httptest` 假上游**(未对真实上游连通,**仍未做**);② 回滚端点与 `design/23 §11` 的 owner-only 口径**有差异**(待裁决)、留痕列表**无分页游标**、`memory_ingest_reviews` 无 TTL/GC;③ 语音**生产端到端未跑**、WS 实时帧/握手回放不带 `voice`、AMR 不落副本(✅ **已由 v0.80 修复**)、删除消息/会话时音频副本引用计数不释放;④ 单路 `toolCalls[]` 的**HTTP 出口端到端未由本切片确证**(写侧已验,网关读侧由主会话补验)、`turns[]` 仍按**路**列条目、真机未验。逐条见 **§7.2 / §7.4**。 - ⚠️ **诚实边界**:① 面板动作**热重载未做**(保存后运行中的 web-search 工具不换接线,需重启)—— ✅ **已由 v0.80(切片 W3)修复**(见 §6 v0.80 ②)、**探针只用 `httptest` 假上游**(未对真实上游连通,**仍未做**);② 回滚端点与 `design/23 §11` 的 owner-only 口径**有差异**(待裁决)、留痕列表**无分页游标**、`memory_ingest_reviews` 无 TTL/GC;③ 语音**生产端到端未跑**、WS 实时帧/握手回放不带 `voice`(✅ **已由 v0.97(切片 M44)修复**)、AMR 不落副本(✅ **已由 v0.80 修复**)、删除消息/会话时音频副本引用计数不释放;④ 单路 `toolCalls[]` 的**HTTP 出口端到端未由本切片确证**(写侧已验,网关读侧由主会话补验)、`turns[]` 仍按**路**列条目、真机未验。逐条见 **§7.2 / §7.4**。
### v0.78(上一版) ### v0.78(上一版)
@@ -1674,10 +1880,10 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **③ 搜索 provider 可变源(切片 PLG-4)**:插件 `web-search` 新增可变源 `$DATA_DIR/config/plugins/web-search.json`(**0600**、temp+rename、文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = 四个动作 `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider`(表格行 `{provider,state,enabled}`,`state ∈ {unknown,disabled,not_configured,ok}`;`testProvider` 的 `errorCode` 闭集十项,⚠️ **`no_results` 时「空结果 ≠ 不通」**)。🔴 **不新增任何 env 键**(`.env` 已冻结);**`.env` 的 `WEB_SEARCH_*` 自此只在文件缺失时生效**(运维视角的行为变更,如实登记)。**§4.14(c) / §5**。 - **③ 搜索 provider 可变源(切片 PLG-4)**:插件 `web-search` 新增可变源 `$DATA_DIR/config/plugins/web-search.json`(**0600**、temp+rename、文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = 四个动作 `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider`(表格行 `{provider,state,enabled}`,`state ∈ {unknown,disabled,not_configured,ok}`;`testProvider` 的 `errorCode` 闭集十项,⚠️ **`no_results` 时「空结果 ≠ 不通」**)。🔴 **不新增任何 env 键**(`.env` 已冻结);**`.env` 的 `WEB_SEARCH_*` 自此只在文件缺失时生效**(运维视角的行为变更,如实登记)。**§4.14(c) / §5**。
- **④ 账号上下线落事件 + 渠道事件实时帧(切片 E7)**:`internal/runtime/events` 增「账号状态迁移」这一路 —— 三态归一(**未知也照常落行**,不整类丢弃)→ 落一行 `tier=t2` / `category=lifecycle`(**复用迁移 `0021`**)→ 经**既有进程内 bus** 的主题 `channel.event` 推成 WS 下行帧 **`channel_event`**(`data.event` 与 REST `events[]` **同一个对象**;⚠️ **实时帧的 `id` 键可能整缺**;**只发 owner/admin**,**viewer 走 REST 只读档**)。采样常驻(约 60s 一拍,**不看面板也在跑**)。⚠️ **当前只覆盖账号状态迁移这一路**(其余渠道事件的实时装饰器**未接线**,见 §7.2);采样通道目前唯一来源是 `qq-obv11` 的探活。**§3.4.5**。 - **④ 账号上下线落事件 + 渠道事件实时帧(切片 E7)**:`internal/runtime/events` 增「账号状态迁移」这一路 —— 三态归一(**未知也照常落行**,不整类丢弃)→ 落一行 `tier=t2` / `category=lifecycle`(**复用迁移 `0021`**)→ 经**既有进程内 bus** 的主题 `channel.event` 推成 WS 下行帧 **`channel_event`**(`data.event` 与 REST `events[]` **同一个对象**;⚠️ **实时帧的 `id` 键可能整缺**;**只发 owner/admin**,**viewer 走 REST 只读档**)。采样常驻(约 60s 一拍,**不看面板也在跑**)。⚠️ **当前只覆盖账号状态迁移这一路**(其余渠道事件的实时装饰器**未接线**,见 §7.2);采样通道目前唯一来源是 `qq-obv11` 的探活。**§3.4.5**。
- **⑤ 微信 iLink 凭据三态(切片 CODE-F3)**:`GET /api/v1/admin/channels/{platform}/login-status` 新增**可选** `authState` / `authDetail`(`"active"` 有效 / `"rejected"` **被拒且需重扫** / `"unknown"` 未知;**闭集**)。🔴 **文案口径写死**:**`errcode=-14` 是「被拒」不是「过期」**、**未知原因不许写成「过期」/「未连接」**(响应体全文由测试钉住)。**键缺席 = 该账号没有这条状态面**,≠ `unknown`。**无新端点、无新路由**(沿用 v0.39 起的端点)。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。**§2.4(c)**。 - **⑤ 微信 iLink 凭据三态(切片 CODE-F3)**:`GET /api/v1/admin/channels/{platform}/login-status` 新增**可选** `authState` / `authDetail`(`"active"` 有效 / `"rejected"` **被拒且需重扫** / `"unknown"` 未知;**闭集**)。🔴 **文案口径写死**:**`errcode=-14` 是「被拒」不是「过期」**、**未知原因不许写成「过期」/「未连接」**(响应体全文由测试钉住)。**键缺席 = 该账号没有这条状态面**,≠ `unknown`。**无新端点、无新路由**(沿用 v0.39 起的端点)。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。**§2.4(c)**。
- **⑥ 语音消息落库面(切片 CODE-VOICE)**:迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''`(JSON 单对象,形状 = DTO `voice` 减去 `url`;**刻意不建索引**);`internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);`channelbridge` 入站语音「归一 → **闸门之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC、**不进文档抽取/注入链路**)。⚠️ **用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);**残留缺口**(WS 实时帧/握手回放不带 `voice`;AMR 那一项**已由 v0.80 修复**)见 §4.5 / §7.2。唯一已生效的**客户端可见**变化:IM 语音在「格式不在可转写白名单内」时正文诚实提示改口径(**形状不变**,客户端本就不该按正文文案分支)。**§4.5 / §7.2**。 - **⑥ 语音消息落库面(切片 CODE-VOICE)**:迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''`(JSON 单对象,形状 = DTO `voice` 减去 `url`;**刻意不建索引**);`internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);`channelbridge` 入站语音「归一 → **闸门之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC、**不进文档抽取/注入链路**)。⚠️ **用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);**残留缺口**(WS 实时帧/握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 那一项**已由 v0.80 修复**)见 §4.5 / §7.2。唯一已生效的**客户端可见**变化:IM 语音在「格式不在可转写白名单内」时正文诚实提示改口径(**形状不变**,客户端本就不该按正文文案分支)。**§4.5 / §7.2**。
- **行为变更**:① 无(新可选键 + 新迁移列/索引);② 无(新端点 / 新帧 / 新可选键;老客户端零影响)—— ⚠️ 但**新增了 `502` 状态码**(仅插件动作执行器失败路径)与一批插件动作错误码;③ **有**(运维视角:`.env` 的 `WEB_SEARCH_*` 仅在可变源文件缺失时生效);④ 无(新帧 + 新事件行;REST 形状未变);⑤ 无(两个可选键;`status`/`botId`/`botToken`/`err` 与 v0.39 起逐字一致);⑥ **有(轻微)**:IM 语音「不可转写格式」的正文诚实提示**改口径**(**形状不变**)。 - **行为变更**:① 无(新可选键 + 新迁移列/索引);② 无(新端点 / 新帧 / 新可选键;老客户端零影响)—— ⚠️ 但**新增了 `502` 状态码**(仅插件动作执行器失败路径)与一批插件动作错误码;③ **有**(运维视角:`.env` 的 `WEB_SEARCH_*` 仅在可变源文件缺失时生效);④ 无(新帧 + 新事件行;REST 形状未变);⑤ 无(两个可选键;`status`/`botId`/`botToken`/`err` 与 v0.39 起逐字一致);⑥ **有(轻微)**:IM 语音「不可转写格式」的正文诚实提示**改口径**(**形状不变**)。
- **破坏性**:**无**。 - **破坏性**:**无**。
- ⚠️ **诚实边界**:① `toolCalls[]` **v0.78 时覆盖率只到真并行路**(普通单路回合查不到图 ⇒ 缺席)—— **已由 v0.79 补齐**(见 §6 v0.79 ④),且**未做真机端到端**(真并行路的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等仍是**代码级**推论);② 插件动作**生产无执行器**(全回 501)—— **已由 v0.79 接线**(见 §6 v0.79 ①)、热装插件的声明来源缺、`panels_changed` 对 viewer 不可见、启用态**不跨重启持久化**、审计持久化记录**未含 pluginId/action**(都在 §7.2);③ `web-search` 声明已就绪但**「面板加一家 provider」未做真机端到端**;④ `channel_event` **只覆盖账号状态迁移这一路**(组合根装饰器未接线),且**未做真机端到端**;⑤ iLink `rejected` 分支**只有单测/httptest 证据**(真实 `errcode=-14` 至今 0 次观测);⑥ 语音**生产端到端未跑** —— **用户可见面已由 v0.79 落地**(见 §6 v0.79 ③),但 WS 帧/握手回放仍不带 `voice`;AMR 不落副本**已由 v0.80 修复**(见 §6 v0.80 ③)。 - ⚠️ **诚实边界**:① `toolCalls[]` **v0.78 时覆盖率只到真并行路**(普通单路回合查不到图 ⇒ 缺席)—— **已由 v0.79 补齐**(见 §6 v0.79 ④),且**未做真机端到端**(真并行路的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等仍是**代码级**推论);② 插件动作**生产无执行器**(全回 501)—— **已由 v0.79 接线**(见 §6 v0.79 ①)、热装插件的声明来源缺、`panels_changed` 对 viewer 不可见、启用态**不跨重启持久化**、审计持久化记录**未含 pluginId/action**(都在 §7.2);③ `web-search` 声明已就绪但**「面板加一家 provider」未做真机端到端**;④ `channel_event` **只覆盖账号状态迁移这一路**(组合根装饰器未接线),且**未做真机端到端**;⑤ iLink `rejected` 分支**只有单测/httptest 证据**(真实 `errcode=-14` 至今 0 次观测);⑥ 语音**生产端到端未跑** —— **用户可见面已由 v0.79 落地**(见 §6 v0.79 ③),但 WS 帧/握手回放仍不带 `voice`(✅ **已由 v0.97(切片 M44)修复**);AMR 不落副本**已由 v0.80 修复**(见 §6 v0.80 ③)。
### v0.77(上一版) ### v0.77(上一版)
@@ -1685,7 +1891,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **① `graph_update` 按路分桶(切片 FV2)**:帧 `data` 新增**可选** `turnId`(这张图所属**路**的权威回合标识,与 `turn_begin` / `steer_ack` / 流帧帧壳同值)与 `round`(会话轮次号);图按 `{会话}#{路}` **归属桶**建(单路的桶逐字等于会话 id ⇒ **单路行为逐字不变**),因此真并行时**每一路各发自己的帧、各落一行**(`flow_graphs` 的唯一键 `(session_id, turn_id)` 不再把另一路整行顶掉)。`GET /api/v1/admin/flow/graph` 响应新增 **`lanes[]`**(最近一轮每一路的图 `[{turnId,graph}]`,按路开始时间升序,恒为非 null 数组;`graph` 仍是「最新一路」⇒ **老的读取方零改动**),前端在 ≥2 路时按**并列泳道**呈现 —— **§2.2 B13 / §3.4.5 / §4.11(a)(d) / §7.2 / §7.4-19**。⚠️ 留白:`turns[]` 仍按**路**列条目(一轮两路显示两条),按轮分组需新迁移 `0023`(**未做**);Agents 页 / 后台思考页仍只显示最新一路。 - **① `graph_update` 按路分桶(切片 FV2)**:帧 `data` 新增**可选** `turnId`(这张图所属**路**的权威回合标识,与 `turn_begin` / `steer_ack` / 流帧帧壳同值)与 `round`(会话轮次号);图按 `{会话}#{路}` **归属桶**建(单路的桶逐字等于会话 id ⇒ **单路行为逐字不变**),因此真并行时**每一路各发自己的帧、各落一行**(`flow_graphs` 的唯一键 `(session_id, turn_id)` 不再把另一路整行顶掉)。`GET /api/v1/admin/flow/graph` 响应新增 **`lanes[]`**(最近一轮每一路的图 `[{turnId,graph}]`,按路开始时间升序,恒为非 null 数组;`graph` 仍是「最新一路」⇒ **老的读取方零改动**),前端在 ≥2 路时按**并列泳道**呈现 —— **§2.2 B13 / §3.4.5 / §4.11(a)(d) / §7.2 / §7.4-19**。⚠️ 留白:`turns[]` 仍按**路**列条目(一轮两路显示两条),按轮分组需新迁移 `0023`(**未做**);Agents 页 / 后台思考页仍只显示最新一路。
- **② 记忆整理护栏抬高 + 诚实截断 + 试算异步落地(切片 MT-A)**:每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认 **`20 → 200`**;两两比对上限从**写死的 `400`** 改为 env 键 **`MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`)**;配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(被**墙钟时间预算**截断:整分区跳过,或两两比对中途收手 —— 成因是**本次调用的时间上限**而不是配置配额);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询:并发**复用既有 `runId`**、与同步**同一个 3 分钟**墙钟、`done` 的 `result` 与同步 `data` **逐字节同形**、`running` 时 `result`/`error` **双缺席**、重启后旧 `runId` 如实 `404`)—— **§4.10(a) / §4.12 / §4.12.1 / §5**。 - **② 记忆整理护栏抬高 + 诚实截断 + 试算异步落地(切片 MT-A)**:每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认 **`20 → 200`**;两两比对上限从**写死的 `400`** 改为 env 键 **`MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`)**;配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(被**墙钟时间预算**截断:整分区跳过,或两两比对中途收手 —— 成因是**本次调用的时间上限**而不是配置配额);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询:并发**复用既有 `runId`**、与同步**同一个 3 分钟**墙钟、`done` 的 `result` 与同步 `data` **逐字节同形**、`running` 时 `result`/`error` **双缺席**、重启后旧 `runId` 如实 `404`)—— **§4.10(a) / §4.12 / §4.12.1 / §5**。
- **③ 「入库前审查」(D3)的 4 个旋钮(切片 MT-B)**:`MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`)—— 队列满 ⇒ **该条不审查但记忆不丢**(只 WARN)、超时 ⇒ **零改写** + WARN、`≤0` = 用默认、召回门**复用** `MEMORY_TIDY_RECALL_THRESHOLD`。🔴 **这 4 个键不进 `.env` 模板**(主人 2026-10-06:`.env` 冻结为「只保证最小化功能可跑」的引导面、**不再新增键**)⇒ **有效行为 = 上面这组代码默认值**,调优入口将由**配置文件 / 面板**提供(**未接线**)。⚠️ 该切片的**面板读侧端点(`GET /admin/memories/reviews`)与回滚端点(`POST /admin/memories/{id}/revert`)目前没有 HTTP 入口**,**本版不登记为可用**(见 §7.2)。 - **③ 「入库前审查」(D3)的 4 个旋钮(切片 MT-B)**:`MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`,⚠️ **默认值已由 v0.93 改为 `90`** —— 键名与文件内容未动,只是代码侧默认值,见 §6 v0.93)—— 队列满 ⇒ **该条不审查但记忆不丢**(只 WARN)、超时 ⇒ **零改写** + WARN、`≤0` = 用默认、召回门**复用** `MEMORY_TIDY_RECALL_THRESHOLD`。🔴 **这 4 个键不进 `.env` 模板**(主人 2026-10-06:`.env` 冻结为「只保证最小化功能可跑」的引导面、**不再新增键**)⇒ **有效行为 = 上面这组代码默认值**,调优入口将由**配置文件 / 面板**提供(**未接线**)。⚠️ 该切片的**面板读侧端点(`GET /admin/memories/reviews`)与回滚端点(`POST /admin/memories/{id}/revert`)目前没有 HTTP 入口**,**本版不登记为可用**(见 §7.2)。
- **行为变更**:无(① 两个新键可选、`lanes[]` 纯增量、单路行为逐字不变;② 只改默认值 / 新增一个截断标签,同步试算的响应形状未变;③ 只多了一组**以代码默认值生效**的旋钮,**不进 `.env`**、不改变任何既有行为)。 - **行为变更**:无(① 两个新键可选、`lanes[]` 纯增量、单路行为逐字不变;② 只改默认值 / 新增一个截断标签,同步试算的响应形状未变;③ 只多了一组**以代码默认值生效**的旋钮,**不进 `.env`**、不改变任何既有行为)。
- **破坏性**:无。 - **破坏性**:无。
- **⚠️ 诚实边界**:① 真并行**未做真机端到端**(线上产物是旧构建;真并行是在单测层用「真 `Loop.Run` × 2 + 网关照同的 ctx 通道」复现的)—— 见 §7.4-24;② 上面 ②「抬高后真机 `compared == scanned`」**尚未被真机证实**(只有单测与量级推算支持);③ MT-B 的入库前审查**未在生产库上跑过**。 - **⚠️ 诚实边界**:① 真并行**未做真机端到端**(线上产物是旧构建;真并行是在单测层用「真 `Loop.Run` × 2 + 网关照同的 ctx 通道」复现的)—— 见 §7.4-24;② 上面 ②「抬高后真机 `compared == scanned`」**尚未被真机证实**(只有单测与量级推算支持);③ MT-B 的入库前审查**未在生产库上跑过**。
@@ -1807,11 +2013,11 @@ curl -s -X POST -H "X-API-Key: $KEY" \
**三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。登记时三批**都还没有实现**;其中**账号在线状态**(`52dac45`)与**非消息事件查询面**(本切片 E6)**已落地**(见下)。 **三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。登记时三批**都还没有实现**;其中**账号在线状态**(`52dac45`)与**非消息事件查询面**(本切片 E6)**已落地**(见下)。
> ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态**(见 §2.4(b))与**非消息事件查询面**(见 §2.4(a))**已落地**;**语音消息**当时未实现,那一批的形状**取自设计文档而非代码核对** —— **v0.79 已把用户可见面落地**(切片 L45,见 §6 v0.79 ③ / §4.5 / §7.2),**残留缺口**(WS 实时帧/握手回放不带 `voice`;AMR 的那一项**已由 v0.80 修复**)仍按「未确证/未实现」读。 > ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态**(见 §2.4(b))与**非消息事件查询面**(见 §2.4(a))**已落地**;**语音消息**当时未实现,那一批的形状**取自设计文档而非代码核对** —— **v0.79 已把用户可见面落地**(切片 L45,见 §6 v0.79 ③ / §4.5 / §7.2),**残留缺口**(WS 实时帧/握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 的那一项**已由 v0.80 修复**)仍按「未确证/未实现」读。
- **纯增量**: - **纯增量**:
① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。 ① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`,`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(见 §3.2、§3.4.4、§4.4 覆盖率的「尚未填充」)。 ② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`,`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(✅ **已由 v0.97(切片 M44)补齐**,见 §3.2、§3.4.4、§4.4 覆盖率)。
③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。 ③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。 ④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。
⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。**已落地**(`owner|admin` 档;元素键名 camelCase,逐字见 §2.4(a);未给窗口 ⇒ 不注入窗口)。 ⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。**已落地**(`owner|admin` 档;元素键名 camelCase,逐字见 §2.4(a);未给窗口 ⇒ 不注入窗口)。
@@ -1906,7 +2112,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
### 7.1 在飞切片 ### 7.1 在飞切片
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)与非消息事件查询面都已落地**(见 §7.2);**语音消息的用户可见面已由 v0.79 落地**(仍残留 WS 帧/握手回放不带 `voice`;AMR 不落副本**已由 v0.80 修复** —— 见 §7.2 / §7.4-29),**不在本表**,逐条见 §7.2。 **v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)与非消息事件查询面都已落地**(见 §7.2);**语音消息的用户可见面已由 v0.79 落地**(WS 帧/握手回放不带 `voice` 的缺口**已由 v0.97(切片 M44)修复**;AMR 不落副本**已由 v0.80 修复** —— 见 §7.2 / §7.4-29),**不在本表**,逐条见 §7.2。
| 项 | 状态 | 冻结位置 | | 项 | 状态 | 冻结位置 |
|---|---|---| |---|---|---|
@@ -1931,7 +2137,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
| 项 | 现状 | | 项 | 现状 |
|---|---| |---|---|
| **✅ v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **用户可见面已实现(v0.79,切片 L45)**:端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5。落地:`documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 0600)、`history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列读写两条路)、网关 DTO `voice`(含唯一派生 `url`)与 `GET /api/v1/media/{name}`。🔴 **残留缺口(如实)**:① **`message_persisted` / `channel_persisted` 帧与握手 `history` 回放仍不带 `voice`**(需给 `history.Message` 加字段,**未做**;且 `message_persisted` 在落库那一刻发出,天生拿不到落库后才回填的值)⇒ **客户端不要在实时帧/握手回放上等 `voice`**;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ **AMR 也落副本**(落盘前转成 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**);③ **消息/会话被删除时音频副本引用计数不被释放**(`refs.go` 只认 `document` 列 ⇒ `ref_count` 偏高,后果是**晚回收**,绝不早删);④ **生产端到端未跑**(未重启后端)。⚠️ 同批的**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)仍**尚未生效**;**已生效**的是 IM 语音「不可转写格式」正文提示改口径(**形状不变**,见 §4.5)。权威设计 `design/24 §10` | | **✅ v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **用户可见面已实现(v0.79,切片 L45)**:端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5。落地:`documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 0600)、`history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列读写两条路)、网关 DTO `voice`(含唯一派生 `url`)与 `GET /api/v1/media/{name}`。🔴 **残留缺口(如实)**:① ✅ **已修(v0.97,切片 M44)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放已带 `voice`**(落库顺序改 INSERT→写列→notify,**首帧即带**,见 §6 v0.97);② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ **AMR 也落副本**(落盘前转成 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**);③ **消息/会话被删除时音频副本引用计数不被释放**(`refs.go` 只认 `document` 列 ⇒ `ref_count` 偏高,后果是**晚回收**,绝不早删);④ ⚠️ **`weixin-ilink` 语音无副本是刻意的**(平台自带转写就不白下载音频),不受 M44 影响。⚠️ 同批的**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)仍**尚未生效**;**已生效**的是 IM 语音「不可转写格式」正文提示改口径(**形状不变**,见 §4.5)。权威设计 `design/24 §10` |
| **✅ v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **已实现**(v0.67 登记项本切片落地):形状与错误码见 §2.4(a)(已按代码核对:元素键名 camelCase、`approx` 与 `counters` 同级、`400 bad_request`、`owner\|admin` 档);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表并回填 §2.1/§2.2 的端点计数 | | **✅ v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **已实现**(v0.67 登记项本切片落地):形状与错误码见 §2.4(a)(已按代码核对:元素键名 camelCase、`approx` 与 `counters` 同级、`400 bad_request`、`owner\|admin` 档);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表并回填 §2.1/§2.2 的端点计数 |
| **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 | | **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 |
| **✅ P1-T:`directMsgPerMin` 的 gateway 写入路径**(`GET /admin/channels` 的 `accounts[].directMsgPerMin` + `PUT .../config` 的顶层 `directMsgPerMin`) | **已实现**(v0.69):缺口 = gateway 面没让该字段通过 ⇒ **面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分**。现 5 处透传(`AdminChannelAccount` DTO / `channelConfigRequest` / `config.ChannelAccount` 拷贝 / `adminChannelAccountOf` / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注 | | **✅ P1-T:`directMsgPerMin` 的 gateway 写入路径**(`GET /admin/channels` 的 `accounts[].directMsgPerMin` + `PUT .../config` 的顶层 `directMsgPerMin`) | **已实现**(v0.69):缺口 = gateway 面没让该字段通过 ⇒ **面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分**。现 5 处透传(`AdminChannelAccount` DTO / `channelConfigRequest` / `config.ChannelAccount` 拷贝 / `adminChannelAccountOf` / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注 |
@@ -1964,7 +2170,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **`task_done` / `automation_result` / `subagent_result` 不是会话消息**:它们**不进消息 store、不是气泡**(后端也没有把它们写成 `chat_messages` 行;`subagent_result` 更是**错过即永久丢**)。客户端应把它们落到自己的可观测面(Web 端落在本端 WS 事件回显 + 任务日志)。 - **`task_done` / `automation_result` / `subagent_result` 不是会话消息**:它们**不进消息 store、不是气泡**(后端也没有把它们写成 `chat_messages` 行;`subagent_result` 更是**错过即永久丢**)。客户端应把它们落到自己的可观测面(Web 端落在本端 WS 事件回显 + 任务日志)。
- `GET /api/v1/files/{name}` **无鉴权**(能力 URL);文档读侧相反、**必须 Bearer**。 - `GET /api/v1/files/{name}` **无鉴权**(能力 URL);文档读侧相反、**必须 Bearer**。
- `GET /admin/llm/usage` **不按 `session_id`** 过滤/分组(尽管该列已落库)。 - `GET /admin/llm/usage` **不按 `session_id`** 过滤/分组(尽管该列已落库)。
- `POST /audio/speech` 的请求体 `voice`/`format` **当前不生效**(合成遵循服务端 `VOICE_TTS_VOICE_ID`/`VOICE_TTS_FORMAT`)。 - `POST /audio/speech` 的请求体 `voice`/`format` **不生效**;**v0.96 起发音人不再是服务端 env 决定**,而是按 `sessionId` 的**生效人格** → `$DATA_DIR/config/voice-tts.json` 的 `personas` 映射(见 §6 v0.96)。产物恒 WAV。
- `/audio/voice-clone` **真建音色**需公网参考音频 URL(当前无,待提供)+ 北京地域 API Key。 - `/audio/voice-clone` **真建音色**需公网参考音频 URL(当前无,待提供)+ 北京地域 API Key。
- 可观测三帧 `observability_*`:`?level=` 分级过滤**由客户端本地做**(帧广播给所有 owner/admin 连接,订阅只决定循环跑不跑)。审计 tab 仍 5s REST 轮询(**未做** `observability_audit` 帧)。 - 可观测三帧 `observability_*`:`?level=` 分级过滤**由客户端本地做**(帧广播给所有 owner/admin 连接,订阅只决定循环跑不跑)。审计 tab 仍 5s REST 轮询(**未做** `observability_audit` 帧)。
- **`thinking` 恒缺席(v0.66 口径更正)**:消息 DTO 的 `thinking` 在**所有**读取面都不出现(`history.Message.Thinking` 全仓无写入者)。思维链只有实时 `thinking.*` 帧(owner/admin)。**旧文档称「历史思维链可由 `GET /sessions/{id}/messages` 返回」是错的**,见 §3.4.4。 - **`thinking` 恒缺席(v0.66 口径更正)**:消息 DTO 的 `thinking` 在**所有**读取面都不出现(`history.Message.Thinking` 全仓无写入者)。思维链只有实时 `thinking.*` 帧(owner/admin)。**旧文档称「历史思维链可由 `GET /sessions/{id}/messages` 返回」是错的**,见 §3.4.4。
@@ -1975,7 +2181,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **账号级限流字段是「值语义」,不是三态(v0.69 明确)**:`groupMsgPerMin`/`msgPerMin`/`maxMessages`/`directMsgPerMin` 在请求体里**省略即 `0`(未设置)**—— 一次「只改 token」的 PUT 若不带这四个键,会把已设限额(含 `directMsgPerMin:-1`)重置为 `0`。要保住就必须回送(面板已如此做)。这是 v0.68 起既有的顶层字段口径,v0.69 **未改**。 - **账号级限流字段是「值语义」,不是三态(v0.69 明确)**:`groupMsgPerMin`/`msgPerMin`/`maxMessages`/`directMsgPerMin` 在请求体里**省略即 `0`(未设置)**—— 一次「只改 token」的 PUT 若不带这四个键,会把已设限额(含 `directMsgPerMin:-1`)重置为 `0`。要保住就必须回送(面板已如此做)。这是 v0.68 起既有的顶层字段口径,v0.69 **未改**。
- **后台思考行的实时帧只带记录里的字段(v0.70 边界)**:`urgency` 在实时帧上一定是**精确档位**(记录落库值),`urgencyLowerBound` / `quietHoursBypassed` **不在记录里**、因此实时帧不带 —— 它们由网关读历史时按安静时段窗口推导(见 §4.10(b))。⇒ 实时行的「穿透安静时段」标记要等重开页面/历史拉取才出现,**这不等于**「这一轮没有穿透」。 - **后台思考行的实时帧只带记录里的字段(v0.70 边界)**:`urgency` 在实时帧上一定是**精确档位**(记录落库值),`urgencyLowerBound` / `quietHoursBypassed` **不在记录里**、因此实时帧不带 —— 它们由网关读历史时按安静时段窗口推导(见 §4.10(b))。⇒ 实时行的「穿透安静时段」标记要等重开页面/历史拉取才出现,**这不等于**「这一轮没有穿透」。
- **`toolCalls` 只在 `GET /sessions/{id}/messages` 填充(v0.78 边界;覆盖率 v0.79 已补齐)**:共享 DTO 上的新键,但**只有该 handler** 会填它;其余出口(`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放)**逐字不变**(有单测钉住)。⚠️ **v0.78 时覆盖率只到真并行路** —— **已由 v0.79(切片 CODE-TC-2)补齐至全部编排回合**;仍缺席的是后台思考会话与 v0.79 之前的单路历史行(见 §4.4 / §7.2)。 - **`toolCalls` 只在 `GET /sessions/{id}/messages` 填充(v0.78 边界;覆盖率 v0.79 已补齐)**:共享 DTO 上的新键,但**只有该 handler** 会填它;其余出口(`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放)**逐字不变**(有单测钉住)。⚠️ **v0.78 时覆盖率只到真并行路** —— **已由 v0.79(切片 CODE-TC-2)补齐至全部编排回合**;仍缺席的是后台思考会话与 v0.79 之前的单路历史行(见 §4.4 / §7.2)。
- **`voice` 只在 4 个 REST 读取面 + `message_edited` 填充(v0.79 边界)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放仍不带 `voice`**(需给 `history.Message` 加字段,未做);且入站语音的 `voice` 列是**落库之后**回填的 ⇒ 即使加了字段,`message_persisted`(在落库那一刻发出)也拿不到值。**客户端不要在实时帧 / 握手回放上等 `voice`**(见 §3.4.4 / §4.4 / §4.5)。 - **`voice` 已在全部读面填充(v0.97 边界)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放自 v0.97(切片 M44)带 `voice`,且首帧即带**(落库顺序 INSERT→写列→notify;即时列读,每帧 +1 次读列、亚毫秒)。`voice_output` 定向回帧不在该批,形状未动(见 §3.4.4 / §4.4 / §4.5)。
- **`memory.review.revert` 是新审计动词(v0.79)**:`design/23 §16.4` 的 `memory.*` 表里还没有它(需设计侧补记);回滚**只记 item id、不记正文**。 - **`memory.review.revert` 是新审计动词(v0.79)**:`design/23 §16.4` 的 `memory.*` 表里还没有它(需设计侧补记);回滚**只记 item id、不记正文**。
- **`web-search` 的「`.env` 只播种」(v0.78 行为变更)**:可变源文件 `$DATA_DIR/config/plugins/web-search.json` 一旦落盘,`.env` 的 `WEB_SEARCH_*` **只在文件缺失时**起作用 ⇒ **改 `.env` 不再影响运行中的部署**(这是 design/09 §5.7 E 要的「`.env` 只播种、面板是配置面」,但运维视角上是行为变更)。🔴 **不是** env 键新增/删除。 - **`web-search` 的「`.env` 只播种」(v0.78 行为变更)**:可变源文件 `$DATA_DIR/config/plugins/web-search.json` 一旦落盘,`.env` 的 `WEB_SEARCH_*` **只在文件缺失时**起作用 ⇒ **改 `.env` 不再影响运行中的部署**(这是 design/09 §5.7 E 要的「`.env` 只播种、面板是配置面」,但运维视角上是行为变更)。🔴 **不是** env 键新增/删除。
- **`channel_event` 是 best-effort 增量提示、且当前只覆盖一路(v0.78 边界)**:帧可能在 hub 发送缓冲满时**丢掉该连接的那一帧** ⇒ 客户端**必须**保留 REST 轮询兜底(以轮询结果为准重排)。且目前**只有账号状态迁移**会产帧(其余渠道事件的实时装饰器**未接线**,见 §7.2);帧里那行的 `id` **可能整缺**(刚落库、id 尚未回读)。 - **`channel_event` 是 best-effort 增量提示、且当前只覆盖一路(v0.78 边界)**:帧可能在 hub 发送缓冲满时**丢掉该连接的那一帧** ⇒ 客户端**必须**保留 REST 轮询兜底(以轮询结果为准重排)。且目前**只有账号状态迁移**会产帧(其余渠道事件的实时装饰器**未接线**,见 §7.2);帧里那行的 `id` **可能整缺**(刚落库、id 尚未回读)。
@@ -2029,7 +2235,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
28. **(v0.78 登记)渠道事件实时面未做真机端到端**:`channel_event` 的「采样 → 落行 → 推帧 → 只投 owner/admin」由**真 SQLite + 真 bus + 真 WS 连接**的单测/端到端用例覆盖,但**运行中的 `cyrene.exe` 未重启**(红线:主会话换产物)⇒ **没有真机日志证据**。且当前 QQ 已停连 ⇒ 若真机启动,`qq-obv11` 账号为 `known=false`,首拍会落一行 `账号状态未知(尚未探到)`。另:**心跳通道(`meta_event.heartbeat`/`lifecycle`)驱动的迁移仍未接**(需适配器把状态投影进状态面,属 `plugins/**`),当前**唯一**迁移来源是探活(`qq_obv11/probe.go`)。 28. **(v0.78 登记)渠道事件实时面未做真机端到端**:`channel_event` 的「采样 → 落行 → 推帧 → 只投 owner/admin」由**真 SQLite + 真 bus + 真 WS 连接**的单测/端到端用例覆盖,但**运行中的 `cyrene.exe` 未重启**(红线:主会话换产物)⇒ **没有真机日志证据**。且当前 QQ 已停连 ⇒ 若真机启动,`qq-obv11` 账号为 `known=false`,首拍会落一行 `账号状态未知(尚未探到)`。另:**心跳通道(`meta_event.heartbeat`/`lifecycle`)驱动的迁移仍未接**(需适配器把状态投影进状态面,属 `plugins/**`),当前**唯一**迁移来源是探活(`qq_obv11/probe.go`)。
29. **(v0.78 登记 · v0.79 更新)语音的端到端未确证**:`internal/runtime/voice/**` 与 `channelbridge` 入站副本链有**真 `transcode.Engine`**(真 AMR 夹具 ⇒ RIFF/WAVE + 时长可确证)的走通用例;**v0.79** 补齐了网关侧(`media_test.go` 用真 `httptest` 真发 `GET`/`Range`/`HEAD`/条件请求,断言响应头与字节逐字相符 ⇒ **端点可用是「真跑过」的**),并新增 `documents.SaveAudio`(含 0600 断言)+ `history` 的 `voice` 列读写。⚠️ **仍未确证**:① **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上有 0600 副本 → 客户端取到字节」只有**替身 + 临时库/临时目录**级证据;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ AMR 转 `.wav` 后落副本 ⇒ 「Web 上能点开听 **AMR** 语音」的**接线**已成立(① 的**生产端到端仍未跑**,未重启后端);③ **`message_persisted` / `channel_persisted` / 握手 `history` 回放不带 `voice`**(见 §4.4 / §7.3);④ `amr` / `flac` / `opus` 在部分浏览器内核上**可播放性未逐一实测**(判据是设计登记的 MDN 规范级结论);⑤ **删除消息/会话时音频副本引用计数不释放**(`ref_count` 偏高 ⇒ 晚回收,绝不早删)。`weixin-ilink` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。 29. **(v0.78 登记 · v0.79 更新)语音的端到端未确证**:`internal/runtime/voice/**` 与 `channelbridge` 入站副本链有**真 `transcode.Engine`**(真 AMR 夹具 ⇒ RIFF/WAVE + 时长可确证)的走通用例;**v0.79** 补齐了网关侧(`media_test.go` 用真 `httptest` 真发 `GET`/`Range`/`HEAD`/条件请求,断言响应头与字节逐字相符 ⇒ **端点可用是「真跑过」的**),并新增 `documents.SaveAudio`(含 0600 断言)+ `history` 的 `voice` 列读写。⚠️ **仍未确证**:① **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上有 0600 副本 → 客户端取到字节」只有**替身 + 临时库/临时目录**级证据;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ AMR 转 `.wav` 后落副本 ⇒ 「Web 上能点开听 **AMR** 语音」的**接线**已成立(① 的**生产端到端仍未跑**,未重启后端);③ ✅ **已修(v0.97,切片 M44)**:`message_persisted` / `channel_persisted` / 握手 `history` 回放**已带 `voice` 且首帧即带**(见 §4.4 / §6 v0.97 / §7.3);④ `amr` / `flac` / `opus` 在部分浏览器内核上**可播放性未逐一实测**(判据是设计登记的 MDN 规范级结论);⑤ **删除消息/会话时音频副本引用计数不释放**(`ref_count` 偏高 ⇒ 晚回收,绝不早删)。`weixin-ilink` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。
30. **(v0.82 登记)`tool_execution.end` 的 `resultSummary` 未做真机端到端**:证据止于**测试进程内**的「真 loop + 真工具 + 真 tracer/flow 图 → 真 bus → 真 relay → 真 hub → **真 WS 连接**」帧(`internal/services/gateway/ws_tool_result_summary_test.go` 用真 `httptest` + `websocket.Dialer` 读回帧),以及 loop 侧「帧上摘要是**从真图快照反读**的同一份文本」的同源钉(`TestRun_ToolEndFrameSummaryMatchesGraphResultSummary`)。⚠️ **仍未确证**:① 对 `:10942` 上的发布产物发一条**真会调工具**的消息、看客户端收到的 `.end` 帧是否带该键(换产物前不可能验);② **失败路径**上「实时帧的错误摘要」与「回看面的图上 `error` 摘要」是否**逐字一致**(同源链是代码级结论:两侧都过 `trace.RedactText`,但没有真机样例);③ 真机上「一次调用拿到超长结果」时帧上那份摘要的长度(代码级是 `RedactText` 的 ≤80 rune;测试造的是 >500 的假值,用来钉网关那道 ≤500 的兜底上限)。**为什么验不了**:本波禁换产物 / 禁重启后端(红线:后端常驻)。 30. **(v0.82 登记)`tool_execution.end` 的 `resultSummary` 未做真机端到端**:证据止于**测试进程内**的「真 loop + 真工具 + 真 tracer/flow 图 → 真 bus → 真 relay → 真 hub → **真 WS 连接**」帧(`internal/services/gateway/ws_tool_result_summary_test.go` 用真 `httptest` + `websocket.Dialer` 读回帧),以及 loop 侧「帧上摘要是**从真图快照反读**的同一份文本」的同源钉(`TestRun_ToolEndFrameSummaryMatchesGraphResultSummary`)。⚠️ **仍未确证**:① 对 `:10942` 上的发布产物发一条**真会调工具**的消息、看客户端收到的 `.end` 帧是否带该键(换产物前不可能验);② **失败路径**上「实时帧的错误摘要」与「回看面的图上 `error` 摘要」是否**逐字一致**(同源链是代码级结论:两侧都过 `trace.RedactText`,但没有真机样例);③ 真机上「一次调用拿到超长结果」时帧上那份摘要的长度(代码级是 `RedactText` 的 ≤80 rune;测试造的是 >500 的假值,用来钉网关那道 ≤500 的兜底上限)。**为什么验不了**:本波禁换产物 / 禁重启后端(红线:后端常驻)。
@@ -2037,4 +2243,4 @@ curl -s -X POST -H "X-API-Key: $KEY" \
--- ---
*本文件由「以代码为真源」维护(当前 `v0.82`;`v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.82`(三面 —— Y1 `resultSummary` / Y3 自排成本可见面(`?trigger=` + `/admin/backthink/self-wake`)/ Y4 `browser` 面板与六个动作)同步了 `internal/runtime/model/stream.go`、`internal/runtime/loop/**`(`tools.go` / `tool_frame_summary.go` / `tool_frame_summary_test.go`)、`internal/services/gateway/**`(`ws_bridge.go` / `ws_tool_result_summary_test.go` / `admin_backthink_filter.go` / `admin_backthink_filter_test.go` / `admin_backthink_selfwake.go` / `admin_query.go` / `routes.go`)、`internal/services/backthink/**`(`backthink.go` / `store.go` / `trigger.go` / `trigger_domain.go` / `selfwake_status.go` 及其测试)、`internal/config/runtimeconfig/backthink.go`、`plugins/tools/browser/**`(`panel.go` / `paneldata.go` / `attach.go` / `tool.go` 及测试)、`internal/capability/browser/**`(`state.go` / `manager.go`)、`internal/runtime/multimodal/vision/**`(`shot.go` / `resolver.go`)、`cmd/cyrene/**`(`wire_browser_panel.go` / `wire_browser_panel_data.go` / `wire_browser_context.go` 及测试)`frontend/web/src/admin/modules/Backthink.tsx`、`frontend/web/src/admin/plugin/nav.ts`、`frontend/web/src/lib/api/adminSystem.ts` 与本文件(§2.0 / §2.1 A13 / §2.2 B8·B19 / §3.4.2 / §4.10(d)(e) / §4.14(a)(d) / §6 / §7.1 / §7.2 / §7.4);`v0.81`(自排轮可见面 + 时间轴 `retentionBoundaryReached` + `round` 跨重启单调)同步了 `internal/services/backthink/**`(`trigger.go` / `selfwake*.go` / `store.go`)、`internal/runtime/flowgraph/**`(`timeline.go` / `timeline_test.go` / `flowgraph.go`)、`internal/services/gateway/**`(`admin_flow_timeline.go` 及测试 / `routes.go`)与本文件(§0.1 / §2.2 B18 / §4.10(d) / §4.11.1 / §6 / §7.1);`v0.80`(三批 —— W1 链路图时间轴出口 / W3 搜索 provider 热重载 + 主 provider·降级链 / W4 入站语音 AMR 转码落副本)同步了 `internal/runtime/flowgraph/**`(`timeline.go` / `timeline_test.go`)、`internal/services/gateway/**`(`admin_flow_timeline.go` / `admin_flow_timeline_test.go` / `history_toolcalls_gap_test.go` / `routes.go`)、`internal/capability/registry/**`(`registry.go` / `replacetool_test.go`)、`plugins/tools/io/search/**`(`panel.go` / `panel_test.go`)、`cmd/cyrene/**`(`websearch_reload.go` / `websearch_reload_test.go` / `websearch_selection.go` / `websearch_selection_test.go` / `websearch_testenv_test.go` / `websearch_action_data.go` / `wire_io_tools.go` / `wire_plugin_actions.go` / `wire_channels.go` / `wire_channels_test.go`)与本文件(§2.0 / §2.2 B18 / §4.1 / §4.5 / §4.11.1 / §4.14 / §6 / §7.1 / §7.2 / §7.3 / §7.4);`v0.79`(四批 —— L1/PLG-2 插件动作执行面接线 / L45 入库前审查管理面 / L45 语音用户可见面 / CODE-TC-2 链路图回合标识覆盖全部编排回合)同步了 `internal/capability/plugin/**`(`actions.go` / `panelactions.go` / `panels.go`)、`internal/services/gateway/**`(`admin_panels.go` / `admin_memory_review.go` / `history.go` / `history_write.go` / `history_voice.go` / `media.go` / `routes.go`)、`internal/runtime/voice/media.go`、`internal/runtime/documents/store_audio.go`、`internal/runtime/history/voice_column.go`、`internal/runtime/loop/**`(`flow.go` / `run.go`)、`internal/runtime/trace/graph.go`、`internal/runtime/flowgraph/flowgraph.go`、`plugins/tools/io/search/**`(`paneldata.go` / `probe.go`)、`cmd/cyrene/**`(`wire_plugin_actions.go` / `websearch_action_data.go` / `websearch_probe.go` / `wire_frontend.go`)与本文件(§0.3 / §1.2 U19 / §2.0 / §2.1 A13-A14 / §2.2 B16-B17 / §3.4.4 / §4.1 / §4.4 / §4.5 / §4.11 / §4.14 / §6 / §7.2 / §7.3 / §7.4);`v0.78`(六面 —— CODE-TC `toolCalls[]` / PLG-2 插件面板接入点 / PLG-4 搜索 provider 可变源 / E7 账号状态事件与 `channel_event` / CODE-F3 iLink 凭据三态 / CODE-VOICE 语音落库面)同步了 `internal/db/migrations/0023_flow_graphs_lane_turn_id.*`、`internal/db/migrations/0020_chat_message_voice.*`、`internal/runtime/flowgraph/**`、`internal/services/gateway/**`(`history.go` / `history_toolcalls.go` / `contracts.go` / `admin_panels.go` / `admin_channels_weixin.go` / `admin_channel_status.go` / `ws_channel_events.go` / `ws_bridge.go` / `ws_hub.go` / `routes.go`)、`internal/capability/plugin/**`(`panels.go` / `manager.go`)、`internal/capability/paneldecl/**`、`internal/config/runtimeconfig/websearch.go`、`internal/config/websearch.go`、`plugins/tools/io/search/**`(`panel.go` / `paneldata.go` / `probe.go`)、`plugins/channels/weixin_ilink/**`(`session_status.go` / `poll.go` / `send.go`)、`internal/runtime/events/**`、`interLine truncated *本文件由「以代码为真源」维护(当前 `v0.97`;`v0.96` ~ `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.97`(三面 —— M44 `voice` 进 WS 实时帧/握手回放 / M47 emotionTTS 自启(默认关)/ M49+M50+M48+M52 上游错误出口「构造即安全」)同步了 `internal/services/gateway/**`(`broadcast.go` / `history_voice.go` / `ws_history.go` 及 `broadcast_voice_test.go`)、`internal/services/channelbridge/**`(`persist.go` / `inbound_voice_copy.go` / `coalesce_batch.go` 及 `inbound_voice_gateway_e2e_test.go`)、`cmd/cyrene/**`(`voice_tts_launch.go` 及测试 / `main.go`)、`internal/config/runtimeconfig/voice_tts_launch.go`、`internal/runtime/voice/ttslaunch/**`、`internal/runtime/trace/**`(`upstream.go` 等)与本文件(§3.2 / §4.4 / §4.5 / §6 / §7.2 / §7.4);`v0.96` ~ `v0.82` 各版的文件级范围见各自 §6 条目;更早版本(`v0.81` ~ `v0.68`)的文件级范围按原记录保留如下:`v0.81` 同步了 backthink/flowgraph/gateway 的自排轮可见面;`v0.80` 同步了 flowgraph timeline、websearch 热重载、wire_channels 的 VoiceTranscoder 注入;`v0.79` 同步了 plugin actions、memory review、history_voice、media、CODE-TC-2;`v0.78` 同步了迁移 0020/0023、voice 包、channelbridge inbound_voice、panels、iLink 三态;`v0.77` 同步了 trace graph 分桶、loop/flow、memory tidy、admin_flow;`v0.76` 的未读面同步了 broadcast/session_read;`v0.75` ~ `v0.71` 同步了 memory tidy、proactive、flowgraph、loop 各面(详见归档)。*
+63 -2
View File
@@ -90,11 +90,62 @@ func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log *
log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr) log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr)
continue continue
} }
logFlowControl(log, built)
out = append(out, built) out = append(out, built)
} }
return out return out
} }
// logFlowControl 逐账号打一条启动日志,如实报出这个账号此刻被限成什么样(M24 裁决 A):
// 闸是否开、每群几条/分、待处理上限、审查触发水位(reviewAt)。
//
// 为什么需要它:namedAdapter 的 Config() 无条件暴露四个流控键(含零值),桥的 cfgInt
// 因此永远认为「键存在」,flowOrDefault 再把 0 映射成保护性默认 —— 于是「一个流控键都
// 没写的账号,闸门仍然是开着的」。这条事实必须一眼可见,否则「群里为什么有消息被丢」
// 只能靠读代码解释。判据:只看这一行日志就能说出这个群的限流/审查额度。
//
// 判定依据是**构建出来的那个适配器本身**,不是重新读一遍配置:非 namedAdapter 的实例
// 不暴露流控键,桥按 flowgate.go「键缺失 ⇒ 该限制关闭」处理;这时必须如实报 off,
// 不能拿保护性默认值假装成「开着」。
func logFlowControl(log *slog.Logger, built adapter.PlatformAdapter) {
if log == nil {
log = slog.Default()
}
log = logger.WithSource(log, "cmd/cyrene/channels")
n, ok := built.(*namedAdapter)
if !ok {
log.Info("channel account ingress flow control",
"account", built.Name(), "gate", "off",
"why", "adapter exposes no flow keys, every limit stays disabled")
return
}
group := flowLimitOrDefault(n.groupMsgPerMin, adapter.FlowLimitDefault)
direct := flowLimitOrDefault(n.directMsgPerMin, adapter.FlowDirectDefault)
pending := flowLimitOrDefault(n.maxMessages, adapter.FlowCapDefault)
log.Info("channel account ingress flow control",
"account", n.Name(), "gate", "on",
"group_msg_per_min", group, "group_msg_per_min_raw", n.groupMsgPerMin,
"direct_msg_per_min", direct, "direct_msg_per_min_raw", n.directMsgPerMin,
"msg_per_min", max(n.msgPerMin, 0),
"max_messages", pending, "max_messages_raw", n.maxMessages,
"review_at", adapter.ReviewAtFor(pending),
"note", "raw 0 (or absent in channels.json) takes the protective default; negative disables")
}
// flowLimitOrDefault 把账号原始值映射为实际生效值:负值关闭该限制、零值取保护性默认、
// 正值固定。它与 internal/services/channelbridge/flowgate.go 的 flowOrDefault 逐字同
// 口径,这里只用于上面那条启动日志 —— 日志必须报生效值,不能报原始值。
func flowLimitOrDefault(v, def int) int {
switch {
case v < 0:
return 0
case v == 0:
return def
default:
return v
}
}
// channelAccountsFromEnv 合成 env 兜底:单账号 CHANNEL_<NAME>_* 适配器,加上 // channelAccountsFromEnv 合成 env 兜底:单账号 CHANNEL_<NAME>_* 适配器,加上
// CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给 // CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给
// runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底 // runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底
@@ -174,8 +225,18 @@ type namedAdapter struct {
denyGroups []string denyGroups []string
allowUsers []string allowUsers []string
denyUsers []string denyUsers []string
// 流控限制(design/12 §6 策略三/四)。它们始终经 Config() 暴露,使桥能构建按账号 // 流控限制(design/12 §6 策略三/四)。**四个键始终经 Config() 暴露,包括零值**:
// 的入口闸门;桥把零值默认映射为保护性取值,把负值映射为「关闭」。 // 桥的 cfgInt 因此永远认为「键存在」,flowOrDefault 于是把 0 映射成保护性默认
// (每群 6 条/分、待处理上限 50、reviewAt = 40)—— 一个在 channels.json 里一个
// 流控键都没写的账号,闸门**仍然是开着的**(M24 只读勘测实测:现存 qq 账号就是
// 这种状态)。负值经桥映射为「关闭」。
//
// 边界的准确说法(M24):flowgate.go 那句「键缺失 ⇒ 该限制关闭」的向后兼容分支,
// 对 CHANNELS_JSON 账号**不可达** —— config.ChannelAccount 用的是普通 int
// (omitempty),「未配置」与「显式配 0」解出来都是 0,这里无从区分。要区分就得
// 改 config 的类型(*int / 存在性表)或在本题二次解析原始 JSON;而那样做会改掉
// 「闸门默认开还是关」这个产品口径(群聊限流/审查默认从开变关),属主人裁决,
// 不在本处自行翻转。见 M24 回报里的选项 A/B/C。
groupMsgPerMin int groupMsgPerMin int
directMsgPerMin int directMsgPerMin int
msgPerMin int msgPerMin int
+7
View File
@@ -125,6 +125,13 @@ func run() error {
return fmt.Errorf("start gateway: %w", err) return fmt.Errorf("start gateway: %w", err)
} }
// emotionTTS 自启(切片 M47):**在网关 Listen 之后**才挂 —— 网关的
// ListenAndServe 已交给自己的 goroutine,这里只投递一次「探活 → 必要时拉起」,
// 整段(毫秒级探活 + 起一个 .bat)都在后台跑,因此**不占**后端启动时长:走到
// 这里时进程已经可以服务。开关默认关(面板文件 voice-tts-launch.json),
// 失败只 WARN,绝不影响后端启动与退出。
a.startVoiceTTSLaunch(ctx)
select { select {
case <-ctx.Done(): case <-ctx.Done():
a.log.Info("cyrene signal received, shutting down") a.log.Info("cyrene signal received, shutting down")
+154
View File
@@ -0,0 +1,154 @@
package main
// 本文件把**后端启动时顺带照顾 emotionTTS**(切片 M47)接进组合根,并守住三条
// 红线(塔 2026-10 已定,主人原话见 internal/runtime/voice/ttslaunch 包注释):
//
// 1. **默认关**:面板文件 `$DATA_DIR/config/voice-tts-launch.json` 没写
// `{"enabled": true}` 时,这里**连探活都不发**、也不构造任何后台任务 ——
// 主人早前说过「开机自启先不急 后面做」。
// 2. **不占启动时长**:`Trigger` 把探活 + 起脚本整段丢进后台 goroutine 后立即
// 返回,调用点在网关 Listen 之后(见 main.run);探活超时/脚本拉起耗时都
// 落不到后端的启动路径上。
// 3. **失败只 WARN**:脚本不存在、起进程失败、探活超时都只是带 source 的日志,
// 本函数**没有 error 返回**,绝不阻塞或改坏后端启动。
// 4. **探活地址单向回退**(塔 2026-10-26 裁决,见 resolveProbeURL):launch 文件
// 显式写了 `probeUrl` 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者
// 都空才是默认 9880。反向(拿这里的地址去改合成面)一律不做。
//
// 零新 `.env` 键(`.env` 已冻结):开关与脚本路径都走面板文件家族
// (`runtimeconfig`,与 `voice-tts.json` 同一套读取口径)。
import (
"context"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
)
// voiceTTSLaunchSource 是本切片日志的来源标签(AGENTS §10:日志必须标来源)。
const voiceTTSLaunchSource = "cmd/cyrene/voice-tts-launch"
// startVoiceTTSLaunch 读面板配置,按需投递**一次**「探活 → 必要时拉起」。
//
// 返回 nil 表示这一级没启用(没配过 / 显式 false / 文件坏掉),调用方据此什么都
// 不做;返回非 nil 仅为了让调用方在关闭期或验收里能 `Wait()`(生产路径忽略它)。
func (a *app) startVoiceTTSLaunch(ctx context.Context) *ttslaunch.Launcher {
log := logger.WithSource(a.log, voiceTTSLaunchSource)
path := runtimeconfig.DefaultVoiceTTSLaunchPath(resolveDataDir(a.cfg))
tuning, err := runtimeconfig.LoadVoiceTTSLaunchTuning(path)
if err != nil {
// 坏文件 ⇒ **不启用**:一份读不懂的配置绝不能变成"随手拉起一个进程"。
log.Warn("voice tts launch config unreadable, emotionTTS auto-launch stays off",
"path", runtimeconfig.VoiceTTSLaunchConfigRelPath, "err", err)
return nil
}
if !tuning.EnabledOn() {
log.Debug("emotionTTS auto-launch not enabled in panel config",
"path", runtimeconfig.VoiceTTSLaunchConfigRelPath, "hint", `set {"enabled": true}`)
return nil
}
resolved := a.resolveProbeURL(tuning.ProbeURL)
if err := validateResolvedProbeURL(resolved); err != nil {
// 🔴 写错地址(漏 scheme / 主机拼错)会让"没起"永远成立 ⇒ **每次启动都拉一次
// 进程**。宁可不启用,也不做这种自动重试机器(复审 P2-2b)。
log.Warn("emotionTTS auto-launch stays off: invalid probe url",
"probe_url", resolved.url,
"probe_url_source", resolved.probeSource,
"err", err)
return nil
}
launcher := ttslaunch.New(withProbeURL(voiceTTSLaunchConfig(tuning), resolved), ttslaunch.WithLogger(log))
cfg := launcher.Config()
log.Info("emotionTTS auto-launch enabled",
"probe_url", cfg.ProbeURL,
// 探活地址可能来自三处,日志必须**同时**给出地址与来源:主人改端口后
// 一眼能看出实际问的是哪个地址、以及它是显式写的还是兜底来的。
"probe_url_source", resolved.probeSource,
"script", cfg.ScriptPath,
"probe_timeout_ms", cfg.ProbeTimeout.Milliseconds(),
"cooldown_sec", int(cfg.Cooldown/time.Second),
"config", runtimeconfig.VoiceTTSLaunchConfigRelPath)
// 立即返回:探活与起脚本都在后台跑(见 ttslaunch.Launcher.Trigger)。
launcher.Trigger(ctx)
return launcher
}
// probeResolution 是"探活地址 + 它的来源"(来源只用于日志,绝不参与判定)。
type probeResolution struct {
url string
probeSource string
}
// 探活地址的三个来源标签(写进启动 Info 行,供主人一眼核对)。
const (
probeSourceLaunchConfig = "voice-tts-launch.json"
probeSourceVoiceTTS = "voice-tts.json"
probeSourceDefault = "default"
)
// resolveProbeURL 决定这次到底探哪个地址 —— **单向**回退,且只在组合根发生:
//
// 1. `voice-tts-launch.json` 显式写了 `probeUrl` ⇒ 用它(**永不**被别的键压过);
// 2. 没写 ⇒ 兜底用 `voice-tts.json` 的 `baseUrl`(同一个服务,主人换端口只该改
// 一处;这正是"两处一起改"的 footgun 的收口);
// 3. 两者都没写(或 voice-tts.json 坏了)⇒ 空串,由 `ttslaunch.Config.Normalized`
// 补默认 `http://127.0.0.1:9880`。
//
// 🔴 只做这一向:反向(拿 launch 的地址去改 `voice-tts.json` 的 `baseUrl`,或用它
// 顶替合成地址)一律不做 —— 合成面与"起没起"是两个问题,前者不能被后者的配置影响。
func (a *app) resolveProbeURL(explicit string) probeResolution {
if url := strings.TrimSpace(explicit); url != "" {
return probeResolution{url: url, probeSource: probeSourceLaunchConfig}
}
log := logger.WithSource(a.log, voiceTTSLaunchSource)
tuning, err := runtimeconfig.LoadVoiceTTSTuning(runtimeconfig.DefaultVoiceTTSPath(resolveDataDir(a.cfg)))
if err != nil {
// 坏文件 ⇒ 这里只回落默认地址(**不**因此关掉自启:那是 launch 文件自己的
// 事),并且必须说清楚"我没读到 baseUrl"。
log.Warn("voice tts config unreadable, probe url falls back to default",
"path", runtimeconfig.VoiceTTSConfigRelPath, "err", err)
return probeResolution{probeSource: probeSourceDefault}
}
if base := strings.TrimSpace(tuning.BaseURL); base != "" {
log.Debug("probe url inherited from voice tts config",
"path", runtimeconfig.VoiceTTSConfigRelPath, "base_url", base)
return probeResolution{url: base, probeSource: probeSourceVoiceTTS}
}
return probeResolution{probeSource: probeSourceDefault}
}
// withProbeURL 把解析出的探活地址填进 launcher 配置(空串留给 Normalized 补默认)。
func withProbeURL(cfg ttslaunch.Config, r probeResolution) ttslaunch.Config {
cfg.ProbeURL = r.url
return cfg
}
// validateResolvedProbeURL 校验**最终要探的那个地址**(空串 = 两个文件都没给,由
// Normalized 补默认 9880,默认值本身合法,这里不重复校验)。
//
// 它和 `ttslaunch.New` 里的那道校验是同一把闸,但这里多做一件事:WARN 带上
// `probe_url_source` —— 主人一眼能看出该改哪个文件(launch 文件的 probeUrl,
// 还是 voice-tts.json 的 baseUrl)。
func validateResolvedProbeURL(r probeResolution) error {
if r.url == "" {
return nil
}
return ttslaunch.ValidateProbeURL(r.url)
}
// voiceTTSLaunchConfig 把面板形状映射成 launcher 的配置。
//
// 它**不**在这里裁决缺省值(脚本路径/探活地址/超时/冷却的默认值全在
// `ttslaunch.Config.Normalized` 一处):零值原样传下去,由那一处补齐。
func voiceTTSLaunchConfig(t runtimeconfig.VoiceTTSLaunchTuning) ttslaunch.Config {
return ttslaunch.Config{
Enabled: t.EnabledOn(),
ScriptPath: t.ScriptPath,
ProbeURL: t.ProbeURL,
ProbeTimeout: time.Duration(t.ProbeTimeoutMs) * time.Millisecond,
Cooldown: time.Duration(t.CooldownSec) * time.Second,
}
}
+150
View File
@@ -0,0 +1,150 @@
package main
// 本文件锁定复审 P2-2 在**组合根**上的两道闸:
//
// 1. **关闭期取消 ⇒ 0 次拉起**(P2-2a):后端关闭时 ctx 被取消,探活随之返回
// `context.Canceled`,这必须是"跳过",绝不能在关闭过程中拉起 TTS;
// 2. **探活地址非法 ⇒ 不启用、一次都不拉**(P2-2b):`probeUrl` 与 `voice-tts.json`
// 的 `baseUrl` 兜底**两个来源**都要被挡(漏一个就等于绕过闸门,变成"每次启动
// 都拉一次进程")。
//
// 用例除了看返回值,还会真等一会儿确认没有进程被拉起(拉起是异步的,脚本一跑
// 就会落 marker 文件);探活用黑洞监听(只接连接不回话),保证取消一定落在探活
// 进行中。
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
)
// TestStartVoiceTTSLaunch_ShutdownCancelDoesNotLaunch 钉住"关闭期取消 ⇒ 0 次拉起"。
//
// 变异点:去掉 ttslaunch 里 `errors.Is(probeErr, context.Canceled)` 那个跳过分支 ⇒
// 取消被当成"没起" ⇒ 脚本被拉起(marker 出现),本用例红。
func TestStartVoiceTTSLaunch_ShutdownCancelDoesNotLaunch(t *testing.T) {
dir := t.TempDir()
blackhole, addr := blackholeListener(t)
defer blackhole.Close()
a, buf := launchTestApp(t, dir)
script, marker := markerBat(t, dir)
writeLaunchPanel(t, dir, fmt.Sprintf(
`{"enabled":true,"probeUrl":%q,"probeTimeoutMs":5000,"scriptPath":%q}`,
"http://"+addr+"/", script))
ctx, cancel := context.WithCancel(context.Background())
launcher := a.startVoiceTTSLaunch(ctx)
if launcher == nil {
t.Fatal("启用后应拿到 launcher")
}
// 模拟后端关闭:探活还挂在黑洞上时取消。
cancel()
launcher.Wait()
assertNotLaunched(t, marker)
if out := buf.String(); strings.Contains(out, "emotionTTS launch failed") {
t.Fatalf("关闭期取消被记成了拉起失败: %s", out)
}
}
// TestStartVoiceTTSLaunch_InvalidProbeURLStaysOff 钉住第一道闸:launch 文件里写了
// 非法 `probeUrl` ⇒ 返回 nil(不构造 launcher)、WARN 标出源、**一次都不拉**。
//
// 变异点:去掉 `validateResolvedProbeURL` ⇒ 返回非 nil,且探活每轮必败 ⇒ 脚本被
// 拉起(marker 出现),两条断言都会红。
func TestStartVoiceTTSLaunch_InvalidProbeURLStaysOff(t *testing.T) {
dir := t.TempDir()
a, buf := launchTestApp(t, dir)
script, marker := markerBat(t, dir)
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":"127.0.0.1:9880","scriptPath":%q}`, script))
launcher := a.startVoiceTTSLaunch(context.Background())
if launcher != nil {
t.Fatal("非法 probeUrl 仍启用了自启(会每次启动都拉一次进程)")
}
assertNotLaunched(t, marker)
out := buf.String()
if !strings.Contains(out, "invalid probe url") {
t.Fatalf("非法 probeUrl 没记 WARN: %s", out)
}
if !strings.Contains(out, "probe_url_source=voice-tts-launch.json") {
t.Fatalf("WARN 没标出该改哪个文件: %s", out)
}
}
// TestStartVoiceTTSLaunch_InvalidBaseURLFallbackStaysOff 钉住第二道闸:launch 文件
// 没写 probeUrl、而 `voice-tts.json` 的 baseUrl 非法 ⇒ 同样不启用、一次都不拉。
func TestStartVoiceTTSLaunch_InvalidBaseURLFallbackStaysOff(t *testing.T) {
dir := t.TempDir()
a, buf := launchTestApp(t, dir)
script, marker := markerBat(t, dir)
writeVoiceTTSPanel(t, a, `{"enabled":true,"baseUrl":"127.0.0.1:9880"}`)
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`, script))
launcher := a.startVoiceTTSLaunch(context.Background())
if launcher != nil {
t.Fatal("baseUrl 兜底来的非法地址也没被挡住(每次启动都会拉一次进程)")
}
assertNotLaunched(t, marker)
out := buf.String()
if !strings.Contains(out, "invalid probe url") {
t.Fatalf("非法 baseUrl 没记 WARN: %s", out)
}
if !strings.Contains(out, "probe_url_source=voice-tts.json") {
t.Fatalf("WARN 没标出该改哪个文件: %s", out)
}
}
// TestValidateResolvedProbeURL_EmptyMeansDefault 钉住"空串不在这里判死":两个文件
// 都没给地址时交给 Normalized 补 9880(默认值本身合法)。
func TestValidateResolvedProbeURL_EmptyMeansDefault(t *testing.T) {
if err := validateResolvedProbeURL(probeResolution{url: "", probeSource: probeSourceDefault}); err != nil {
t.Fatalf("空地址应交给默认值: %v", err)
}
if err := validateResolvedProbeURL(probeResolution{url: ttslaunch.DefaultProbeURL}); err != nil {
t.Fatalf("默认地址必须合法: %v", err)
}
if err := validateResolvedProbeURL(probeResolution{url: "localhost:9880"}); err == nil {
t.Fatal("漏 scheme 的地址被放过了")
}
}
// markerBat 造一个"跑起来就落 marker"的 .bat,用于观察"到底有没有进程被拉起"。
// 非 Windows 返回空串(那台机器上这个脚本本来也拉不起来),调用方据此跳过观察。
func markerBat(t *testing.T, dir string) (script, marker string) {
t.Helper()
if runtime.GOOS != "windows" {
return "", ""
}
script = filepath.Join(dir, "一键启动.bat")
marker = filepath.Join(dir, "launched.txt")
body := fmt.Sprintf("@echo off\r\necho launched> \"%s\"\r\n", marker)
if err := os.WriteFile(script, []byte(body), 0o600); err != nil {
t.Fatalf("write script: %v", err)
}
return script, marker
}
// assertNotLaunched 等一小会儿确认脚本**没有**被拉起(拉起是异步的,所以不能只看
// 返回值;空 marker = 非 Windows,跳过观察)。
func assertNotLaunched(t *testing.T, marker string) {
t.Helper()
if marker == "" {
return
}
deadline := time.Now().Add(600 * time.Millisecond)
for time.Now().Before(deadline) {
if _, err := os.Stat(marker); err == nil {
t.Fatalf("脚本被拉起了: %s", marker)
}
time.Sleep(50 * time.Millisecond)
}
}
+288
View File
@@ -0,0 +1,288 @@
package main
// 本文件锁定切片 M47 在**组合根**上的口径(都是"改坏就静默开错/静默拖慢启动"的那种):
//
// 1. 面板文件没配过 / 写了 `enabled:false` ⇒ **不启用**(返回 nil),连探活都不发;
// 2. 启用后 `startVoiceTTSLaunch` **立即返回**,即使探活会挂住整整一个超时 ——
// 证明 TTS 的延迟不计入后端启动时长(它是唯一"启动耗时"可测的量);
// 3. 拉不起来(脚本不存在)⇒ 只有 WARN,**没有任何 error 回流**到启动路径。
// 4. 探活地址**单向**回退(塔 2026-10-26 裁决):launch 文件显式写了 `probeUrl`
// 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者都空才是默认 9880。
// 启动 Info 行必须同时给出地址与来源。
//
// 它用 `DefaultVoiceTTSLaunchPath` 的真实落点
// ($DATA_DIR/config/voice-tts-launch.json)跑,因此也钉住"零新 .env 键"。
import (
"bytes"
"context"
"fmt"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
)
// captureBuf 是并发安全的日志缓冲(WARN 由后台 goroutine 写)。
type captureBuf struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *captureBuf) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *captureBuf) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// launchTestApp 造一个只够本题用的 app(数据目录 + 收日志的 logger)。
func launchTestApp(t *testing.T, dataDir string) (*app, *captureBuf) {
t.Helper()
buf := &captureBuf{}
log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
return &app{cfg: &config.Config{DataDir: dataDir}, log: log}, buf
}
// writeLaunchPanel 把面板文件写到真实落点($DATA_DIR/config/voice-tts-launch.json)。
func writeLaunchPanel(t *testing.T, dataDir, body string) {
t.Helper()
path := runtimeconfig.DefaultVoiceTTSLaunchPath(dataDir)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write panel: %v", err)
}
}
// TestStartVoiceTTSLaunch_DefaultOff 钉住"默认关":没配过 ⇒ 不构造、不探活、不拉起。
func TestStartVoiceTTSLaunch_DefaultOff(t *testing.T) {
a, buf := launchTestApp(t, t.TempDir())
start := time.Now()
if l := a.startVoiceTTSLaunch(context.Background()); l != nil {
t.Fatal("没配过却启用了自启")
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("默认关也花了 %v", elapsed)
}
if !strings.Contains(buf.String(), "not enabled") {
t.Fatalf("没留一条「没启用」的痕迹: %s", buf.String())
}
}
// TestStartVoiceTTSLaunch_ExplicitFalse 钉住"显式 false 同样一次都不拉"。
func TestStartVoiceTTSLaunch_ExplicitFalse(t *testing.T) {
dir := t.TempDir()
writeLaunchPanel(t, dir, `{"enabled":false}`)
a, _ := launchTestApp(t, dir)
if l := a.startVoiceTTSLaunch(context.Background()); l != nil {
t.Fatal("enabled:false 却启用了自启")
}
}
// TestStartVoiceTTSLaunch_ReturnsBeforeProbe 是本切片的"启动耗时"证明:
// 探活会挂住一个 800ms 的超时(对端只接连接不回话),而挂载动作必须在毫秒级返回。
//
// 同时它顺带钉住第 3 条:脚本不存在 ⇒ 后台只记 WARN,`startVoiceTTSLaunch` 自己
// 没有任何 error 可返回(签名里就没有),后端启动路径不受影响。
func TestStartVoiceTTSLaunch_ReturnsBeforeProbe(t *testing.T) {
dir := t.TempDir()
blackhole, addr := blackholeListener(t)
defer blackhole.Close()
writeLaunchPanel(t, dir, fmt.Sprintf(
`{"enabled":true,"probeUrl":%q,"probeTimeoutMs":800,"scriptPath":%q}`,
"http://"+addr+"/", filepath.Join(dir, "不存在的一键启动.bat")))
a, buf := launchTestApp(t, dir)
start := time.Now()
launcher := a.startVoiceTTSLaunch(context.Background())
elapsed := time.Since(start)
if launcher == nil {
t.Fatal("启用后应拿到 launcher")
}
if elapsed > 150*time.Millisecond {
t.Fatalf("挂载花了 %v —— 探活被同步等在了启动路径上", elapsed)
}
// 后台那次探活会等到 800ms 超时,然后去拉一个不存在的脚本。
launcher.Wait()
out := buf.String()
if !strings.Contains(out, "level=WARN") || !strings.Contains(out, "backend startup unaffected") {
t.Fatalf("拉起失败没有只记 WARN: %s", out)
}
}
// TestVoiceTTSLaunchConfig_MapsTuning 钉住"面板字段 → launcher 配置"的映射:
// 缺省值不由组合根裁决(空字段原样传下去,由 ttslaunch.Config.Normalized 补)。
func TestVoiceTTSLaunchConfig_MapsTuning(t *testing.T) {
enabled := true
cfg := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{
Enabled: &enabled,
ScriptPath: `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`,
ProbeURL: "http://127.0.0.1:9880",
ProbeTimeoutMs: 250,
CooldownSec: 90,
})
if !cfg.Enabled {
t.Fatal("enabled 没映射过来")
}
if cfg.ProbeTimeout != 250*time.Millisecond || cfg.Cooldown != 90*time.Second {
t.Fatalf("超时/冷却没映射: %+v", cfg)
}
if cfg.ScriptPath != ttslaunch.DefaultScriptPath || cfg.ProbeURL != ttslaunch.DefaultProbeURL {
t.Fatalf("路径/地址没映射: %+v", cfg)
}
// 空配置 ⇒ 不启用,且一个缺省值都不在这里填(Normalized 才是唯一裁决处)。
if empty := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{}); empty.Enabled {
t.Fatal("空配置不该启用")
} else if empty.ScriptPath != "" || empty.ProbeURL != "" || empty.ProbeTimeout != 0 {
t.Fatalf("组合根不该替 launcher 填缺省值: %+v", empty)
}
}
// blackholeListener 起一个"只接连接、绝不回话"的回环监听:探活会一直等到自己的
// 超时,正好用来证明挂载动作没有同步等它。
func blackholeListener(t *testing.T) (net.Listener, string) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
var conns []net.Conn
var mu sync.Mutex
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
mu.Lock()
conns = append(conns, c)
mu.Unlock()
}
}()
t.Cleanup(func() {
mu.Lock()
defer mu.Unlock()
for _, c := range conns {
_ = c.Close()
}
})
return ln, ln.Addr().String()
}
// TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS 钉住**单向回退**的第一向:
// launch 文件没写 `probeUrl` 时,用 `voice-tts.json` 的 `baseUrl`(主人换端口只该
// 改一处)。
//
// 它不只比字符串 —— 真让探活打到那个端口(hits > 0):把回退摘掉,探活就会跑去
// 默认 9880,本用例立刻红。
func TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS(t *testing.T) {
dir := t.TempDir()
voiceTTS, hits := countingServer(t)
a, buf := launchTestApp(t, dir)
// 合成面把端口改成了自定义端口;自启面**没写** probeUrl。
writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q,"personas":{"cyrene":"char_1"}}`, voiceTTS.URL))
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`,
filepath.Join(dir, "不存在的一键启动.bat")))
launcher := a.startVoiceTTSLaunch(context.Background())
if launcher == nil {
t.Fatal("启用后应拿到 launcher")
}
if got := launcher.Config().ProbeURL; got != voiceTTS.URL {
t.Fatalf("探活地址 = %q, want voice-tts.json 的 baseUrl %q", got, voiceTTS.URL)
}
launcher.Wait()
if n := atomic.LoadInt32(hits); n == 0 {
t.Fatal("探活没打到 voice-tts.json 的 baseUrl 端口(回退失效)")
}
if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts.json") {
t.Fatalf("启动 Info 行没标出地址是兜底来的: %s", out)
}
}
// TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault 钉住第二向:两个文件都没给
// 地址 ⇒ 默认 `http://127.0.0.1:9880`,且日志标 `default`。
func TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault(t *testing.T) {
dir := t.TempDir()
a, buf := launchTestApp(t, dir)
// 只有自启文件,没有 voice-tts.json。
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`,
filepath.Join(dir, "不存在的一键启动.bat")))
launcher := a.startVoiceTTSLaunch(context.Background())
if launcher == nil {
t.Fatal("启用后应拿到 launcher")
}
if got := launcher.Config().ProbeURL; got != ttslaunch.DefaultProbeURL {
t.Fatalf("探活地址 = %q, want 默认 %q", got, ttslaunch.DefaultProbeURL)
}
launcher.Wait()
if out := buf.String(); !strings.Contains(out, "probe_url_source=default") {
t.Fatalf("启动 Info 行没标出地址用的是默认值: %s", out)
}
}
// TestStartVoiceTTSLaunch_ExplicitProbeURLWins 钉住"显式压过兜底":launch 文件写了
// `probeUrl` ⇒ 用它,**永不**去问 voice-tts.json 的 baseUrl(后者一次都不该被探)。
func TestStartVoiceTTSLaunch_ExplicitProbeURLWins(t *testing.T) {
dir := t.TempDir()
explicit, explicitHits := countingServer(t)
inherited, inheritedHits := countingServer(t)
a, buf := launchTestApp(t, dir)
writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q}`, inherited.URL))
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":%q,"scriptPath":%q}`,
explicit.URL, filepath.Join(dir, "不存在的一键启动.bat")))
launcher := a.startVoiceTTSLaunch(context.Background())
if launcher == nil {
t.Fatal("启用后应拿到 launcher")
}
if got := launcher.Config().ProbeURL; got != explicit.URL {
t.Fatalf("探活地址 = %q, want 显式 %q", got, explicit.URL)
}
launcher.Wait()
if atomic.LoadInt32(explicitHits) == 0 {
t.Fatal("显式 probeUrl 没被用来探活")
}
if n := atomic.LoadInt32(inheritedHits); n != 0 {
t.Fatalf("显式 probeUrl 仍去问了 voice-tts.json 的地址 %d 次", n)
}
if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts-launch.json") {
t.Fatalf("启动 Info 行没标出地址是显式配置的: %s", out)
}
}
// countingServer 起一个"数请求次数"的 httptest 服务:用来证明探活**真的打到了**
// 某个地址,而不只是配置里写着那个字符串。
func countingServer(t *testing.T) (*httptest.Server, *int32) {
t.Helper()
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
return srv, &hits
}
+85 -1
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
@@ -108,6 +109,18 @@ func (a *app) wireChannels() error {
// 入站附件的目录口径与文档摄取层(wire_runtime.go 的 docResolver)同源, // 入站附件的目录口径与文档摄取层(wire_runtime.go 的 docResolver)同源,
// 两处必须指向同一棵目录树,否则附件会落在一个地方、抽取去另一个地方找。 // 两处必须指向同一棵目录树,否则附件会落在一个地方、抽取去另一个地方找。
docDataDir := resolveDataDir(a.cfg) docDataDir := resolveDataDir(a.cfg)
// owner 车道结构化旋钮(design/18 §11「乙 = C」)的落盘面:主人在群里说「少说点」
// 这类诉求被写成 $DATA_DIR/config/owner_lane.json(可读可改,面板后续照此写入)。
// 它只承载「说不说 / 多频繁」,不承载任何身份/权限信息。
ownerLane := runtimeconfig.NewOwnerLaneStore(
runtimeconfig.DefaultOwnerLanePath(docDataDir),
logger.WithSource(a.log, "services/channelbridge/ownerlane"))
// 静默窗口合并(M23)的落盘面:$DATA_DIR/config/coalesce.json(零新 env 键,
// design/13 §2.1)。它只承载**调优**(窗口/绝对上限/单批上限/给不给陌生人),
// **不承载身份**,也**翻不开**主人私聊的 direct 车道(那是 actor 元数据决定的类型)。
coalesceStore := runtimeconfig.NewCoalesceStore(
runtimeconfig.DefaultCoalescePath(docDataDir),
logger.WithSource(a.log, "services/channelbridge/coalesce"))
bridge, berr := channelbridge.New(channelbridge.Options{ bridge, berr := channelbridge.New(channelbridge.Options{
Log: logger.WithSource(a.log, "services/channelbridge"), Log: logger.WithSource(a.log, "services/channelbridge"),
Loop: a.loop, Loop: a.loop,
@@ -145,6 +158,32 @@ func (a *app) wireChannels() error {
PersonaPrompt: a.defaultInstructions(), PersonaPrompt: a.defaultInstructions(),
Engage: channelbridge.DefaultEngageConfig(), Engage: channelbridge.DefaultEngageConfig(),
// EngageFor 留空(P2a);按群覆盖随 P2c 到来。 // EngageFor 留空(P2a);按群覆盖随 P2c 到来。
// OwnerLaneFor / OwnerLaneSet 是 owner 车道结构化旋钮(design/18 §11「乙 = C」)
// 的读取/落盘接缝:读接口按 (platform, groupID) 返回该群生效策略(缺省 = 不对她说
// 就不开口),写接口把「少说点」这类结构化诉求落进 owner_lane.json,也是面板后续
// 写入的那一份。它只影响说不说/多频繁,绝不触碰权限与 actor 元数据。
OwnerLaneFor: func(platform, groupID string) *channelbridge.OwnerLanePolicy {
p := ownerLane.PolicyFor(platform, groupID)
return &channelbridge.OwnerLanePolicy{
ReplyToUnaddressed: p.ReplyToUnaddressed,
MinReplyGapSec: p.MinReplyGapSec,
}
},
OwnerLaneSet: func(platform, groupID string, p channelbridge.OwnerLanePolicy) error {
return ownerLane.Set(platform, groupID, runtimeconfig.OwnerLanePolicy{
ReplyToUnaddressed: p.ReplyToUnaddressed,
MinReplyGapSec: p.MinReplyGapSec,
})
},
// Coalesce 是**静默窗口合并**(M23)的注入面:按车道键取生效窗口参数。
// 车道类型不在这里决定 —— 主人私聊恒为 direct 车道(无窗口),由 actor 元数据
// 判定,配置无权翻转;这里只给 batch 车道(群聊/陌生人私聊)的窗口取值。
Coalesce: channelbridge.CoalesceOptions{
For: func(laneKey string) *runtimeconfig.CoalescePolicy {
p := coalesceStore.PolicyFor(laneKey)
return &p
},
},
// Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。 // Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
Factory: factory, Factory: factory,
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线 // Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
@@ -184,6 +223,7 @@ func (a *app) wireChannels() error {
fallback: factory.fallback, fallback: factory.fallback,
bridge: bridge, bridge: bridge,
owner: a.owner, owner: a.owner,
coalesce: coalesceStore,
log: a.log, log: a.log,
} }
// 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载, // 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载,
@@ -204,11 +244,55 @@ type runtimeChannels struct {
// owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员 // owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员
// 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。 // 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。
owner *user.OwnerProfile owner *user.OwnerProfile
log *slog.Logger // coalesce 是静默窗口合并(M23)的**同一个** store 实例:bridge 每次发车前从它
// 读生效值,因此面板保存后下一次发车即生效(热生效),无需重启。
coalesce *runtimeconfig.CoalesceStore
log *slog.Logger
} }
func (r *runtimeChannels) Path() string { return r.path } func (r *runtimeChannels) Path() string { return r.path }
// CoalescePath 返回静默窗口配置文件(coalesce.json)的绝对落点,供面板如实回显。
func (r *runtimeChannels) CoalescePath() string {
if r.coalesce == nil {
return ""
}
return r.coalesce.Path()
}
// CoalescePresent 报告静默窗口配置文件是否已存在(面板据此显示「还没配置过」)。
func (r *runtimeChannels) CoalescePresent() bool {
if r.coalesce == nil {
return false
}
return r.coalesce.Present()
}
// CoalesceSnapshot 返回盘上那份静默窗口配置(未 clamp 的原值),供面板回显。
func (r *runtimeChannels) CoalesceSnapshot() runtimeconfig.CoalesceFile {
if r.coalesce == nil {
return runtimeconfig.CoalesceFile{}
}
return r.coalesce.Snapshot()
}
// ApplyCoalesce 应用一份三态补丁:内存与落盘在同一个临界区,桥下一次发车即读到新值
// (store 与 bridge 是**同一个**实例,因此不需要任何重启或重载)。
func (r *runtimeChannels) ApplyCoalesce(patch runtimeconfig.CoalescePatch) (bool, error) {
if r.coalesce == nil {
return false, errors.New("channel coalesce config store not wired")
}
wrote, err := r.coalesce.Apply(patch)
if err != nil {
return wrote, fmt.Errorf("apply coalesce patch: %w", err)
}
if wrote {
r.log.Info("channel coalesce config saved (hot: the bridge reads it on the next departure)",
"path", r.CoalescePath())
}
return wrote, nil
}
func (r *runtimeChannels) Load() map[string][]config.ChannelAccount { func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
accounts := r.loadAccounts() accounts := r.loadAccounts()
r.applyAdminIDs(accounts) r.applyAdminIDs(accounts)
+18 -1
View File
@@ -126,7 +126,11 @@ func (a *app) wireFrontend() error {
if err != nil { if err != nil {
return fmt.Errorf("gateway: %w", err) return fmt.Errorf("gateway: %w", err)
} }
gw.SetVoice(a.voice) // 语音面只在这**一处**注入:emotionTTS 已按面板配置启用时是「emotionTTS +
// 既有链」的门面(失败回落既有链),否则就是既有链本身(行为逐字不变)。
// 两条既有出站口(POST /api/v1/audio/speech 与 WS voice_output)都读这一份,
// 因此不新建端点、不加帧族(切片 M35 红线)。
gw.SetVoice(a.voiceForGateway())
a.gw = gw // 必须在下面接线 hook 之前赋值——那些 hook 捕获了 a.gw / gw(否则会 nil 接收者 panic) a.gw = gw // 必须在下面接线 hook 之前赋值——那些 hook 捕获了 a.gw / gw(否则会 nil 接收者 panic)
// 可观测性 trace 出口(design/19 §2):网关已存在,其环形缓冲必须由循环产出的 // 可观测性 trace 出口(design/19 §2):网关已存在,其环形缓冲必须由循环产出的
// span 喂养,否则管理端 trace/metrics 接口会一直是空的。 // span 喂养,否则管理端 trace/metrics 接口会一直是空的。
@@ -165,6 +169,12 @@ func (a *app) wireFrontend() error {
// backthink.New 时网关还没组装。 // backthink.New 时网关还没组装。
a.backthink.SetAuditSink(gw) a.backthink.SetAuditSink(gw)
} }
// 子代理结果回填(M32;design/05 §2/§5/§9):网关 **已存在**(回填的实时出口就是
// 它的 `message_persisted` 扇出)之后才能接线。它订阅 `subagent.result`,把后台
// 批次的结论写成父会话的一条 **system 注记**(不冒充 user/assistant、不带 turn_id、
// 绝不进记忆抽取),因此「她拍了个子代理出去,然后没有下文」这件事在历史与下一轮
// 上下文里都有落点。装配细节与降级口径见 wire_subagent_backfill.go。
wireSubagentBackfill(a, gw)
return nil return nil
} }
@@ -203,6 +213,13 @@ func wirePanelHub(a *app) {
}) })
a.plugins.AttachPanels(hub) a.plugins.AttachPanels(hub)
declareBuiltinPanels(hub, log) declareBuiltinPanels(hub, log)
// 「MCP 服务器」页(切片 M20):声明 + 动作处理器成对装配,且必须在中枢接线之后
// (AttachMCPPanel 经插件管理器取中枢,未接线时会响亮报错而不是静默不挂)。
// 它归属插件管理器自己的领域数据(Statuses/Install/SetEnabled/Uninstall),因此
// 与 web-search 的纯声明不同:这里一处调用同时完成声明与动作登记。
if err := a.plugins.AttachMCPPanel(); err != nil {
log.Warn("mcp server panel unavailable; its actions will answer 501", "error", err)
}
// browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记 // browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记
// 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。 // 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。
// //
+28
View File
@@ -0,0 +1,28 @@
package main
// 本文件只有一件事:给循环的记忆提取器套上「剔除子代理结果注记」这一层,并把这条
// 口径写在一个可测的入口上(M32)。
//
// 为什么要单独一个函数,而不是在 `wire_runtime.go` 的 Options 字面量里就地调一次:
//
// - 它是**红线级**口径(子代理结论绝不进记忆库),值得有一个名字与一个测试,
// 而不是夹在一长串 Options 里的一行调用 —— 那一行被删掉时没有任何东西会红;
// - 组合根里所有「必须套上」的装饰都走这种小函数(同 `a.buildMemoryExtractor()`
// 之于提取器本身),语义在函数名下,接线在唯一一处。
//
// 判据与实现见 `internal/services/gateway/subagent_backfill_extract.go`:元数据
// (role=system + 注记标记),不是提示词。
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// loopMemoryExtractor 返回循环实际使用的提取器(`wire_runtime.go` 的
// `loop.Options.Extractor` 就是它):内层是组合根按配置构建的那个(LLM 或确定性),
// 外层保证子代理结果注记**永远不进**抽取输入。
//
// base 为 nil 时返回 nil(未配置提取器的部署逐字不变:循环那边同样是 nil 即跳过)。
func loopMemoryExtractor(base memory.Extractor) memory.Extractor {
return gateway.WithoutSubagentNotes(base)
}
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"context"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/transcript"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// 本文件锁住组合根那一条红线:循环用的提取器**必须**套上「剔除子代理注记」这一层
// (loopMemoryExtractor)。删掉那层装饰,子代理的结论就会被当成对话写进记忆库 ——
// 那条污染线刚查过(84% 记忆来自内部会话),不能再添一条。
//
// 断言的是**组合根这个入口**的语义,因此即使 gateway 那边的装饰器自己写对,把这里
// 换成 `base` 直通也会红。
// noteCaptureStore 是回填落库面的替身:只留下注记正文。
type noteCaptureStore struct {
mu sync.Mutex
rows []history.Message
}
func (s *noteCaptureStore) AppendFull(_ context.Context, msg history.Message) (history.Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
if msg.ID == "" {
msg.ID = "msg-note"
}
s.rows = append(s.rows, msg)
return msg, nil
}
// seenCaptureExtractor 记录内层提取器实际收到的对话。
type seenCaptureExtractor struct {
mu sync.Mutex
seen []model.Message
}
func (e *seenCaptureExtractor) Extract(_ context.Context, conversation []model.Message) ([]memory.MemoryItem, error) {
e.mu.Lock()
defer e.mu.Unlock()
e.seen = append([]model.Message(nil), conversation...)
return nil, nil
}
// TestLoopMemoryExtractorStripsBackfilledNotes 走真链路取一条注记(回填 → 落库 →
// 转录播种),再把它连同一条主人输入交给组合根返回的提取器:内层必须只看得到主人
// 那一句。
func TestLoopMemoryExtractorStripsBackfilledNotes(t *testing.T) {
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
store := &noteCaptureStore{}
bf, err := gateway.NewSubagentBackfill(gateway.SubagentBackfillDeps{Bus: b, History: store})
if err != nil {
t.Fatalf("NewSubagentBackfill: %v", err)
}
if err := bf.Handle(context.Background(), bus.Event{
Subject: orchestration.SubagentResultSubject,
Payload: &orchestration.SubagentResultEvent{
SessionID: history.MainSessionID,
SpawnResult: &orchestration.SpawnResult{
Status: "done",
Summary: "子代理汇总: 共 1 个, 1 成功",
Agents: []orchestration.SpawnAgentResult{{
ID: "sub-a1", Goal: "调研 X", Status: "done", Summary: "结论:X 可行",
}},
},
},
}); err != nil {
t.Fatalf("Handle: %v", err)
}
if len(store.rows) != 1 {
t.Fatalf("backfill wrote %d rows, want one system note", len(store.rows))
}
note := transcript.Message(store.rows[0])
if note.Role != model.RoleSystem {
t.Fatalf("seeded note role = %q, want system", note.Role)
}
inner := &seenCaptureExtractor{}
extractor := loopMemoryExtractor(inner)
if extractor == nil {
t.Fatal("loopMemoryExtractor(non-nil) = nil")
}
if _, err := extractor.Extract(context.Background(), []model.Message{
note,
model.TextMessage(model.RoleUser, "接着说"),
}); err != nil {
t.Fatalf("Extract: %v", err)
}
inner.mu.Lock()
seen := inner.seen
inner.mu.Unlock()
if len(seen) != 1 {
t.Fatalf("inner extractor saw %d messages, want only the user turn: the composition root dropped the note filter", len(seen))
}
if !strings.Contains(messageTextOf(seen[0]), "接着说") {
t.Fatalf("inner extractor lost the real dialogue: %+v", seen)
}
}
// TestLoopMemoryExtractorKeepsNil 锁住未配置提取器的部署逐字不变。
func TestLoopMemoryExtractorKeepsNil(t *testing.T) {
if got := loopMemoryExtractor(nil); got != nil {
t.Fatalf("loopMemoryExtractor(nil) = %v, want nil", got)
}
}
// messageTextOf 返回一条模型消息的正文(测试窄视图)。
func messageTextOf(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
b.WriteString(c.Text)
}
}
return b.String()
}
+6
View File
@@ -75,6 +75,12 @@ func TestMemoryReviewIsWired(t *testing.T) {
"a.memoryT1RateLimit()", "a.memoryT1RateLimit()",
"T1PerWindow: t1PerWindow", "T1PerWindow: t1PerWindow",
"T1Window: time.Duration(t1WindowSec) * time.Second", "T1Window: time.Duration(t1WindowSec) * time.Second",
// 临时记忆 TTL(design/03 §9.6):第五态 `defer` 写进 `expires_at` 的
// 存活秒数,同样来自那个文件(`tempTtlSec`),缺位/坏值回落代码默认
// 7 天。**它没有 `.env` 档**(`.env` 已冻结,本键在那里从来没有出处),
// 因此这一行被摘掉 = 面板配了也不生效,正是本缝断言要拦的回归。
"a.memoryTempTTL()",
"TempTTL: tempTTL",
}, },
"main.go": { "main.go": {
"a.memoryReview.Start(ctx)", "a.memoryReview.Start(ctx)",
+17 -6
View File
@@ -10,7 +10,7 @@ import (
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。 // 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
// //
// 四件事只在这里定: // 六件事只在这里定:
// - **形态**:定期后台任务(安静时段降频)的心跳(memory.TidyService),不是 // - **形态**:定期后台任务(安静时段降频)的心跳(memory.TidyService),不是
// 会话内派 subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止; // 会话内派 subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数, // - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
@@ -26,7 +26,12 @@ import (
// 变成「无限制」,也不能让整理频率变成忙轮询); // 变成「无限制」,也不能让整理频率变成忙轮询);
// - **启动播种**(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件**要做**」): // - **启动播种**(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件**要做**」):
// 那个文件不存在时由后端自己写一份「当前生效值」进去(seedMemoryTidyPanel), // 那个文件不存在时由后端自己写一份「当前生效值」进去(seedMemoryTidyPanel),
// 使「面板优先」从第一次启动起就成立,而不是靠 `.env` 一直兜着。 // 使「面板优先」从第一次启动起就成立,而不是靠 `.env` 一直兜着;
// - **到期清扫的挂点**(2026-10-13 追加,切片 M3,design/23 §19):临时记忆到期
// 后要**真删掉**(读侧时效闸只负责「看不见」)。清扫是 memory 包内的后台件
// (expire.go / expire_loop.go),起在 **TidyService.Start** 里、与心跳共用
// 同一个 ctx 与 wg,因此它跟整理同生共死(`Stop` 一并停掉它),不新开关闭链、
// 不新开端口、不加 env 键 —— 组合根这边**零接线**。
// //
// 刻意**不做**的一件事(design §4.1 F): // 刻意**不做**的一件事(design §4.1 F):
// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务, // - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务,
@@ -49,6 +54,10 @@ func (a *app) wireMemoryTidy() error {
if err != nil { if err != nil {
return fmt.Errorf("memory tidy: %w", err) return fmt.Errorf("memory tidy: %w", err)
} }
// 到期清扫(design/23 §19 的第 ② / ③ 步)**不在组合根挂**:它起在
// TidyService.Start 里,与整理心跳共用同一个 ctx 与 wg(见 expire_loop.go)。
// 因此这里零改动即完成接线 —— 不新起一条关闭链、不新开监听端口、不加任何
// env 键,`wire_services.go` 的 a.memoryTidy.Stop 会连同它一起停掉。
a.memoryTidy = svc a.memoryTidy = svc
return nil return nil
} }
@@ -58,11 +67,13 @@ func (a *app) wireMemoryTidy() error {
// 而不是每次都靠 `.env` 兜。失败只记日志、**绝不阻断启动**:运行期仍按 `.env` + // 而不是每次都靠 `.env` 兜。失败只记日志、**绝不阻断启动**:运行期仍按 `.env` +
// 代码默认跑,行为与播种前逐字相同(与 materializeModels 同一口径)。 // 代码默认跑,行为与播种前逐字相同(与 materializeModels 同一口径)。
// //
// 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)在 `.env` 里 // 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)**外加**临时
// **没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见 // 记忆 TTL 一键(tempTtlSec,切片 M2 加进来的第五个 review 键)在 `.env` 里
// wireMemoryReview 的三个 helper),而播种只发生在**面板文件不存在**时 —— 那正是 // **都没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见
// wireMemoryReview 的四个 helper:memoryIngestGateOff / memoryT1JudgeOff /
// memoryT1RateLimit / memoryTempTTL),而播种只发生在**面板文件不存在**时 —— 那正是
// 「零值 + normalizeReviewConfig」这一条路径,因此这里给零值 ReviewConfig 就是 // 「零值 + normalizeReviewConfig」这一条路径,因此这里给零值 ReviewConfig 就是
// **当时**的生效值,不是猜的。审查开关本身(MEMORY_REVIEW_ENABLED)不在这十键里, // **当时**的生效值,不是猜的。审查开关本身(MEMORY_REVIEW_ENABLED)不在这十一键里,
// 因此不必为它去读一次 env。 // 因此不必为它去读一次 env。
func (a *app) seedMemoryTidyPanel(tidy memory.TidyConfig) { func (a *app) seedMemoryTidyPanel(tidy memory.TidyConfig) {
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg)) path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
+12
View File
@@ -69,6 +69,18 @@ func TestMemoryTidyIsWired(t *testing.T) {
"main.go": { "main.go": {
"a.memoryTidy.Start(ctx)", "a.memoryTidy.Start(ctx)",
}, },
// 到期清扫(切片 M3,design/23 §19 的第 ② / ③ 步):临时记忆到期后要
// **真删掉**,而它起在 TidyService.Start 里(与心跳同一个 cctx / wg)。
// 少了这一句,过期行只会被读侧时效闸「挡在门外」而**永远不删** ——
// 不报错、不红任何别的用例,只是库里的临时层无界留存。因此这里把它
// 钉成 seam:`TestTidyServiceStart_清扫随服务起停` 是它的运行时对照。
"../../internal/runtime/memory/tidy_service.go": {
"go s.expirySweepLoop(cctx)",
},
"../../internal/runtime/memory/expire_loop.go": {
"func (s *TidyService) expirySweepLoop(ctx context.Context)",
"DefaultExpireSweepInterval",
},
"wire_services.go": { "wire_services.go": {
// 关库之前先停整理(它每轮要用连接池)。 // 关库之前先停整理(它每轮要用连接池)。
"a.memoryTidy.Stop(ctx)", "a.memoryTidy.Stop(ctx)",
+29 -6
View File
@@ -8,6 +8,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
@@ -99,23 +100,45 @@ func TestWirePanelHubDeclaresBuiltinSearchPanel(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("List: %v", err) t.Fatalf("List: %v", err)
} }
if len(entries) != 1 || entries[0].PluginID != search.PanelPluginID || // 按 pluginID 定位,而不是数「恰好一条」:内置提供者会越来越多(本片新增
entries[0].Panel.ID != search.PanelIDProviders { // mcp/servers 管理页),数总数的断言每加一页就假红一次,而它真正想守的是
t.Fatalf("内置面板 = %+v,期望 web-search/providers 一条", entries) // 「web-search/providers 在目录里,且形状仍是声明的那一版」。
entry, ok := findPanelEntry(entries, search.PanelPluginID, search.PanelIDProviders)
if !ok {
t.Fatalf("内置面板里没有 web-search/providers:%+v", entries)
}
// 形状也钉住:一个 form(写面)+ 一个 table(读面)。
if len(entry.Panel.Sections) != 2 || entry.Panel.Sections[0].Type != sdk.SectionForm ||
entry.Panel.Sections[1].Type != sdk.SectionTable {
t.Fatalf("web-search/providers 的区块形状 = %+v,期望 form+table", entry.Panel.Sections)
} }
for _, name := range search.ActionNames() { for _, name := range search.ActionNames() {
if _, ok := hub.Action(search.PanelPluginID, name); !ok { if _, ok := hub.Action(search.PanelPluginID, name); !ok {
t.Fatalf("内置插件声明的动作 %q 未进白名单", name) t.Fatalf("内置插件声明的动作 %q 未进白名单", name)
} }
} }
// viewer 拿到的必须是裁掉 form 的只读投影。 // viewer 拿到的必须是裁掉 form 的只读投影(只剩那个表格区块)。
viewer, err := hub.List(auth.RoleViewer) viewer, err := hub.List(auth.RoleViewer)
if err != nil { if err != nil {
t.Fatalf("List(viewer): %v", err) t.Fatalf("List(viewer): %v", err)
} }
if len(viewer) != 1 || len(viewer[0].Panel.Sections) != 1 { viewerEntry, ok := findPanelEntry(viewer, search.PanelPluginID, search.PanelIDProviders)
t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewer) if !ok {
t.Fatalf("viewer 快照里没有 web-search/providers:%+v", viewer)
} }
if len(viewerEntry.Panel.Sections) != 1 || viewerEntry.Panel.Sections[0].Type != sdk.SectionTable {
t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewerEntry.Panel.Sections)
}
}
// findPanelEntry 在目录快照里按 (pluginId, panelId) 定位那一条。
func findPanelEntry(entries []paneldecl.Entry, pluginID, panelID string) (paneldecl.Entry, bool) {
for _, e := range entries {
if e.PluginID == pluginID && e.Panel.ID == panelID {
return e, true
}
}
return paneldecl.Entry{}, false
} }
// TestWirePanelHubNilPluginsIsNoop 断言未构造插件管理器时接线是空操作(不 panic)。 // TestWirePanelHubNilPluginsIsNoop 断言未构造插件管理器时接线是空操作(不 panic)。
+1 -1
View File
@@ -324,7 +324,7 @@ func (a *app) wireRuntime() error {
OnTurnEnd: a.onTurnEnd, OnTurnEnd: a.onTurnEnd,
Memory: store, Memory: store,
Retriever: a.retriever, Retriever: a.retriever,
Extractor: a.buildMemoryExtractor(), Extractor: loopMemoryExtractor(a.buildMemoryExtractor()),
Checkpoint: checkpoint, Checkpoint: checkpoint,
Tracer: tracer, Tracer: tracer,
Flow: flowPub, Flow: flowPub,
+80
View File
@@ -0,0 +1,80 @@
package main
// 本文件把**子代理结果回填**接进组合根(M32;design/05 §2/§5/§9)。
//
// 为什么必须在组合根装配,而不是在网关 `New` 里顺手订阅:
//
// - 回填要写的是**父会话的聊天历史**,而网关 New 拿到的 History 与组合根
// `as.history` 是同一个 `*history.Store`,但它此刻还没被 hub 的广播出口包上 ——
// 落库之后必须用**同一个** `message_persisted` 扇出(`Server.BroadcastPersisted`)
// 才能让在线端与刷新/重连的端看到同一行;
// - 与 hub 的分工:hub 只把主题归一成 `subagent_result` 帧(投递),回填订阅者只把
// 结果写成父会话的一行系统注记(落库)。两者订阅**同一个主题**、互不相识,
// 因此以后改帧不会牵连落库,改落库也不会牵连帧。
//
// 订阅用的 ctx 是 `context.Background()`:组合根这一步没有任何长生命周期 ctx 可用
// (`newApp` 的 ctx 只用于构造期的库操作),而进程关停时 `app.close` 会
// `a.bus.Close()` —— 它取消所有订阅的处理器 ctx,因此这里不需要第二个停止开关。
// 这与 hub(`Server.Start` 里自建 ctx 并在 `Close` 取消)是同一取舍。
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// backfillWireLogSource 是这条接线自己的日志来源:订阅成功/失败要能按来源定位,
// 而不是混在 `main` 或 `gateway` 的裸日志里(AGENTS §10 的日志来源铁律)。
const backfillWireLogSource = "cmd/cyrene/wire_subagent_backfill"
// wireSubagentBackfill 把「后台子代理结果 → 父会话系统注记」的订阅者接到组合根的
// 进程内总线上(与调度器、网关 hub **同一个** bus 实例)。
//
// 三条降级口径(都**必须可见**,绝不静默):
//
// - 没有 bus 或没有历史存储:这条回填此刻做不了(未接持久化的构建);
// - 订阅失败:总线已关闭或主题非法 —— 那意味着后台结果永远不会回填;
// - 广播出口缺失(网关为 nil):仍然落库(历史是唯一事实来源),只是不推帧。
func wireSubagentBackfill(a *app, gw *gateway.Server) {
log := logger.WithSource(a.log, backfillWireLogSource)
if a.bus == nil || a.history == nil {
log.Warn("subagent backfill not wired: needs both a bus and a history store",
"bus", a.bus != nil, "history", a.history != nil)
return
}
backfill, err := gateway.NewSubagentBackfill(gateway.SubagentBackfillDeps{
Bus: a.bus,
History: a.history,
Broadcast: backfillSink(gw),
Log: a.log,
})
if err != nil {
log.Warn("subagent backfill not wired", "err", err)
return
}
if err := backfill.Start(context.Background()); err != nil {
log.Warn("subagent backfill subscribe failed; background results will not reach the parent session",
"err", err)
return
}
log.Info("subagent backfill wired",
"subject", "subagent.result", "session_role", history.RoleSystem)
}
// backfillSink 返回回填的下行出口。网关为 nil 时返回**真 nil 接口**(带类型的 nil
// 指针会让订阅者里的 `!= nil` 判断失效,落库之后去调一个空接收者)。
func backfillSink(gw *gateway.Server) gateway.SubagentNoteSink {
if gw == nil {
return nil
}
return gw
}
// 编译期锁住这条链路的两端:网关仍是回填的下行出口,历史存储仍是它的落库面。
// 任何一边改签名,这里先红,而不是等到运行期回填静默缺席。
var (
_ gateway.SubagentNoteSink = (*gateway.Server)(nil)
_ gateway.SubagentNoteStore = (*history.Store)(nil)
)
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
)
// 本文件锁住 M32 的接线本身:**组合根真的把回填订阅者挂上了那条总线**。
//
// 为什么单独测这一步:这次缺陷的形状就是「所有零件都在、没有一根线」。回填订阅者
// 写对了、总线也发得对,只要 `wireFrontend` 里少掉那一行调用,主人在界面上看到的
// 仍然是「没有下文」—— 而且没有任何测试会红。因此这里断言的是**装配后的行为**:
// 组合根接线 → 真 bus 上发布编排层载荷 → 父会话真的多出一条系统注记行。
// waitForBackfillRow 轮询历史,直到父会话出现一条消息或到期。
func waitForBackfillRow(t *testing.T, st *history.Store, sessionID string) []history.Message {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
rows, err := st.Recent(context.Background(), sessionID, 10)
if err != nil {
t.Fatalf("read recent: %v", err)
}
if len(rows) > 0 {
return rows
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("the parent session never received the backfilled note: the composition root did not wire the subscriber")
return nil
}
// TestWireSubagentBackfillPersistsThroughBus 是 ④ 的正面证据:装配 → 发布 → 落行。
func TestWireSubagentBackfillPersistsThroughBus(t *testing.T) {
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
a := &app{bus: b, history: newTestHistory(t), log: testLog()}
wireSubagentBackfill(a, nil)
payload := &orchestration.SubagentResultEvent{
SessionID: history.MainSessionID,
SpawnResult: &orchestration.SpawnResult{
Status: "done",
Summary: "子代理汇总: 共 1 个, 1 成功",
Agents: []orchestration.SpawnAgentResult{{
ID: "sub-a1", Goal: "调研 X", Role: "cyrene", Status: "done", Summary: "结论:X 可行",
}},
},
}
if err := b.Publish(context.Background(), orchestration.SubagentResultSubject, payload); err != nil {
t.Fatalf("publish: %v", err)
}
rows := waitForBackfillRow(t, a.history, history.MainSessionID)
if len(rows) != 1 {
t.Fatalf("parent session has %d messages, want exactly the one system note: %+v", len(rows), rows)
}
row := rows[0]
if row.Role != history.RoleSystem {
t.Fatalf("backfilled row role = %q, want %q (a sub-agent conclusion is not her own words)", row.Role, history.RoleSystem)
}
if row.TurnID != "" || row.PersonaID != "" {
t.Fatalf("backfilled row = %+v, want no turn_id and no persona_id", row)
}
}
// TestWireSubagentBackfillWithoutHistoryWarns 锁住降级口径:没有历史存储时不装配、
// 不 panic,而是留一条可见的告警(绝不假装回填成立)。
func TestWireSubagentBackfillWithoutHistoryWarns(t *testing.T) {
a := &app{bus: bus.New(), log: testLog()}
wireSubagentBackfill(a, nil) // 没有 history:只允许告警
if err := a.bus.Publish(context.Background(), orchestration.SubagentResultSubject,
&orchestration.SubagentResultEvent{SessionID: history.MainSessionID}); err != nil {
t.Fatalf("publish on an unwired root: %v", err)
}
}
+92
View File
@@ -0,0 +1,92 @@
package main
// 本文件把**半云端 emotionTTS** 接成她的一级发音人(切片 M35),并保持三条红线:
//
// 1. **配置走面板文件** `$DATA_DIR/config/voice-tts.json`(零新 `.env` 键,design/13
// §2.1)。文件缺失/坏掉 ⇒ 这一级**不启用**,既有语音链逐字不变。
// 2. **文本优先**:合成失败只回落既有链 + 一条带来源的 WARN,绝不把"这条回复"
// 判成失败(聊天正文早已发出/落库)。
// 3. **不新建端点/帧族**:只换网关 `SetVoice` 的注入点,因此既有两条出站口
// (`POST /api/v1/audio/speech` 与 WS `voice_output`)同时用上它。
//
// 为什么包一层而不是插进链里:既有链由 `multimodal/voice.New` 按 env 组装,各级
// 列表不外露;`.env` 已冻结,不能为新能力加键。包装既不动那条链,也让"失败回落"
// 发生在**合成入口**这一处,而不是散在两条出站口里。
import (
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// voiceTTSSource 是本切片日志的来源标签(AGENTS §10:日志必须标来源)。
const voiceTTSSource = "cmd/cyrene/voice-tts"
// voiceForGateway 返回注入给网关的语音服务:emotionTTS 已按面板配置启用时是
// 「emotionTTS + 既有链」的门面,否则**就是既有链本身**(行为逐字不变)。
//
// 它只在 `wireFrontend` 接线时调用一次;每次调用都重新读一次面板文件(启动期
// 读一次的口径与 `backthink.json` 一致)。
func (a *app) voiceForGateway() gateway.VoiceService {
if a.voice == nil {
return nil
}
cfg, enabled := a.voiceTTSConfig()
if !enabled {
return a.voice
}
client := voicetts.New(cfg, voicetts.WithLogger(logger.WithSource(a.log, voiceTTSSource)))
service := voicetts.NewService(client, a.voice, voicetts.WithServiceLogger(logger.WithSource(a.log, voiceTTSSource)))
logger.WithSource(a.log, voiceTTSSource).Info("emotionTTS voice enabled",
"base_url", client.RedactedBaseURL(),
"personas", len(cfg.Personas),
"default_set", cfg.Default != "",
"timeout_sec", int(cfg.Timeout/time.Second),
"config", runtimeconfig.VoiceTTSConfigRelPath)
return service
}
// voiceTTSConfig 读面板配置并映射成 provider 配置。第二个返回值报告"这一级是否
// 启用"——坏文件、缺文件、显式 false 都得出 false(并在坏文件时留一条 WARN)。
//
// 它**不**在这里做"缺省填什么"的裁决:默认值在 `voicetts.Config.Normalized` 一处。
// 🔴 它也**不**给 personas/default 补任何角色:发音人只能由面板显式给出(全局定死
// 一个 character 正是主人点名的病害)。
func (a *app) voiceTTSConfig() (voicetts.Config, bool) {
log := logger.WithSource(a.log, voiceTTSSource)
path := runtimeconfig.DefaultVoiceTTSPath(resolveDataDir(a.cfg))
tuning, err := runtimeconfig.LoadVoiceTTSTuning(path)
if err != nil {
// 坏文件 ⇒ **这一级不启用**:宁可没有它,也不要用一份读不懂的配置去驱动
// 一台外部服务。既有链照旧服务(文本优先)。
log.Warn("voice tts config unreadable, emotionTTS stays disabled",
"path", runtimeconfig.VoiceTTSConfigRelPath, "err", err)
return voicetts.Config{}, false
}
if !tuning.EnabledOn() {
log.Debug("emotionTTS not enabled in panel config",
"path", runtimeconfig.VoiceTTSConfigRelPath, "hint", "set {\"enabled\": true}")
return voicetts.Config{}, false
}
if legacy := tuning.LegacyGlobalCharacter(); legacy != "" {
// 旧版的全局 character:**忽略**,但绝不静默 —— 运维需要知道换成 personas。
log.Warn("voice tts config: global `character` is deprecated and ignored",
"character", legacy, "config", runtimeconfig.VoiceTTSConfigRelPath,
"hint", "use {\"personas\": {\"<personaId>\": \"<characterId>\"}} so the voice follows the persona")
}
return voicetts.Config{
Enabled: true,
BaseURL: tuning.BaseURL,
Personas: tuning.Personas,
Default: tuning.Default,
Timeout: time.Duration(tuning.TimeoutSec) * time.Second,
}, true
}
// voiceFallbackChain 声明 a.voice 满足 emotionTTS 门面要的底链面(编译期断言)。
// 它把"既有链少了一个方法"这种改动的代价,从**运行期 panic** 提前到**编译失败**。
var _ voicetts.Chain = (*voice.Chain)(nil)
+127
View File
@@ -0,0 +1,127 @@
package main
// 本文件锁定切片 M35 在**组合根**上的三条口径(都是"改坏就静默不工作/静默开错"的那种):
//
// 1. 面板文件缺失 ⇒ **这一级不启用**,返回的仍是既有链本身(行为逐字不变);
// 2. 文件里 `enabled:true` ⇒ 返回 emotionTTS 门面,`personas`/`default`/`timeoutSec`
// 按文件来(发音人只能来自 persona 映射;已废弃的全局 `character` 不生效);
// 3. 文件坏掉 ⇒ **不启用**(宁可没有这一级,也不拿读不懂的配置去驱动外部服务)。
//
// 它用 `DefaultVoiceTTSPath` 的真实落点($DATA_DIR/config/voice-tts.json)跑,
// 因此也钉住"零新 .env 键":没有任何一处读环境变量。
import (
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
)
// voiceTTSApp 造一个只够本题用的 app:数据目录 + 一条既有链。
func voiceTTSApp(t *testing.T) *app {
t.Helper()
dir := t.TempDir()
return &app{
cfg: &config.Config{DataDir: dir},
log: tidyTestLog(),
voice: mvoice.New(config.VoiceConfig{}),
}
}
// TestVoiceForGateway_DisabledByDefault 钉住"没配过 ⇒ 原样用既有链"。
func TestVoiceForGateway_DisabledByDefault(t *testing.T) {
a := voiceTTSApp(t)
got := a.voiceForGateway()
if got != any(a.voice) {
t.Fatalf("没配过时不该包装既有链: %T", got)
}
}
// TestVoiceForGateway_WrapsWhenEnabled 钉住"文件启用 ⇒ 门面生效 + 配置逐项读对"。
//
// 🔴 它同时钉住主人 2026-10-26 那条硬要求在**组合根**上的形态:配置里读出来的是
// `personas` 映射与 `default`,**没有**任何"全局一个 character"。
func TestVoiceForGateway_WrapsWhenEnabled(t *testing.T) {
a := voiceTTSApp(t)
writeVoiceTTSPanel(t, a, `{"enabled":true,"baseUrl":"http://127.0.0.1:9880",
"personas":{"cyrene":"char_84fb88a3","furina":"char_5314489e"},
"default":"char_84fb88a3","format":"wav","timeoutSec":45}`)
cfg, enabled := a.voiceTTSConfig()
if !enabled {
t.Fatal("面板写了 enabled:true,但组合根报未启用")
}
if len(cfg.Personas) != 2 || cfg.Personas["furina"] != "char_5314489e" {
t.Fatalf("personas 没有逐项读出: %+v", cfg.Personas)
}
if cfg.Default != "char_84fb88a3" {
t.Fatalf("default = %q, want char_84fb88a3", cfg.Default)
}
got := a.voiceForGateway()
if _, ok := got.(*voicetts.Service); !ok {
t.Fatalf("启用后应返回 *tts.Service, got %T", got)
}
}
// TestVoiceForGateway_LegacyCharacterIgnored 钉住已废弃的全局 character **不生效**:
// 只写旧键时,映射与 default 都是空的 ⇒ 任何请求都选不到发音人(不会悄悄回退到
// 那个全局角色,因为那正是主人担心的病害)。
func TestVoiceForGateway_LegacyCharacterIgnored(t *testing.T) {
a := voiceTTSApp(t)
writeVoiceTTSPanel(t, a, `{"enabled":true,"character":"char_84fb88a3"}`)
cfg, enabled := a.voiceTTSConfig()
if !enabled {
t.Fatal("enabled:true 应让这一级启用")
}
if len(cfg.Personas) != 0 || cfg.Default != "" {
t.Fatalf("废弃的全局 character 被当成了发音人: personas=%v default=%q", cfg.Personas, cfg.Default)
}
}
// TestVoiceForGateway_BadFileStaysDisabled 钉住"坏文件 ⇒ 不启用"(绝不半开)。
func TestVoiceForGateway_BadFileStaysDisabled(t *testing.T) {
a := voiceTTSApp(t)
writeVoiceTTSPanel(t, a, `{"enabled":`)
got := a.voiceForGateway()
if _, ok := got.(*voicetts.Service); ok {
t.Fatal("坏文件却启用了 emotionTTS(会拿读不懂的配置去调外部服务)")
}
if got != any(a.voice) {
t.Fatalf("坏文件时应原样用既有链, got %T", got)
}
}
// TestVoiceTTSConfig_NoEnvKeys 钉住"零新 .env 键":把相关环境变量设成垃圾值,
// 配置解析的结果只由面板文件决定(证明它根本没读环境)。
func TestVoiceTTSConfig_NoEnvKeys(t *testing.T) {
a := voiceTTSApp(t)
t.Setenv("VOICE_TTS_PROVIDER", "emotiontts")
t.Setenv("EMOTIONTTS_BASE_URL", "http://evil.invalid")
writeVoiceTTSPanel(t, a, `{"enabled":true}`)
cfg, enabled := a.voiceTTSConfig()
if !enabled {
t.Fatal("面板启用却没启用")
}
if cfg.BaseURL != "" {
t.Errorf("baseURL = %q,want 空(默认值由 Normalized 补,不读环境)", cfg.BaseURL)
}
}
// writeVoiceTTSPanel 把内容写进**真实落点**($DATA_DIR/config/voice-tts.json)。
func writeVoiceTTSPanel(t *testing.T, a *app, body string) {
t.Helper()
path := filepath.Join(a.cfg.DataDir, "config", "voice-tts.json")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write panel: %v", err)
}
}
+16 -3
View File
@@ -2,14 +2,27 @@
// the Go package can //go:embed it into the single Cyrene binary. It runs as the // the Go package can //go:embed it into the single Cyrene binary. It runs as the
// last step of `npm run build`. The source dist is the canonical build output, // last step of `npm run build`. The source dist is the canonical build output,
// so it also serves as the external deploy-override directory. // so it also serves as the external deploy-override directory.
import { cp, rm } from "node:fs/promises"; //
import { dirname, resolve } from "node:path"; // The destination is wiped first so assets from an older build cannot linger in
// the binary. internal/webui/dist/.gitkeep is committed because //go:embed
// all:dist fails to compile when the directory holds no files at all — that is
// what makes `go build ./...` work in a clean checkout. The wipe may clear build
// output but never the placeholder, so it is restored on every run, including a
// failed copy: a bare placeholder still compiles, a missing one breaks the build.
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
const src = resolve(here, "dist"); const src = resolve(here, "dist");
const dest = resolve(here, "..", "..", "internal", "webui", "dist"); const dest = resolve(here, "..", "..", "internal", "webui", "dist");
const placeholder = ".gitkeep";
await rm(dest, { recursive: true, force: true }); await rm(dest, { recursive: true, force: true });
await cp(src, dest, { recursive: true }); try {
await cp(src, dest, { recursive: true });
} finally {
await mkdir(dest, { recursive: true });
await writeFile(join(dest, placeholder), "");
}
console.log(`webui: copied ${src} -> ${dest}`); console.log(`webui: copied ${src} -> ${dest}`);
@@ -9,6 +9,7 @@ import { Button, Surface, Tag } from "../../components/primitives";
import { MemoryTidyFactsCard } from "./ConfigFactsCards"; import { MemoryTidyFactsCard } from "./ConfigFactsCards";
import { MemoryTidyDryRunCard } from "./MemoryTidyDryRunCard"; import { MemoryTidyDryRunCard } from "./MemoryTidyDryRunCard";
import { MemoryTidyApplyCard } from "./MemoryTidyApplyCard"; import { MemoryTidyApplyCard } from "./MemoryTidyApplyCard";
import { MemoryTidyTuningCard } from "./MemoryTidyTuningCard";
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = { const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
user: "accent", user: "accent",
@@ -191,6 +192,8 @@ export function MemoriesModule(): React.ReactNode {
<MemoryTidyApplyCard /> <MemoryTidyApplyCard />
<MemoryTidyTuningCard />
<MemoryTidyFactsCard /> <MemoryTidyFactsCard />
<Notice> <Notice>
@@ -25,6 +25,8 @@ import {
MemoryTidyDryRunBody, MemoryTidyDryRunBody,
TIDY_DRY_RUN_DISCLAIMER, TIDY_DRY_RUN_DISCLAIMER,
TIDY_DRY_RUN_ERROR_HINT, TIDY_DRY_RUN_ERROR_HINT,
TIDY_RECALL_UNAVAILABLE,
TIDY_RECALL_UNAVAILABLE_HINT,
type TidyDryRunState, type TidyDryRunState,
} from "./MemoryTidyDryRunCard"; } from "./MemoryTidyDryRunCard";
@@ -96,7 +98,7 @@ ok(has(doneHtml, "0.950"), "必须显示分数(区间)");
ok(has(doneHtml, "mem-short") && has(doneHtml, "保留 mem-long"), "必须显示「哪条 → 保留哪条」"); ok(has(doneHtml, "mem-short") && has(doneHtml, "保留 mem-long"), "必须显示「哪条 → 保留哪条」");
ok(has(doneHtml, "作息偏晚"), "必须显示合并后的存活条目原文(diff 预览)"); ok(has(doneHtml, "作息偏晚"), "必须显示合并后的存活条目原文(diff 预览)");
ok(has(doneHtml, "扫描 736") && has(doneHtml, "用时 42 ms"), "必须显示扫描口径与耗时"); ok(has(doneHtml, "扫描 736") && has(doneHtml, "用时 42 ms"), "必须显示扫描口径与耗时");
ok(has(doneHtml, "召回来源:embedding"), "必须显示召回来源(lexical 是降级信号)"); ok(has(doneHtml, "召回来源:语义向量(embedding)"), "必须显示召回来源(lexical 是降级信号)");
ok(has(doneHtml, "scope=1"), "必须显示否决分布(它解释「为什么没有更多建议」)"); ok(has(doneHtml, "scope=1"), "必须显示否决分布(它解释「为什么没有更多建议」)");
// ── 截断:必须说明「这一轮看到的比库里少 + 该调哪个键」 ───────────────────── // ── 截断:必须说明「这一轮看到的比库里少 + 该调哪个键」 ─────────────────────
@@ -124,6 +126,42 @@ ok(!has(errorHtml, "扫描 "), "错误态绝不得渲染观测口径(扫描/
ok(!has(errorHtml, "这一轮没有产生任何候选"), "错误态不得复用空态文案(两者含义不同)"); ok(!has(errorHtml, "这一轮没有产生任何候选"), "错误态不得复用空态文案(两者含义不同)");
ok(has(errorHtml, "把失败画成「0 条」"), "错误态反而要解释「为什么不显示 0 条」"); ok(has(errorHtml, "把失败画成「0 条」"), "错误态反而要解释「为什么不显示 0 条」");
// ── ④ 召回来源(契约 v0.91):四种取值**各有各的话**,空串绝不是空标签 ────────
// 起因是 M6 的诊断(DevDocs/audit/去重失效诊断-2026-10-13.md):旧实现把「全量
// 降级」静默报成 embedding,面板据此判读会把「向量不可用」读成「向量正常」。
// 因此这个字段的**全部价值**就是让人看得出这一轮到底走了哪条路。
const recallCases: Array<[string, string]> = [
["embedding", "语义向量"],
["lexical", "已降级"],
["mixed", "部分分区降级"],
["", "无候选可比"],
["unregistered_mode", "未登记取值"],
];
for (const [mode, want] of recallCases) {
const html = render({ kind: "done", result: { ...RESULT, recallMode: mode } });
ok(has(html, want), `recallMode=${JSON.stringify(mode)} 必须渲染成可读中文(含「${want}」)`);
}
// 空串绝不许渲染成「召回来源:」后面空白(那是本次要修的那个缺陷)。
const noCandidateHtml = render({ kind: "done", result: { ...RESULT, recallMode: "", candidates: [], pairs: 0 } });
ok(has(noCandidateHtml, "无候选可比"), "空串 = 本轮没做召回,必须说出来");
ok(!has(noCandidateHtml, "召回来源:<"), "空串绝不能渲染成空标签(后面什么都没有)");
ok(!has(noCandidateHtml, "召回来源:</"), "空串绝不能渲染成空标签(连标签体都空)");
ok(has(noCandidateHtml, "未做召回"), "空串要解释「这一轮压根没算过相似度」,不能含糊成「—」");
// ── ⑤ 「尝试过召回但向量源不可用」:与上面两种都不同,走**错误**路径 ─────────
// 试算的严格口径下这种失败不回响应体(前端只能从 error.code 认出来),因此这里
// 必须单独给一句说明 —— 否则主人对着一句「试算失败」分不清是没候选还是向量挂了。
const recallDownHtml = render({
kind: "error",
message: "语义召回(embedding)不可用,试算未产出候选(试算不做静默降级)",
code: TIDY_RECALL_UNAVAILABLE,
});
ok(has(recallDownHtml, TIDY_RECALL_UNAVAILABLE_HINT), "向量源不可用时要给专门的说明");
ok(has(recallDownHtml, "不等于"), "必须与「没有候选可比」显式区分");
ok(has(recallDownHtml, "没有降级到词项"), "必须说清它也没有降级到词项余弦");
ok(!has(errorHtml, TIDY_RECALL_UNAVAILABLE_HINT), "别的错误不该套用这条说明(不许乱归因)");
// ── 未跑 / 在跑 ──────────────────────────────────────────────────────────── // ── 未跑 / 在跑 ────────────────────────────────────────────────────────────
const idleHtml = render({ kind: "idle" }); const idleHtml = render({ kind: "idle" });
ok(has(idleHtml, "试算整理建议"), "未跑时必须给出按钮文案"); ok(has(idleHtml, "试算整理建议"), "未跑时必须给出按钮文案");
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { adminMemoryTidyDryRun } from "../../lib/api"; import { ApiError, adminMemoryTidyDryRun } from "../../lib/api";
import type { AdminTidyDryRunCandidate, AdminTidyDryRunResult } from "../../lib/types"; import type { AdminTidyDryRunCandidate, AdminTidyDryRunResult } from "../../lib/types";
import { Button, Surface, Tag } from "../../components/primitives"; import { Button, Surface, Tag } from "../../components/primitives";
import { Notice } from "../components"; import { Notice } from "../components";
@@ -38,7 +38,18 @@ export type TidyDryRunState =
| { kind: "idle" } | { kind: "idle" }
| { kind: "running" } | { kind: "running" }
| { kind: "done"; result: AdminTidyDryRunResult } | { kind: "done"; result: AdminTidyDryRunResult }
| { kind: "error"; message: string }; | { kind: "error"; message: string; code?: string };
/** TIDY_RECALL_UNAVAILABLE 是「这一轮**尝试过**向量召回但向量源不可用」的错误码。
*
* 它与「没有候选可比」(成功响应 + `recallMode:""`)和「降级到词项」(`lexical`)
* 是三件不同的事,必须各有各的话。试算的严格口径下这种失败**不回响应体**,因此它
* 只走错误路径 —— 面板在这里把它说出来,而不是让主人对着一句「试算失败」猜。 */
export const TIDY_RECALL_UNAVAILABLE = "tidy_recall_unavailable";
/** TIDY_RECALL_UNAVAILABLE_HINT 是上面那个错误码的补充说明。 */
export const TIDY_RECALL_UNAVAILABLE_HINT =
"这一轮尝试过向量召回、但向量源不可用(试算不做静默降级):它既不等于「没有候选可比」,也没有降级到词项余弦。请检查 embedding 服务后重试。";
const actionLabel: Record<string, string> = { const actionLabel: Record<string, string> = {
merge: "并入", merge: "并入",
@@ -112,6 +123,50 @@ function CandidateRow({ candidate }: { candidate: AdminTidyDryRunCandidate }): R
); );
} }
/** RecallSourceTag 渲染「这一轮到底走的是向量还是词项」(契约 v0.91 的 `recallMode`)。
*
* 它是 M6 诊断(`DevDocs/audit/去重失效诊断-2026-10-13.md`)的落点:旧实现按「有没
* 有配 embedder」写这个字段,于是**全量降级被静默报成 `embedding`**,面板据此判读会
* 把「向量不可用」读成「向量正常工作」。取值域与读法(三种态**必须分得清**):
*
* - `embedding` —— 语义向量召回真的用上了(好态);
* - `lexical` —— 全部走零依赖词项余弦(进程没接 embedder ⇒ 降级,命中会更少);
* - `mixed` —— **部分分区降级**、其余走向量(v0.91 新增);
* - `""` —— **没有候选可比**:这一轮压根没算过相似度(不是降级,也不该显示成
* 一个空标签 —— 那会让人以为字段坏了)。
*
* ⚠️ 「尝试过召回但向量源不可用」**不在这里**:试算的严格口径下那种情况直接报错
* (`error.code = tidy_recall_unavailable`),响应体根本不会出现;线上也没有
* `recall_unavailable_partitions` 这个键(它只存在于后端日志行)。因此本组件不许
* 用「—」把空串含糊掉 —— 每个取值都有一句可读的中文。 */
export function recallSourceLabel(mode: string): string {
switch (mode) {
case "embedding":
return "召回来源:语义向量(embedding)";
case "lexical":
return "召回来源:词项余弦(未接向量,已降级)";
case "mixed":
return "召回来源:部分分区降级(其余走向量)";
case "":
return "召回来源:无候选可比(本轮未做召回)";
default:
return `召回来源:${mode}(未登记取值)`;
}
}
/** recallSourceTone 给上面那句话配色调:好态 accent、降级 warn、无候选 neutral。 */
export function recallSourceTone(mode: string): "accent" | "warn" | "neutral" {
switch (mode) {
case "embedding":
return "accent";
case "lexical":
case "mixed":
return "warn";
default:
return "neutral";
}
}
/** ResultBody 渲染一次试算的完整结果(有候选 / 空 / 观测口径)。 */ /** ResultBody 渲染一次试算的完整结果(有候选 / 空 / 观测口径)。 */
function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactNode { function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactNode {
const vetoReasons = result.vetoReasons ?? {}; const vetoReasons = result.vetoReasons ?? {};
@@ -119,7 +174,7 @@ function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactN
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Tag tone="ok">算完了,一行都没写</Tag> <Tag tone="ok">算完了,一行都没写</Tag>
<Tag tone={result.recallMode === "embedding" ? "accent" : "warn"}>召回来源:{result.recallMode}</Tag> <Tag tone={recallSourceTone(result.recallMode)}>{recallSourceLabel(result.recallMode)}</Tag>
<span className="text-[11px] text-[var(--mu-text-lo)]"> <span className="text-[11px] text-[var(--mu-text-lo)]">
扫描 {result.scanned} / 比对 {result.compared} / 召回 {result.pairs} 对 / 否决 {result.vetoed} 对 · 用时{" "} 扫描 {result.scanned} / 比对 {result.compared} / 召回 {result.pairs} 对 / 否决 {result.vetoed} 对 · 用时{" "}
{result.durationMs} ms · 规则 {result.ruleVersion} · 分区 {result.partitions} {result.durationMs} ms · 规则 {result.ruleVersion} · 分区 {result.partitions}
@@ -174,6 +229,9 @@ function StateBody({ state }: { state: TidyDryRunState }): React.ReactNode {
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<Notice tone="err">试算失败:{state.message}</Notice> <Notice tone="err">试算失败:{state.message}</Notice>
{state.code === TIDY_RECALL_UNAVAILABLE ? (
<span className="text-[11px] text-[var(--mu-text-mid)]">{TIDY_RECALL_UNAVAILABLE_HINT}</span>
) : null}
<span className="text-[11px] text-[var(--mu-text-lo)]"> <span className="text-[11px] text-[var(--mu-text-lo)]">
{TIDY_DRY_RUN_ERROR_HINT}。这里刻意不显示候选数 —— 把失败画成「0 条」就是把「没算出来」冒充成结论。 {TIDY_DRY_RUN_ERROR_HINT}。这里刻意不显示候选数 —— 把失败画成「0 条」就是把「没算出来」冒充成结论。
</span> </span>
@@ -223,7 +281,11 @@ export function MemoryTidyDryRunCard(): React.ReactNode {
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
log("memory.tidy.dryRun", "error"); log("memory.tidy.dryRun", "error");
setState({ kind: "error", message: e instanceof Error ? e.message : "试算失败" }); setState({
kind: "error",
message: e instanceof Error ? e.message : "试算失败",
code: e instanceof ApiError ? e.code : undefined,
});
}); });
}; };
@@ -0,0 +1,124 @@
// 记忆整理「调优」卡的**真实渲染**测试(切片 MEMPANEL / M4)。
//
// 它钉住三件**文案与呈现**的事(逻辑那一半见 memoryTidyTuning.test.ts):
// ① 7 个键都有中文标签 + 各自的兜底说明(「留空 ⇒ 跟随谁」);
// ② 热生效与否**如实**:`hot:false` 必须写「重启后端后生效」,绝不写成「已生效」;
// ③ 坏值**就地报错**并拦住保存(后端那道 400 仍在,但不让主人白跑)。
//
// 跑法(与 MemoryTidyDryRunCard.render.test.ts 同):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyTuningCard.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-memory-tidy-tuning.render.test.cjs \
// && node /tmp/cyrene-memory-tidy-tuning.render.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { AdminMemoryTidyTuningView } from "../../lib/api/adminMemories";
import {
MemoryTidyTuningBody,
TIDY_TUNING_COLD_LABEL,
TIDY_TUNING_DISCLAIMER,
TIDY_TUNING_HOT_LABEL,
} from "./MemoryTidyTuningCard";
import { draftFromView, TUNING_LABELS } from "./memoryTidyTuning";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
/** fixtureView 造一份 GET 响应形状的数据(含全部 7 个键的规格)。 */
function fixtureView(overrides: Partial<AdminMemoryTidyTuningView> = {}): AdminMemoryTidyTuningView {
return {
path: "config/memory-tidy.json",
present: true,
hot: false,
hotError: "运行期没有接线可热生效的实现(deps.MemoryTidy / deps.Memories);新值已落盘,重启后端后生效",
specs: [
{ key: "intervalSec", kind: "int", unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" },
{ key: "quietIntervalSec", kind: "int", unit: "sec", min: 1, fallback: "panel:intervalSec" },
{ key: "quietStartHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" },
{ key: "quietEndHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" },
{ key: "ingestGate", kind: "bool", fallback: "code-default" },
{ key: "autoApply", kind: "bool", fallback: "code-default" },
{ key: "tempTtlSec", kind: "int", unit: "sec", min: 1, fallback: "code-default" },
],
values: { intervalSec: 10800, quietStartHour: 23, quietEndHour: 7, ingestGate: true, autoApply: true },
envValues: { intervalSec: 86400, quietStartHour: 23, quietEndHour: 7 },
...overrides,
};
}
function renderBody(view: AdminMemoryTidyTuningView, draft: Record<string, string>, errors: Record<string, string> = {}, extra: Record<string, unknown> = {}): string {
return renderToStaticMarkup(
createElement(MemoryTidyTuningBody, {
view,
draft,
errors,
busy: false,
receipt: null,
savedView: null,
onDraftChange: () => {},
onSave: () => {},
...extra,
} as never)
);
}
const view = fixtureView();
const html = renderBody(view, draftFromView(view), {}, { receipt: "已保存:平时整理间隔。", savedView: { ...view, hot: true } });
// ── ① 7 个键:中文标签 + 键名 + 兜底说明 ───────────────────────────────────
for (const key of Object.keys(TUNING_LABELS)) {
ok(has(html, TUNING_LABELS[key]), `必须渲染 ${key} 的中文标签`);
ok(has(html, key), `必须显示键名 ${key}(后端契约的键,面板不改名)`);
}
ok(has(html, TIDY_TUNING_DISCLAIMER), "恒显示「面板配置文件优先,.env 只做兜底」");
ok(has(html, "config/memory-tidy.json"), "必须显示面板配置文件的落点");
ok(has(html, "跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "缺位的键要写清「留空 ⇒ 跟随谁」(env 档)");
ok(has(html, "当前 86400"), "env 档的当前生效值要如实带出来");
ok(has(html, "跟随面板里的 intervalSec"), "quietIntervalSec 的下一档是面板内的 intervalSec");
ok(has(html, "跟随代码默认"), "ingestGate/autoApply/tempTtlSec 的下一档是代码默认");
ok(has(html, "≈ 3 小时"), "秒数要给可读提示(10800 ⇒ 3 小时)");
// ── ② 热生效与否:两种口径必须分得清 ───────────────────────────────────────
ok(has(html, TIDY_TUNING_HOT_LABEL), "hot:true 时必须说「已热生效(不用重启后端)」");
ok(has(html, "保存后回显"), "保存后必须回显**从磁盘重新读回**的真值");
ok(has(html, "intervalSec = 10800"), "回显要逐键给出真值");
const coldHtml = renderBody(view, draftFromView(view), {}, {
receipt: "已保存:平时整理间隔。",
savedView: view, // hot: false
});
ok(has(coldHtml, TIDY_TUNING_COLD_LABEL), "hot:false 时必须说「已保存到面板配置文件,重启后端后生效」");
ok(!has(coldHtml, TIDY_TUNING_HOT_LABEL), "hot:false 时绝不许出现「已热生效」");
ok(has(coldHtml, "运行期没有接线可热生效的实现"), "hot:false 要带出后端给的 hotError 原因");
// ── ③ 坏值就地报错并拦住保存 ───────────────────────────────────────────────
const badHtml = renderBody(view, { ...draftFromView(view), intervalSec: "0" }, {
intervalSec: "平时整理间隔 必须 ≥ 1 秒",
});
ok(has(badHtml, "必须 ≥ 1 秒"), "坏值要就地显示错误文案");
ok(has(badHtml, "有坏值,未提交"), "有坏值时必须明说「未提交」(盘上不会被动)");
ok(has(badHtml, "disabled"), "有坏值时保存按钮必须禁用");
ok(has(badHtml, "清空一个键 = 从面板文件里删掉它"), "要解释「清空 ≠ 写 0」这条三态口径");
ok(has(badHtml, "T1 判定"), "要说明同文件其它旋钮原样保留(这张卡只写 7 个键)");
// 缺位下的盘上值:没配 ⇒ 面板显示「未写 ⇒ 跟随兜底」,不编一个 0。
const missing = fixtureView({ values: {} });
const missingHtml = renderBody(missing, draftFromView(missing));
ok(has(missingHtml, "留空 ⇒ 跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "没配的键要显示「留空 ⇒ 跟随下一档」");
ok(!has(missingHtml, "intervalSec = 0"), "没配的键绝不能显示成 0(那是「写了 0」的坏值形态)");
// eslint-disable-next-line no-console
console.log(`MemoryTidyTuningCard.render: ${checks} checks passed`);
@@ -0,0 +1,273 @@
import { useCallback, useEffect, useState } from "react";
import {
adminMemoryTidyTuning,
adminMemoryTidyTuningSave,
type AdminMemoryTidyTuningView,
} from "../../lib/api/adminMemories";
import { Button, Surface, Tag } from "../../components/primitives";
import { Notice } from "../components";
import { useAuditStore } from "../audit";
import type { TuningDraft } from "./memoryTidyTuning";
import {
TUNING_LABELS,
buildTuningPatch,
draftFromView,
fallbackLabel,
patchSummary,
secondsHint,
unitLabel,
} from "./memoryTidyTuning";
/**
* 记忆整理「调优」卡(切片 MEMPANEL / M4)。真源:
* internal/services/gateway/admin_memory_tuning.go。
*
* 它回答的是主人的两句原话:「`.env` 只做兜底用 …… 后端自动创建独立的面板配置文件
* 之后就走面板配置文件,这些键优先从面板配置上取」与「频率必须调,可以调到 1 小时
* 或者三小时,或者动态分配,比如白天间隔短一点、晚上静默期稍微长一点」。
*
* 三条口径落在文案里,因为它们是这张卡的产品承诺:
* 1. **面板优先、`.env` 兜底**:留空 = 不写这个键 ⇒ 回落 `.env` → 代码默认。
* 每个键都写清「跟随谁」,不做含糊的「未设置」;
* 2. **坏值就地报错**:校验规则来自后端的 specs(类型/单位/上下界),非法草稿
* **不发请求**(后端那道 400 仍在,只是不让主人白跑);
* 3. **热生效与否如实说**:后端回 `hot:false` 时写「重启后端后生效」,绝不写成
* 「已生效」。
*
* 呈现与取数分层:`MemoryTidyTuningBody` 只吃状态(渲染测试直接喂 fixture),卡组件
* 负责取数 + 表单草稿 + 保存。
*/
/** 这张卡的核心承诺(渲染测试钉住它必须出现)。 */
export const TIDY_TUNING_DISCLAIMER = "面板配置文件优先,.env 只做兜底";
/** 热生效与「要重启」两种文案(测试分别钉住)。 */
export const TIDY_TUNING_HOT_LABEL = "已热生效(不用重启后端)";
export const TIDY_TUNING_COLD_LABEL = "已保存到面板配置文件,重启后端后生效";
/** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */
export interface TuningBodyProps {
view: AdminMemoryTidyTuningView;
draft: TuningDraft;
errors: Record<string, string>;
busy: boolean;
/** 保存成功的回执文案(含热生效与否);null = 还没保存过。 */
receipt: string | null;
/** 保存后回显的真值(后端**重新读盘**的结果)。 */
savedView: AdminMemoryTidyTuningView | null;
onDraftChange: (key: string, value: string) => void;
onSave: () => void;
}
/** hotLabel 返回「这次到底生没生效」的诚实文案。 */
export function hotLabel(view: AdminMemoryTidyTuningView): string {
return view.hot ? TIDY_TUNING_HOT_LABEL : TIDY_TUNING_COLD_LABEL;
}
/** fallbackHint 返回「留空时跟随谁 + 兜底那一档当前是多少」(有就报数)。 */
function fallbackHint(view: AdminMemoryTidyTuningView, key: string, fallback: string): string {
const envValue = view.envValues?.[key];
const base = fallbackLabel({ key, kind: "int", fallback });
return envValue === undefined ? base : `${base}(当前 ${envValue})`;
}
/** FieldRow 渲染一个键:中文标签 + 控件 + 兜底说明 + 就地错误。 */
function FieldRow({
spec,
value,
error,
fallback,
valueHint,
onChange,
}: {
spec: AdminMemoryTidyTuningView["specs"][number];
value: string;
error: string;
fallback: string;
valueHint: string;
onChange: (next: string) => void;
}): React.ReactNode {
const label = TUNING_LABELS[spec.key] ?? spec.key;
const inputCls =
"w-40 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
return (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)]/60 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-56 text-xs text-[var(--mu-text-hi)]">{label}</span>
{spec.kind === "bool" ? (
<select
value={value}
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
onChange={(e) => onChange(e.target.value)}
>
<option value="">跟随兜底</option>
<option value="true">开</option>
<option value="false">关</option>
</select>
) : (
<input
value={value}
inputMode="numeric"
placeholder="留空 = 跟随兜底"
className={inputCls}
onChange={(e) => onChange(e.target.value)}
/>
)}
{valueHint ? <span className="text-[11px] text-[var(--mu-text-lo)]">{valueHint}</span> : null}
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{spec.key}</span>
</div>
<div className="text-[11px] text-[var(--mu-text-lo)]">
{value.trim() === "" ? `留空 ⇒ ${fallback}` : `写进面板配置文件(覆盖 ${fallback})`}
{spec.kind === "int" && unitLabel(spec) ? ` · 单位:${unitLabel(spec)}` : ""}
</div>
{error ? <div className="text-[11px] text-[var(--mu-err)]">⚠ {error}</div> : null}
</div>
);
}
/** MemoryTidyTuningBody 是展示半边:吃状态、不取数(渲染测试直接喂它)。 */
export function MemoryTidyTuningBody({
view,
draft,
errors,
busy,
receipt,
savedView,
onDraftChange,
onSave,
}: TuningBodyProps): React.ReactNode {
const hasErrors = Object.keys(errors).length > 0;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Tag tone="accent">{TIDY_TUNING_DISCLAIMER}</Tag>
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{view.path}</span>
<span className="text-[11px] text-[var(--mu-text-lo)]">
{view.present ? "面板配置已存在" : "面板配置还没创建(后端下次启动会自动创建)"}
</span>
</div>
{view.specs.map((spec) => (
<FieldRow
key={spec.key}
spec={spec}
value={draft[spec.key] ?? ""}
error={errors[spec.key] ?? ""}
fallback={fallbackHint(view, spec.key, spec.fallback)}
valueHint={spec.unit === "sec" && /^\d+$/.test((draft[spec.key] ?? "").trim())
? secondsHint(Number((draft[spec.key] ?? "").trim()))
: ""}
onChange={(next) => onDraftChange(spec.key, next)}
/>
))}
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={busy || hasErrors} onClick={onSave}>
{busy ? "保存中…" : "保存调优"}
</Button>
{hasErrors ? (
<span className="text-[11px] text-[var(--mu-text-lo)]">有坏值,未提交(后端一个字都不会改)</span>
) : null}
</div>
{receipt ? <Notice>{receipt}</Notice> : null}
{savedView ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
<span className="text-[var(--mu-text-mid)]">保存后回显(从磁盘重新读回的真值):</span>
{savedView.specs.map((spec) => {
const v = savedView.values[spec.key];
return (
<div key={spec.key} className="font-mono text-[var(--mu-text-lo)]">
{spec.key} = {v === undefined ? "(未写 ⇒ 跟随兜底)" : String(v)}
</div>
);
})}
<div className="text-[var(--mu-text-mid)]">{hotLabel(savedView)}</div>
{savedView.hot ? null : (
<div className="text-[var(--mu-text-lo)]">{savedView.hotError ?? "运行期未接线可热生效的实现"}</div>
)}
</div>
) : null}
<p className="text-[11px] text-[var(--mu-text-lo)]">
只写这张卡的 7 个键;文件里其它旋钮(T1 判定 / T1 限速 / 每轮自动落地上限)原样保留。
清空一个键 = 从面板文件里删掉它(回到下一档兜底),不是写 0。
</p>
</div>
);
}
/** MemoryTidyTuningCard 负责取数、表单草稿与保存。 */
export function MemoryTidyTuningCard(): React.ReactNode {
const [view, setView] = useState<AdminMemoryTidyTuningView | null>(null);
const [draft, setDraft] = useState<TuningDraft>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [receipt, setReceipt] = useState<string | null>(null);
const [savedView, setSavedView] = useState<AdminMemoryTidyTuningView | null>(null);
const log = useAuditStore((s) => s.log);
const reload = useCallback(() => {
setFail(null);
return adminMemoryTidyTuning()
.then((next) => {
setView(next);
setDraft(draftFromView(next));
})
.catch((e: unknown) => setFail(e instanceof Error ? e.message : "读取面板配置失败"));
}, []);
useEffect(() => {
void reload();
}, [reload]);
const onDraftChange = useCallback((key: string, value: string) => {
setDraft((prev) => ({ ...prev, [key]: value }));
setReceipt(null);
}, []);
const onSave = useCallback(() => {
if (!view) return;
const check = buildTuningPatch(view, draft);
setErrors(check.errors);
if (Object.keys(check.errors).length > 0) return;
setBusy(true);
setFail(null);
adminMemoryTidyTuningSave(check.patch)
.then((next) => {
// 响应里的值是后端**重新读盘**的结果:据此重铺草稿(回显真值)。
setView(next);
setDraft(draftFromView(next));
setSavedView(next);
setErrors({});
setReceipt(`已保存:${patchSummary(check.patch)}。${hotLabel(next)}`);
log("memory.tidy.tuning", "ok");
})
.catch((e: unknown) => {
setFail(e instanceof Error ? e.message : "保存面板配置失败");
log("memory.tidy.tuning", "error");
})
.finally(() => setBusy(false));
}, [view, draft, log]);
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">记忆整理调优(面板配置)</h3>
<Tag tone="neutral">GET / PUT /api/v1/admin/memories/tidy/tuning</Tag>
</div>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{view === null ? (
<span className="text-xs text-[var(--mu-text-lo)]">{fail ? "读取失败" : "读取中…"}</span>
) : (
<MemoryTidyTuningBody
view={view}
draft={draft}
errors={errors}
busy={busy}
receipt={receipt}
savedView={savedView}
onDraftChange={onDraftChange}
onSave={onSave}
/>
)}
</Surface>
);
}
@@ -3,7 +3,7 @@ import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types
import { Button } from "../../components/primitives"; import { Button } from "../../components/primitives";
import { import {
accountCandidates, accountCandidates,
flattenAccounts, draftAccounts,
groupAccounts, groupAccounts,
platformIsConfigured, platformIsConfigured,
platformOptions, platformOptions,
@@ -13,11 +13,16 @@ import {
/** /**
* 渠道账号白名单编辑器(主人 2026-10-05 要求):**一个平台一条**,该平台下用 * 渠道账号白名单编辑器(主人 2026-10-05 要求):**一个平台一条**,该平台下用
* 「+ 添加账号 ID」加多行;平台是权威枚举**下拉**;账号 ID 旁给「从该平台已配置 * 「+ 添加账号 ID」加多行;平台是权威枚举**下拉**;账号 ID 旁给「从该平台已配置
* 账号里选」的候选。改动经 `onChange` 回到草稿时**立即展平**为 * 账号里选」的候选。改动经 `onChange` 回到草稿时**写回扁平数组**
* `accounts:[{platform,accountId}…]` —— 后端契约与 `owner.yaml` 格式完全不变。 * `accounts:[{platform,accountId}…]` —— 后端契约与 `owner.yaml` 格式完全不变。
* *
* 为什么不在这里直接保存:档案的读取/草稿/保存状态由 `Users.tsx` 的 * 为什么不在这里直接保存:档案的读取/草稿/保存状态由 `Users.tsx` 的
* `OwnerProfileSection` 统一持有(一次真实 PUT),本组件只管呈现与展平。 * `OwnerProfileSection` 统一持有(一次真实 PUT),本组件只管呈现与写回草稿。
*
* 为什么拆成两个函数:`OwnerAccountsEditor` 只留「分组/选项」这层 memo(同一表单里改
* 别的字段时不必重算),界面本体 `OwnerAccountsView` 是**无 hook** 的 —— 本项目没有
* jsdom,测试正是**直接调用**它、点它自己的按钮来钉住「手势 → 草稿 → 重渲染」这条往返
* (见 `ownerAccounts.test.ts` ⑤)。往 View 里加 hook 会让那条测试报 "Invalid hook call"。
*/ */
export function OwnerAccountsEditor({ export function OwnerAccountsEditor({
accounts, accounts,
@@ -39,10 +44,39 @@ export function OwnerAccountsEditor({
() => platformOptions(platforms, groups.map((g) => g.platform)), () => platformOptions(platforms, groups.map((g) => g.platform)),
[platforms, groups] [platforms, groups]
); );
return (
<OwnerAccountsView
groups={groups}
options={options}
channels={channels}
disabled={disabled}
onChange={onChange}
/>
);
}
// 每次改动都「分组 → 展平 → 回草稿」:草稿里始终是后端要的扁平形态, export interface OwnerAccountsViewProps {
// 界面上的分组只是它的投影,两者不会各说各话。 /** 界面上的分组(草稿的投影);手势产生的下一份**草稿**也由它算出来。 */
const emit = (next: OwnerAccountGroup[]): void => onChange(flattenAccounts(next)); groups: OwnerAccountGroup[];
options: EnumItem[];
channels: AdminChannelRecord[] | null;
disabled: boolean;
/** 新草稿(扁平数组)。 */
onChange: (next: OwnerAccount[]) => void;
}
/** 界面本体(**无 hook**,见文件头):所有手势都走这里 → `onChange(新草稿)`。 */
export function OwnerAccountsView({
groups,
options,
channels,
disabled,
onChange,
}: OwnerAccountsViewProps): React.ReactNode {
// 每次改动都「分组 → 改 → 回草稿」:草稿里始终是扁平形态,界面上的分组只是它的投影,
// 两者不会各说各话。写回必须**无损**(`draftAccounts`):一旦写成有损的写盘形态,刚点出来的
// 「+ 添加账号 ID」空行当场蒸发 —— 点了等于没点。空行只在保存时丢(见 `accountsForSave`)。
const emit = (next: OwnerAccountGroup[]): void => onChange(draftAccounts(next));
const patchGroup = (i: number, patch: Partial<OwnerAccountGroup>): void => const patchGroup = (i: number, patch: Partial<OwnerAccountGroup>): void =>
emit(groups.map((g, idx) => (idx === i ? { ...g, ...patch } : g))); emit(groups.map((g, idx) => (idx === i ? { ...g, ...patch } : g)));
@@ -156,6 +190,7 @@ function PlatformGroupRow(props: PlatformGroupRowProps): React.ReactNode {
{group.accountIds.map((accountId, j) => ( {group.accountIds.map((accountId, j) => (
<AccountIdRow <AccountIdRow
key={`acct-${index}-${j}`} key={`acct-${index}-${j}`}
row={j}
listId={`acct-${index}-${j}-candidates`} listId={`acct-${index}-${j}-candidates`}
accountId={accountId} accountId={accountId}
candidates={candidates} candidates={candidates}
@@ -227,6 +262,8 @@ function PlatformPicker({
interface AccountIdRowProps { interface AccountIdRowProps {
listId: string; listId: string;
/** 在本分组里的行号(`0` 起):给行一个稳定的定位标记,浏览器里/测试里都指得到具体那一行。 */
row: number;
accountId: string; accountId: string;
candidates: string[]; candidates: string[];
platform: string; platform: string;
@@ -239,6 +276,7 @@ interface AccountIdRowProps {
/** 一行账号 ID:手填 + 该平台已配置账号的候选(点一下就填入)。 */ /** 一行账号 ID:手填 + 该平台已配置账号的候选(点一下就填入)。 */
function AccountIdRow({ function AccountIdRow({
listId, listId,
row,
accountId, accountId,
candidates, candidates,
platform, platform,
@@ -248,7 +286,7 @@ function AccountIdRow({
onRemove, onRemove,
}: AccountIdRowProps): React.ReactNode { }: AccountIdRowProps): React.ReactNode {
return ( return (
<div className="space-y-1"> <div className="space-y-1" data-account-row={row}>
<div className="flex flex-wrap items-end gap-2"> <div className="flex flex-wrap items-end gap-2">
<label className="block min-w-56 flex-1 space-y-1"> <label className="block min-w-56 flex-1 space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">账号 ID</span> <span className="text-xs text-[var(--mu-text-lo)]">账号 ID</span>
+205 -43
View File
@@ -7,6 +7,12 @@ import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"; import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives"; import { Button, Surface, Switch, Tag } from "../../components/primitives";
import { InstalledPluginsPanel } from "./plugins/InstalledPlugins"; import { InstalledPluginsPanel } from "./plugins/InstalledPlugins";
import {
mcpServerEntries,
originGroupTitle,
otherCapabilityEntries,
toolOriginBuckets,
} from "./pluginOrigin";
type CapKind = "tool" | "skill" | "channel"; type CapKind = "tool" | "skill" | "channel";
@@ -16,6 +22,9 @@ const KIND_LABELS: Record<CapKind, string> = {
channel: "渠道 Channel", channel: "渠道 Channel",
}; };
/** 指向「MCP 服务器」页的提示文案:server 级动作(启停/重连/卸载)都在那边。 */
const MCP_PAGE_HINT = "在「MCP 服务器」页卸载该 server";
// Channel adapters are not gated by the tool policy (group/stranger markers are a // Channel adapters are not gated by the tool policy (group/stranger markers are a
// tool concept; a channel is configured via its own 渠道管理 page), so the // tool concept; a channel is configured via its own 渠道管理 page), so the
// channel block shows identity + a pointer rather than the policy switches. // channel block shows identity + a pointer rather than the policy switches.
@@ -27,7 +36,44 @@ function channelColumns(): AdminColumn<AdminPluginRecord>[] {
]; ];
} }
/** Columns for a tool / skill row (has environment markers, owner can edit). */ /** The environment-marker switches, shared by tool and skill rows.
*
* 它对**工具名**生效(`tool_policies` 以 name 为键),因此来自 MCP server 的工具有同
* 一套开关;`mcp:<server>` 那条 KindMCP 条目拿不到开关(策略判定从不读它),所以在
* MCP 登记段里不给开关 —— 假开关比没有开关更糟。 */
function policyColumn(
isOwner: boolean,
busy: string | null,
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
): AdminColumn<AdminPluginRecord> {
return {
key: "policy",
title: "环境授权(owner)",
render: (p) => (
<span className="flex items-center gap-4">
<label className="flex items-center gap-1.5">
<Switch
checked={p.allowInGroup}
disabled={!isOwner || busy === p.name}
onChange={(v) => setPolicy(p.name, v, p.allowInStranger)}
/>
<span className="text-xs text-[var(--mu-text-mid)]">群聊</span>
</label>
<label className="flex items-center gap-1.5">
<Switch
checked={p.allowInStranger}
disabled={!isOwner || busy === p.name}
onChange={(v) => setPolicy(p.name, p.allowInGroup, v)}
/>
<span className="text-xs text-[var(--mu-text-mid)]">陌生人</span>
</label>
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
</span>
),
};
}
/** Columns for a host-builtin tool / a skill row (uninstall = remove this capability). */
function registeredColumns( function registeredColumns(
isOwner: boolean, isOwner: boolean,
busy: string | null, busy: string | null,
@@ -38,31 +84,7 @@ function registeredColumns(
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> }, { key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{ key: "kind", title: "类型", render: (p) => <Tag tone="accent">{(KIND_LABELS as Record<string, string>)[p.kind] ?? p.kind}</Tag> }, { key: "kind", title: "类型", render: (p) => <Tag tone="accent">{(KIND_LABELS as Record<string, string>)[p.kind] ?? p.kind}</Tag> },
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> }, { key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
{ policyColumn(isOwner, busy, setPolicy),
key: "policy",
title: "环境授权(owner)",
render: (p) => (
<span className="flex items-center gap-4">
<label className="flex items-center gap-1.5">
<Switch
checked={p.allowInGroup}
disabled={!isOwner || busy === p.name}
onChange={(v) => setPolicy(p.name, v, p.allowInStranger)}
/>
<span className="text-xs text-[var(--mu-text-mid)]">群聊</span>
</label>
<label className="flex items-center gap-1.5">
<Switch
checked={p.allowInStranger}
disabled={!isOwner || busy === p.name}
onChange={(v) => setPolicy(p.name, p.allowInGroup, v)}
/>
<span className="text-xs text-[var(--mu-text-mid)]">陌生人</span>
</label>
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
</span>
),
},
{ {
key: "actions", key: "actions",
title: "操作", title: "操作",
@@ -75,6 +97,61 @@ function registeredColumns(
]; ];
} }
/** Columns for a tool that belongs to an MCP server.
*
* 🔴 行内**没有**「卸载」:单把 MCP 工具不是独立安装单元 —— 卸载它只能绕过插件管理器
* 裸删注册表条目,而该 server 的 names 账本不同步(重连/refresh 后工具复活,用户以为
* 卸掉了)。服务端同样拒绝这条路径(`plugin_uninstall_scope`),这里给出该去哪里做。 */
function mcpToolColumns(
isOwner: boolean,
busy: string | null,
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
): AdminColumn<AdminPluginRecord>[] {
return [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{
key: "origin",
title: "来源",
render: (p) => <Tag tone="neutral" title="注册时由后端盖章的上游归属,不从工具名推断">{p.origin?.server || "未知 server"}</Tag>,
},
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
policyColumn(isOwner, busy, setPolicy),
{
key: "actions",
title: "操作",
render: () => <span className="text-xs text-[var(--mu-text-lo)]">{MCP_PAGE_HINT}</span>,
},
];
}
/** Columns for a `kind:"mcp"` entry: one registration row per server, server-scoped. */
function mcpServerColumns(): AdminColumn<AdminPluginRecord>[] {
return [
{ key: "name", title: "登记键", render: (p) => <span className="font-mono text-xs font-medium">{p.name}</span> },
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-md truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
{
key: "admin",
title: "管理",
render: () => (
<span className="text-xs text-[var(--mu-text-lo)]">
启停 / 重连 / 卸载 / 安装见「MCP 服务器」页;本行是注册表登记条目,不承载单独的工具策略
</span>
),
},
];
}
/** Columns for capability kinds the page does not otherwise model (e.g. iot).
*
* design/09 §5.2.1 红线③:已装但本页不认识的条目要**如实显示**,不得静默过滤。 */
function otherColumns(): AdminColumn<AdminPluginRecord>[] {
return [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{ key: "kind", title: "类型", render: (p) => <Tag tone="neutral">{p.kind}</Tag> },
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-md truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
];
}
export function PluginsModule(): React.ReactNode { export function PluginsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListPlugins, []); const { data, loading, error, reload } = useAsyncData(adminListPlugins, []);
useAutoReload(reload, 10000); useAutoReload(reload, 10000);
@@ -125,14 +202,22 @@ export function PluginsModule(): React.ReactNode {
return groups; return groups;
}, [data]); }, [data]);
const toolSkillColumns = registeredColumns(isOwner, busy, setPolicy, uninstall); // 工具段的两级分节:先按上游 server,再剩「宿主内置」。
const toolBuckets = useMemo(() => toolOriginBuckets(byKind.tool), [byKind.tool]);
const mcpServers = useMemo(() => mcpServerEntries(data ?? []), [data]);
const others = useMemo(() => otherCapabilityEntries(data ?? []), [data]);
const toolCols = registeredColumns(isOwner, busy, setPolicy, uninstall);
const mcpToolCols = mcpToolColumns(isOwner, busy, setPolicy);
const channelCols = channelColumns(); const channelCols = channelColumns();
const serverCols = mcpServerColumns();
const otherCols = otherColumns();
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<PageHeader <PageHeader
title="插件与能力" title="插件与能力"
subtitle="已注册能力(按工具 / 技能 / 渠道分组)+ 运行时 MCP 安装/卸载 · POST /api/v1/admin/plugins/policy (owner-only)" subtitle="已注册能力(工具按上游 MCP server 分节 / 技能 / 渠道)+ 运行时 MCP 安装 · POST /api/v1/admin/plugins/policy (owner-only)"
/> />
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。</Notice> : null} {!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。</Notice> : null}
@@ -144,28 +229,66 @@ export function PluginsModule(): React.ReactNode {
<Surface className="space-y-3"> <Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">已注册能力</h3> <h3 className="text-sm font-semibold">已注册能力</h3>
<span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins · 按工具 / 技能 / 渠道分组</span> <span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins · 工具按上游 server 分节</span>
</div> </div>
{(["tool", "skill"] as CapKind[]).map((kind) => { <div className="space-y-1.5">
const rows = byKind[kind]; <div className="flex items-center gap-2 pt-1">
return ( <Tag tone="accent">{KIND_LABELS.tool}</Tag>
<div key={kind} className="space-y-1.5"> <span className="text-xs text-[var(--mu-text-lo)]">
<div className="flex items-center gap-2 pt-1"> {byKind.tool.length} 个 · {toolBuckets.mcp.length} 台 MCP server + 宿主内置
<Tag tone="accent">{KIND_LABELS[kind]}</Tag> </span>
<span className="text-xs text-[var(--mu-text-lo)]">{rows.length} 个</span> </div>
{toolBuckets.mcp.map((group) => (
<div key={group.key} className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2 pt-1">
<Tag tone="neutral" title="来源由后端在注册时盖章,不从工具名解析">
{originGroupTitle(group)}
</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">server 级动作见「MCP 服务器」页</span>
</div> </div>
<AdminTable <AdminTable
columns={toolSkillColumns} columns={mcpToolCols}
rows={loading ? null : rows} rows={loading ? null : group.rows}
loading={loading} loading={loading}
error={error} error={error}
emptyTitle={`无${KIND_LABELS[kind]}`} emptyTitle="无工具"
rowKey={(p) => `${kind}:${p.name}`} rowKey={(p) => `mcp:${group.key}:${p.name}`}
/> />
</div> </div>
); ))}
})}
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2 pt-1">
<Tag tone="neutral">宿主内置工具</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">{toolBuckets.builtin.length} 把</span>
</div>
<AdminTable
columns={toolCols}
rows={loading ? null : toolBuckets.builtin}
loading={loading}
error={error}
emptyTitle="无宿主内置工具"
rowKey={(p) => `tool:${p.name}`}
/>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2 pt-1">
<Tag tone="accent">{KIND_LABELS.skill}</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">{byKind.skill.length} 个</span>
</div>
<AdminTable
columns={toolCols}
rows={loading ? null : byKind.skill}
loading={loading}
error={error}
emptyTitle={`无${KIND_LABELS.skill}`}
rowKey={(p) => `skill:${p.name}`}
/>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center gap-2 pt-1"> <div className="flex items-center gap-2 pt-1">
@@ -181,9 +304,48 @@ export function PluginsModule(): React.ReactNode {
rowKey={(p) => `channel:${p.name}`} rowKey={(p) => `channel:${p.name}`}
/> />
</div> </div>
{/* 🔴 `kind:"mcp"` 条目**必须如实显示**(design/09 §5.2.1 红线③):它是每台
server 的注册登记,不是可调用能力,因此这里给事实 + 指向,不给策略开关
(`tool_policies` 只按工具名判定,对这条永远是惰性的 = 假开关)。 */}
<div className="space-y-1.5">
<div className="flex items-center gap-2 pt-1">
<Tag tone="neutral">MCP server 登记</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">{mcpServers.length} 台</span>
</div>
<AdminTable
columns={serverCols}
rows={loading ? null : mcpServers}
loading={loading}
error={error}
emptyTitle="无 MCP server 登记(可在「MCP 服务器」页添加)"
rowKey={(p) => `mcpentry:${p.name}`}
/>
</div>
{others.length > 0 ? (
<div className="space-y-1.5">
<div className="flex items-center gap-2 pt-1">
<Tag tone="neutral">其它能力</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">{others.length} 个 · 本页暂无专用视图,如实列出</span>
</div>
<AdminTable
columns={otherCols}
rows={others}
loading={loading}
error={error}
emptyTitle="无其它能力"
rowKey={(p) => `other:${p.name}`}
/>
</div>
) : null}
</Surface> </Surface>
<Notice>工具 schema / 技能为后端 M10+ 提供;此页按类型分组展示已注册能力。群聊/陌生人环境可见性标记(默认关闭)仅对工具/技能生效。</Notice> <Notice>
工具按「注册时」的来源分节(来自哪台 MCP server 由后端盖章,不解析工具名);这是「呈现」分组,权限仍由
tool_policies 的逐工具环境授权决定。MCP server 的启停/重连/卸载/安装都在「MCP 服务器」页 ——
单把 MCP 工具不能单独卸载(否则 server 侧的工具账本会不同步)。
</Notice>
</div> </div>
); );
} }
+4 -1
View File
@@ -20,6 +20,7 @@ import type {
import { useAuditStore } from "../audit"; import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"; import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { OwnerAccountsEditor } from "./OwnerAccountsEditor"; import { OwnerAccountsEditor } from "./OwnerAccountsEditor";
import { accountsForSave } from "./ownerAccounts";
import { Button, Surface, Tag } from "../../components/primitives"; import { Button, Surface, Tag } from "../../components/primitives";
const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = { const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
@@ -166,7 +167,9 @@ function OwnerProfileSection({ isOwner }: { isOwner: boolean }): React.ReactNode
setBusy(true); setBusy(true);
setFail(null); setFail(null);
setNotice(null); setNotice(null);
adminSaveOwnerProfile(profile) // 草稿里允许有还没填完的空行(编辑期不能丢行),写盘前才收成写盘形态:只留平台与账号 ID
// 都非空的条目,并去掉两侧空白。既不把空行写进 owner.yaml,也不会因为半成品条目被后端 400。
adminSaveOwnerProfile({ ...profile, accounts: accountsForSave(profile.accounts) })
.then((saved) => { .then((saved) => {
log("owner.profile.edit", "ok"); log("owner.profile.edit", "ok");
setProfile(saved); setProfile(saved);
@@ -0,0 +1,343 @@
/**
* 渠道「静默窗口合并」卡(M23)。真源:internal/services/gateway/admin_coalesce.go。
*
* 它回答的是主人的原话「要让群聊聊天很自然,很流畅的做法」:群里「一鼓作气打三句」
* 不该让她回三次 —— 她在群里等一小段静默,把这几句**合并成一轮**回答(批内每条仍是
* 独立的一条消息,绝不拼成一条)。
*
* 三条口径落在文案里,因为它们是这张卡的产品承诺:
* 1. **主人私聊没有这个窗口**:她的私聊一条一条立刻回(`ownerPrivate:"never"` 是后端
* 下发的只读事实,面板**不给**开关 —— 那条车道由身份元数据决定,配置翻不开);
* 2. **越界不拒绝、会夹紧**:规则随读下发(`rules`),面板就地校验,后端仍会 clamp
* 并留 WARN —— 不让主人白跑,也不假装「你配的数就是生效的数」;
* 3. **保存即热生效**:桥每次发车前重读同一份配置,所以 `hot:true` 是事实。
*
* 呈现与取数分层:`CoalesceCardBody` 只吃状态(渲染测试直接喂 fixture),
* `ChannelCoalesceCard` 负责取数 + 表单草稿 + 保存。
*/
import { useCallback, useEffect, useState } from "react";
import { request } from "../../../lib/api/core";
import { Button, Surface, Tag } from "../../../components/primitives";
import { Notice } from "../../components";
import { useAuditStore } from "../../audit";
/** 一组窗口参数(后端 AdminCoalescePolicy 的同形投影)。 */
export interface CoalescePolicyView {
enabled: boolean;
windowMs: number;
maxWaitMs: number;
maxBatchMessages: number;
applyToStrangers: boolean;
strangerWindowMs: number;
}
/** 取值规则(后端 AdminCoalesceRules)。 */
export interface CoalesceRules {
windowMs: [number, number];
maxWaitMs: [number, number];
maxBatchMessages: [number, number];
clamp: string;
flushReasons: string[];
}
/** GET/PUT 的 data 形状(后端 AdminCoalesceView)。 */
export interface CoalesceView {
path: string;
present: boolean;
hot: boolean;
default: CoalescePolicyView;
patch: Record<string, unknown>;
conversations: Record<string, CoalescePolicyView>;
rules: CoalesceRules;
ownerPrivate: string;
}
/** 表单草稿:一律字符串(输入框的原样值),保存时才转数字。 */
export interface CoalesceDraft {
enabled: string;
windowMs: string;
maxWaitMs: string;
maxBatchMessages: string;
applyToStrangers: string;
strangerWindowMs: string;
}
/** 只读事实的文案(渲染测试钉住它必须出现)。 */
export const OWNER_PRIVATE_NOTE =
"主人私聊不受此设置影响:她的私聊没有静默窗口,一条一条立刻回(由身份元数据决定,面板没有开关)";
/** 热生效文案。 */
export const COALESCE_HOT_LABEL = "已热生效(下一次发车就用新值,不用重启后端)";
/** 字段顺序与中文标签(表单项由它驱动,避免手写六段重复代码)。 */
export const COALESCE_FIELDS: ReadonlyArray<{
key: keyof CoalesceDraft;
label: string;
kind: "bool" | "int";
hint: string;
}> = [
{ key: "enabled", label: "启用群聊静默窗口", kind: "bool", hint: "关掉 = 恢复到「到达即回、一条一轮」" },
{ key: "windowMs", label: "静默窗口(毫秒)", kind: "int", hint: "新消息会重置计时(等她把话说完)" },
{ key: "maxWaitMs", label: "绝对上限(毫秒)", kind: "int", hint: "从这一批第一条算起,到点必发车" },
{ key: "maxBatchMessages", label: "单批上限(条)", kind: "int", hint: "超出不是丢弃,是多出来的排成下一批" },
{ key: "applyToStrangers", label: "对陌生人私聊生效", kind: "bool", hint: "关掉 = 陌生人私聊逐条回" },
{ key: "strangerWindowMs", label: "陌生人私聊窗口(毫秒)", kind: "int", hint: "仅在「对陌生人私聊生效」时使用" },
];
/** draftFromView 把生效值铺成草稿。 */
export function draftFromView(view: CoalesceView): CoalesceDraft {
return {
enabled: String(view.default.enabled),
windowMs: String(view.default.windowMs),
maxWaitMs: String(view.default.maxWaitMs),
maxBatchMessages: String(view.default.maxBatchMessages),
applyToStrangers: String(view.default.applyToStrangers),
strangerWindowMs: String(view.default.strangerWindowMs),
};
}
/** parseCoalescePatch 把草稿按**三态**转成补丁:只有与当前生效值不同的键才写。 */
export function parseCoalescePatch(
view: CoalesceView,
draft: CoalesceDraft
): { patch: { default: Record<string, number | boolean> }; errors: Record<string, string>; changed: string[] } {
const errors: Record<string, string> = {};
const out: Record<string, number | boolean> = {};
const changed: string[] = [];
const intIn = (key: string, value: string, lo: number, hi: number): number | null => {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
errors[key] = "要填整数(毫秒/条)";
return null;
}
const n = Number(trimmed);
if (n < lo || n > hi) {
errors[key] = `取值范围 ${lo}–${hi}`;
return null;
}
return n;
};
const window = intIn("windowMs", draft.windowMs, view.rules.windowMs[0], view.rules.windowMs[1]);
const maxWait = window === null
? null
: intIn("maxWaitMs", draft.maxWaitMs, Math.max(view.rules.maxWaitMs[0], window), view.rules.maxWaitMs[1]);
const batch = intIn("maxBatchMessages", draft.maxBatchMessages, view.rules.maxBatchMessages[0], view.rules.maxBatchMessages[1]);
const strangerWindow = intIn(
"strangerWindowMs",
draft.strangerWindowMs,
view.rules.windowMs[0],
view.rules.windowMs[1]
);
const enabled = draft.enabled === "true";
const applyToStrangers = draft.applyToStrangers === "true";
const put = (key: string, next: number | boolean, current: number | boolean): void => {
if (next !== current) {
out[key] = next;
changed.push(key);
}
};
if (window !== null) put("windowMs", window, view.default.windowMs);
if (maxWait !== null) put("maxWaitMs", maxWait, view.default.maxWaitMs);
if (batch !== null) put("maxBatchMessages", batch, view.default.maxBatchMessages);
if (strangerWindow !== null) put("strangerWindowMs", strangerWindow, view.default.strangerWindowMs);
put("enabled", enabled, view.default.enabled);
put("applyToStrangers", applyToStrangers, view.default.applyToStrangers);
return { patch: { default: out }, errors, changed };
}
/** fetchCoalesce 读一次配置(面板配置面,writeAuth = owner|admin)。 */
export function fetchCoalesce(): Promise<CoalesceView> {
return request<CoalesceView>("/admin/channels/coalesce");
}
/** saveCoalesce 提交三态补丁(返回**重新读盘**后的真值)。 */
export function saveCoalesce(patch: { default: Record<string, number | boolean> }): Promise<CoalesceView> {
return request<CoalesceView>("/admin/channels/coalesce", {
method: "PUT",
body: JSON.stringify(patch),
});
}
/** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */
export interface CoalesceBodyProps {
view: CoalesceView;
draft: CoalesceDraft;
errors: Record<string, string>;
busy: boolean;
receipt: string | null;
savedView: CoalesceView | null;
onDraftChange: (key: keyof CoalesceDraft, value: string) => void;
onSave: () => void;
}
/** CoalesceCardBody 是展示半边:吃状态、不取数(渲染测试直接喂它)。 */
export function CoalesceCardBody({
view,
draft,
errors,
busy,
receipt,
savedView,
onDraftChange,
onSave,
}: CoalesceBodyProps): React.ReactNode {
const hasErrors = Object.keys(errors).length > 0;
const inputCls =
"w-36 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Tag tone="accent">{COALESCE_HOT_LABEL}</Tag>
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{view.path}</span>
<span className="text-[11px] text-[var(--mu-text-lo)]">
{view.present ? "面板配置已存在" : "面板配置还没写过(运行期走代码默认:2.0s / 6s / 24 条)"}
</span>
</div>
<Notice>{OWNER_PRIVATE_NOTE}</Notice>
{COALESCE_FIELDS.map((field) => (
<div key={field.key} className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)]/60 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-56 text-xs text-[var(--mu-text-hi)]">{field.label}</span>
{field.kind === "bool" ? (
<select
value={draft[field.key]}
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
onChange={(e) => onDraftChange(field.key, e.target.value)}
>
<option value="true">开</option>
<option value="false">关</option>
</select>
) : (
<input
value={draft[field.key]}
inputMode="numeric"
className={inputCls}
onChange={(e) => onDraftChange(field.key, e.target.value)}
/>
)}
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{field.key}</span>
</div>
<div className="text-[11px] text-[var(--mu-text-lo)]">{field.hint}</div>
{errors[field.key] ? <div className="text-[11px] text-[var(--mu-err)]">⚠ {errors[field.key]}</div> : null}
</div>
))}
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={busy || hasErrors} onClick={onSave}>
{busy ? "保存中…" : "保存窗口设置"}
</Button>
{hasErrors ? (
<span className="text-[11px] text-[var(--mu-text-lo)]">有坏值,未提交(后端一个字都不会改)</span>
) : null}
</div>
{receipt ? <Notice>{receipt}</Notice> : null}
{savedView ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
<span className="text-[var(--mu-text-mid)]">保存后回显(后端重新读盘的真值):</span>
<div className="font-mono text-[var(--mu-text-lo)]">
窗口 {savedView.default.windowMs}ms · 上限 {savedView.default.maxWaitMs}ms · 单批{" "}
{savedView.default.maxBatchMessages} 条 · {savedView.default.enabled ? "已启用" : "已关闭"}
</div>
<div className="text-[var(--mu-text-mid)]">{savedView.hot ? COALESCE_HOT_LABEL : "已落盘,但未能热生效"}</div>
</div>
) : null}
{Object.keys(view.conversations).length > 0 ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
<span className="text-[var(--mu-text-mid)]">每会话覆盖(在配置文件里维护,键 = 平台|渠道):</span>
{Object.entries(view.conversations).map(([key, policy]) => (
<div key={key} className="font-mono text-[var(--mu-text-lo)]">
{key} → {policy.enabled ? "启用" : "关闭"} · 窗口 {policy.windowMs}ms · 单批 {policy.maxBatchMessages} 条
</div>
))}
</div>
) : null}
<p className="text-[11px] text-[var(--mu-text-lo)]">
规则:窗口 {view.rules.windowMs[0]}–{view.rules.windowMs[1]}ms、绝对上限 ≥ 窗口且 ≤ {view.rules.maxWaitMs[1]}ms、单批{" "}
{view.rules.maxBatchMessages[0]}–{view.rules.maxBatchMessages[1]} 条;越界值由后端夹紧({view.rules.clamp})并留日志。
发车原因:{view.rules.flushReasons.join(" / ")}。
</p>
</div>
);
}
/** ChannelCoalesceCard 负责取数、表单草稿与保存。 */
export function ChannelCoalesceCard(): React.ReactNode {
const [view, setView] = useState<CoalesceView | null>(null);
const [draft, setDraft] = useState<CoalesceDraft | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [receipt, setReceipt] = useState<string | null>(null);
const [savedView, setSavedView] = useState<CoalesceView | null>(null);
const log = useAuditStore((s) => s.log);
const reload = useCallback(() => {
setFail(null);
return fetchCoalesce()
.then((next) => {
setView(next);
setDraft(draftFromView(next));
})
.catch((e: unknown) => setFail(e instanceof Error ? e.message : "读取窗口配置失败"));
}, []);
useEffect(() => {
void reload();
}, [reload]);
const onDraftChange = useCallback((key: keyof CoalesceDraft, value: string) => {
setDraft((prev) => (prev ? { ...prev, [key]: value } : prev));
setReceipt(null);
}, []);
const onSave = useCallback(() => {
if (!view || !draft) return;
const check = parseCoalescePatch(view, draft);
setErrors(check.errors);
if (Object.keys(check.errors).length > 0) return;
if (check.changed.length === 0) {
setReceipt("没有改动(一个键都没写)");
return;
}
setBusy(true);
setFail(null);
saveCoalesce(check.patch)
.then((next) => {
setView(next);
setDraft(draftFromView(next));
setSavedView(next);
setErrors({});
setReceipt(`已保存:${check.changed.join(", ")}。${COALESCE_HOT_LABEL}`);
log("channels.coalesce", "ok");
})
.catch((e: unknown) => {
setFail(e instanceof Error ? e.message : "保存窗口配置失败");
log("channels.coalesce", "error");
})
.finally(() => setBusy(false));
}, [view, draft, log]);
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">群聊静默窗口合并</h3>
<Tag tone="neutral">GET / PUT /api/v1/admin/channels/coalesce</Tag>
</div>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{view === null || draft === null ? (
<span className="text-xs text-[var(--mu-text-lo)]">{fail ? "读取失败" : "读取中…"}</span>
) : (
<CoalesceCardBody
view={view}
draft={draft}
errors={errors}
busy={busy}
receipt={receipt}
savedView={savedView}
onDraftChange={onDraftChange}
onSave={onSave}
/>
)}
</Surface>
);
}
@@ -9,6 +9,7 @@ import { PLATFORM_LABEL, PLATFORM_TYPES } from "../channelFields";
import { adapterNameOf, configuredAccountCount, configuredPlatforms, platformOf } from "./channelUtils"; import { adapterNameOf, configuredAccountCount, configuredPlatforms, platformOf } from "./channelUtils";
import { channelListState, channelStatus } from "./channelStatus"; import { channelListState, channelStatus } from "./channelStatus";
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView"; import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
import { ChannelCoalesceCard } from "./CoalesceCard";
import type { AdapterNav } from "./useAdapterNav"; import type { AdapterNav } from "./useAdapterNav";
/** Page ① — aggregate all adapters: connection state, message volume, health. /** Page ① — aggregate all adapters: connection state, message volume, health.
@@ -79,6 +80,9 @@ export function OverviewPage({ navigate }: { navigate: (n: AdapterNav) => void }
onOpenList={() => navigate({ kind: "page", page: "list" })} onOpenList={() => navigate({ kind: "page", page: "list" })}
/> />
) : null} ) : null}
{/* 群聊静默窗口合并(M23):窗口/上限这类调优只该在面板改,不该 SSH 改文件。 */}
<ChannelCoalesceCard />
</div> </div>
); );
} }
@@ -0,0 +1,140 @@
// 群聊静默窗口卡(M23)的**真实渲染**测试。
//
// 它钉住三件**呈现**的事(逻辑那一半在 CoalesceCard.tsx 的导出函数里):
// ① 六个字段都有中文标签 + 一句人话说明(窗口/上限/单批/陌生人/开关);
// ② 主人私聊**只给一行只读说明**(`ownerPrivate:"never"` 的呈现),**不给开关**;
// ③ 越界/非整数就地报错并拦住保存(后端那道 400 仍在,只是不让主人白跑)。
//
// 跑法(与 MemoryTidyTuningCard.render.test.ts 同):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channels/coalesceCard.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-coalesce-card.render.test.cjs \
// && node /tmp/cyrene-coalesce-card.render.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
COALESCE_FIELDS,
COALESCE_HOT_LABEL,
OWNER_PRIVATE_NOTE,
CoalesceCardBody,
draftFromView,
parseCoalescePatch,
type CoalesceView,
} from "./CoalesceCard";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
/** fixtureView 造一份 GET 响应形状的数据(代码默认 + 一条会话覆盖)。 */
function fixtureView(overrides: Partial<CoalesceView> = {}): CoalesceView {
return {
path: "/data/config/coalesce.json",
present: true,
hot: true,
default: {
enabled: true,
windowMs: 2000,
maxWaitMs: 6000,
maxBatchMessages: 24,
applyToStrangers: true,
strangerWindowMs: 2000,
},
patch: {},
conversations: {
"telegram|group:-20": {
enabled: false,
windowMs: 2000,
maxWaitMs: 6000,
maxBatchMessages: 24,
applyToStrangers: true,
strangerWindowMs: 2000,
},
},
rules: {
windowMs: [300, 15000],
maxWaitMs: [300, 30000],
maxBatchMessages: [1, 50],
clamp: "clamp",
flushReasons: ["quiet", "max_wait", "all_stale", "off", "stop"],
},
ownerPrivate: "never",
...overrides,
};
}
function render(view: CoalesceView, draftOverride?: Partial<ReturnType<typeof draftFromView>>): string {
const draft = { ...draftFromView(view), ...(draftOverride ?? {}) };
const check = parseCoalescePatch(view, draft);
return renderToStaticMarkup(
createElement(CoalesceCardBody, {
view,
draft,
errors: check.errors,
busy: false,
receipt: null,
savedView: null,
onDraftChange: () => undefined,
onSave: () => undefined,
})
);
}
function main(): void {
const view = fixtureView();
const html = render(view);
// ① 六个字段的中文标签 + 说明都在,且「主人私聊」只有说明、没有开关。
for (const field of COALESCE_FIELDS) {
ok(has(html, field.label), `field label missing: ${field.label}`);
ok(has(html, field.hint), `field hint missing for ${field.key}`);
ok(has(html, field.key), `field key not shown for ${field.key}`);
}
ok(has(html, OWNER_PRIVATE_NOTE), "owner-private read-only note missing");
ok(has(html, "面板没有开关"), "owner-private note must say the panel has no switch for it");
ok(has(html, COALESCE_HOT_LABEL), "hot label missing");
ok(has(html, "/data/config/coalesce.json"), "config path not shown");
ok(has(html, "telegram|group:-20"), "per-conversation override not listed");
ok(has(html, "300–15000ms"), "window rules not shown");
ok(has(html, "quiet"), "flush reasons not shown");
// ② 三态补丁:只有改过的键才写;没改 ⇒ 空补丁。
const unchanged = parseCoalescePatch(view, draftFromView(view));
ok(Object.keys(unchanged.patch.default).length === 0, "unchanged draft must produce an empty patch");
ok(unchanged.changed.length === 0, "unchanged draft must report no changed keys");
const changed = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "3000" });
ok(changed.patch.default.windowMs === 3000, "changed window must land in the patch");
ok(changed.changed.length === 1, `only the changed key may be written, got ${changed.changed.join(",")}`);
// ③ 坏值就地报错,且补丁里不含那个键(不会把一半好一半坏的东西发出去)。
const bad = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "10", maxBatchMessages: "abc" });
ok(bad.errors.windowMs !== undefined, "out-of-range window must be reported");
ok(bad.errors.maxBatchMessages !== undefined, "non-numeric batch must be reported");
ok(bad.patch.default.windowMs === undefined, "invalid window must not be sent");
ok(bad.patch.default.maxBatchMessages === undefined, "invalid batch must not be sent");
// 绝对上限必须 ≥ 窗口(下层规则由面板现算,不能只信后端)。
const waitTooSmall = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "5000", maxWaitMs: "3000" });
ok(waitTooSmall.errors.maxWaitMs !== undefined, "maxWait below the window must be rejected");
const badHtml = render(view, { windowMs: "10" });
ok(has(badHtml, "取值范围"), "invalid draft must render an inline error");
ok(has(badHtml, "有坏值,未提交"), "invalid draft must block the save button");
process.stdout.write(`coalesce card render: ${checks} checks passed\n`);
}
main();
@@ -0,0 +1,167 @@
// 记忆整理「调优」卡**纯逻辑**的测试(切片 MEMPANEL / M4)。
//
// 为什么需要它:这张卡最容易写错的地方不是样式,而是**三态**(键缺位 / 合法值 /
// 非法值)与「清空 ≠ 不改」这两条 —— 错一处就会写出「主人以为改了、盘上没动」或者
// 「一个坏值把整份配置写成 0」这类事故。这些判据全在 memoryTidyTuning.ts 的纯函数里,
// 因此不需要渲染就能钉住。
//
// 跑法(与同目录 *.render.test.ts 同一手法,本文件不依赖 React):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/memoryTidyTuning.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-memory-tidy-tuning.test.cjs \
// && node /tmp/cyrene-memory-tidy-tuning.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import type {
AdminMemoryTidyTuningSpec,
AdminMemoryTidyTuningView,
} from "../../lib/api/adminMemories";
import {
buildTuningPatch,
draftFromView,
fallbackLabel,
fieldError,
patchSummary,
secondsHint,
} from "./memoryTidyTuning";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function eq<T>(got: T, want: T, what: string): void {
checks += 1;
if (got !== want) throw new Error(`FAILED: ${what}:got=${JSON.stringify(got)} want=${JSON.stringify(want)}`);
}
function deepEq(got: unknown, want: unknown, what: string): void {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
checks += 1;
if (g !== w) throw new Error(`FAILED: ${what}:got=${g} want=${w}`);
}
/** spec 是后端 specs 的替身(取值规则来自后端,前端只消费)。 */
function spec(key: string, kind: string, extra: Partial<AdminMemoryTidyTuningSpec> = {}): AdminMemoryTidyTuningSpec {
return { key, kind, fallback: "code-default", ...extra } as AdminMemoryTidyTuningSpec;
}
/** view 造一份 GET 响应形状的 fixture。 */
function view(values: Record<string, number | boolean>, envValues?: Record<string, number>): AdminMemoryTidyTuningView {
return {
path: "config/memory-tidy.json",
present: true,
hot: false,
hotError: "运行期没有接线可热生效的实现",
specs: [
spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" }),
spec("quietIntervalSec", "int", { unit: "sec", min: 1, fallback: "panel:intervalSec" }),
spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" }),
spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" }),
spec("ingestGate", "bool", { fallback: "code-default" }),
spec("autoApply", "bool", { fallback: "code-default" }),
spec("tempTtlSec", "int", { unit: "sec", min: 1, fallback: "code-default" }),
],
values,
envValues,
};
}
// ① 草稿:键缺位 ⇒ 空串(= 跟随兜底),而不是 0 —— 「没配」与「配成 0」必须分得开。
{
const draft = draftFromView(view({ intervalSec: 10800, ingestGate: false }));
eq(draft.intervalSec, "10800", "草稿回填盘上值");
eq(draft.ingestGate, "false", "布尔键回填为字符串");
eq(draft.quietIntervalSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)");
eq(draft.tempTtlSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)");
}
// ② 三个态:没动过的键**不进补丁**(= 不改动);改过的进补丁;合法值按类型提交。
{
const base = view({ intervalSec: 86400, quietStartHour: 23, quietEndHour: 7, ingestGate: false });
const draft = draftFromView(base);
const untouched = buildTuningPatch(base, draft);
deepEq(untouched.patch, {}, "没动过任何键 ⇒ 空补丁(盘上逐字节不动)");
deepEq(untouched.errors, {}, "没动过任何键 ⇒ 无错误");
const changed = buildTuningPatch(base, { ...draft, intervalSec: "10800", ingestGate: "true" });
deepEq(changed.patch, { intervalSec: 10800, ingestGate: true }, "合法值 ⇒ 采用(且只含改动过的键)");
deepEq(changed.errors, {}, "合法值 ⇒ 无错误");
}
// ③ 清空哨兵:原本有值、现在清空 ⇒ `null`(删掉该键,回到下一档兜底);
// 原本就没值、现在还是空 ⇒ 什么都不发(不改动)。
{
const base = view({ intervalSec: 3600, tempTtlSec: 604800 });
const draft = draftFromView(base);
const cleared = buildTuningPatch(base, { ...draft, intervalSec: "" });
deepEq(cleared.patch, { intervalSec: null }, "清空一个有值的键 ⇒ null(回到兜底)");
const alreadyEmpty = view({ ingestGate: true });
const mix = view({ ingestGate: true });
const r2 = buildTuningPatch(mix, { ...draftFromView(alreadyEmpty), tempTtlSec: "" });
deepEq(r2.patch, {}, "原本就没值的键保持空 ⇒ 不改动(不是清除)");
}
// ④ 非法值:**就地报错且不进补丁**(界面据此拦下保存),后端的 400 仍是权威那道门。
{
const base = view({ intervalSec: 3600, quietStartHour: 23, quietEndHour: 7 });
const draft = draftFromView(base);
const cases: Array<[string, string, string]> = [
["intervalSec", "abc", "非整数"],
["intervalSec", "0", "低于下界(0 会让整理空转)"],
["intervalSec", "-60", "负数"],
["quietStartHour", "24", "钟点越上界"],
["quietEndHour", "-1", "钟点越下界"],
["tempTtlSec", "1.5", "小数不是整数"],
];
for (const [key, raw, why] of cases) {
const res = buildTuningPatch(base, { ...draft, [key]: raw });
ok(res.errors[key] !== undefined, `非法值必须就地报错(${why})`);
ok(res.patch[key] === undefined, `非法值绝不能进补丁(${why})`);
}
// 混着来:一个坏值不影响对合法键的校验,但界面整体不许保存(errors 非空)。
const mixed = buildTuningPatch(base, { ...draft, intervalSec: "0", quietStartHour: "22" });
ok(Object.keys(mixed.errors).length === 1, "只有一个坏值时只报一处");
eq(mixed.patch.quietStartHour, 22, "合法键仍照常进补丁(后端一次事务性写盘)");
// 布尔键只认三个取值。
eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), "yes").length > 0, true, "布尔键的坏值要报错");
eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), ""), "", "空 = 跟随兜底,合法");
}
// ⑤ 边界值合法(下界/上界本身可用,不是「越界」)。
{
eq(fieldError(spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "x" }), "1"), "", "下界本身合法");
eq(fieldError(spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "0"), "", "钟点 0 合法");
eq(fieldError(spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "23"), "", "钟点 23 合法");
}
// ⑥ 文案:兜底来源与单位要写成人话(面板的「留空 ⇒ 跟随谁」全靠它)。
{
eq(fallbackLabel(spec("intervalSec", "int", { fallback: "env:MEMORY_TIDY_INTERVAL_SEC" })),
"跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC", "env 兜底文案");
eq(fallbackLabel(spec("quietIntervalSec", "int", { fallback: "panel:intervalSec" })),
"跟随面板里的 intervalSec", "面板内兜底文案");
eq(fallbackLabel(spec("tempTtlSec", "int", { fallback: "code-default" })), "跟随代码默认", "代码默认文案");
eq(secondsHint(10800), "≈ 3 小时", "秒 → 小时提示");
eq(secondsHint(1800), "≈ 30 分钟", "秒 → 分钟提示");
eq(secondsHint(0), "", "非正数不提示");
eq(patchSummary({ intervalSec: 3600, ingestGate: false }), "同步入库闸(写入时当场审查)、平时整理间隔", "回执文案");
eq(patchSummary({}), "没有需要改的键", "空补丁回执");
}
// ⑦ 字符串数字不做隐式转换(" 12 " 这种带空白的要能通过,但 "12 3" 不行)。
{
const base = view({ intervalSec: 3600 });
const padded = buildTuningPatch(base, { ...draftFromView(base), intervalSec: " 7200 " });
deepEq(padded.patch, { intervalSec: 7200 }, "两侧空白应当被容忍");
const bad = buildTuningPatch(base, { ...draftFromView(base), intervalSec: "12 3" });
ok(bad.errors.intervalSec !== undefined, "内部空格不是合法整数");
}
console.log(`memoryTidyTuning.test.ts: ${checks} checks passed`);
@@ -0,0 +1,152 @@
import type {
AdminMemoryTidyTuningPatch,
AdminMemoryTidyTuningSpec,
AdminMemoryTidyTuningView,
} from "../../lib/api/adminMemories";
/* ----------------------------------------------------------------------------
* 记忆整理「面板调优」卡的**纯逻辑**(切片 MEMPANEL / M4):表单草稿 ↔ 三态补丁。
*
* 与呈现分开(与 memoryTidyApplyState.ts 同一手法):这一半不依赖 React,可以直接
* 用 node 跑断言(见 memoryTidyTuning.test.ts)。
*
* 三条不可走样的口径:
* 1. **取值规则来自后端的 specs**(类型/单位/上下界/兜底来源):面板不硬编码第二套
* 枚举或上下界 —— 否则就会出现「面板允许、后端 400」的漂移。本文件只负责把
* spec 翻成**草稿的校验规则**与中文文案。
* 2. **键缺位 ≠ 清空**:草稿里没动过的键**不进补丁**(= 后端不动它);把一个原本
* 有值的键清空 ⇒ 发 `null`(= 后端删掉该键,回到下一档兜底)。两者在盘上的结果
* 完全不同,因此这里必须分开。
* 3. **坏值就地报错**:非法草稿**不产生补丁**(界面据此拦下保存),而不是发出去等
* 后端 400 —— 后端那道门仍在(它是权威),这只是不让主人白跑一趟。
* -------------------------------------------------------------------------- */
/** `true` / `false` / 三个中文标签:调优键的中文名(面板文案)。 */
export const TUNING_LABELS: Record<string, string> = {
intervalSec: "平时整理间隔",
quietIntervalSec: "静默时段整理间隔",
quietStartHour: "静默时段开始(本地钟点)",
quietEndHour: "静默时段结束(本地钟点)",
ingestGate: "同步入库闸(写入时当场审查)",
autoApply: "轮末自动落地(高置信候选直接改记忆)",
tempTtlSec: "临时记忆存活时长",
};
/** 兜底来源的中文说明(后端 specs 的 `fallback` 只有三族)。 */
export function fallbackLabel(spec: AdminMemoryTidyTuningSpec): string {
if (spec.fallback.startsWith("env:")) {
return `跟随 .env 的 ${spec.fallback.slice("env:".length)}`;
}
if (spec.fallback.startsWith("panel:")) {
return `跟随面板里的 ${spec.fallback.slice("panel:".length)}`;
}
return "跟随代码默认";
}
/** 单位的中文说明。 */
export function unitLabel(spec: AdminMemoryTidyTuningSpec): string {
if (spec.unit === "sec") return "秒";
if (spec.unit === "hour") return "本地钟点(0-23)";
return "";
}
/** 秒数的可读提示(面板不改变单位,只帮主人把 10800 读成 3 小时)。 */
export function secondsHint(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return "";
if (seconds % 3600 === 0) return `≈ ${seconds / 3600} 小时`;
if (seconds % 60 === 0) return `≈ ${seconds / 60} 分钟`;
return `≈ ${seconds} 秒`;
}
/** 表单草稿:每个键一个字符串(bool 键用 "true"/"false",空串 = 跟随兜底/清空)。 */
export type TuningDraft = Record<string, string>;
/** 草稿的校验结果。 */
export interface TuningDraftCheck {
/** 键 → 就地错误文案(空对象 = 可以保存)。 */
errors: Record<string, string>;
/** 真正要提交的三态补丁(只含改动过的键)。 */
patch: AdminMemoryTidyTuningPatch;
}
/** draftFromView 把盘上的值铺成表单草稿(键缺位 ⇒ 空串 = 跟随兜底)。 */
export function draftFromView(view: AdminMemoryTidyTuningView): TuningDraft {
const draft: TuningDraft = {};
for (const spec of view.specs) {
const value = view.values[spec.key];
if (value === undefined || value === null) {
draft[spec.key] = "";
continue;
}
draft[spec.key] = typeof value === "boolean" ? (value ? "true" : "false") : String(value);
}
return draft;
}
/** fieldError 校验**单个**键的草稿值;返回空串表示合法(空 = 沿用兜底,也合法)。 */
export function fieldError(spec: AdminMemoryTidyTuningSpec, raw: string): string {
const text = raw.trim();
if (text === "") return "";
if (spec.kind === "bool") {
return text === "true" || text === "false" ? "" : `${TUNING_LABELS[spec.key] ?? spec.key} 只能选「开 / 关 / 跟随兜底」`;
}
if (!/^-?\d+$/.test(text)) {
return `${TUNING_LABELS[spec.key] ?? spec.key} 必须是整数(秒),收到「${text}」`;
}
const n = Number(text);
if (spec.min !== undefined && n < spec.min) {
return `${TUNING_LABELS[spec.key] ?? spec.key} 必须 ≥ ${spec.min}${unitLabel(spec)}`;
}
if (spec.max !== undefined && n > spec.max) {
return `${TUNING_LABELS[spec.key] ?? spec.key} 必须 ≤ ${spec.max}${unitLabel(spec)}`;
}
return "";
}
/** buildTuningPatch 是「草稿 → 三态补丁」的唯一实现点(口径 2、3)。 */
export function buildTuningPatch(
view: AdminMemoryTidyTuningView,
draft: TuningDraft
): TuningDraftCheck {
const errors: Record<string, string> = {};
const patch: AdminMemoryTidyTuningPatch = {};
for (const spec of view.specs) {
const raw = draft[spec.key] ?? "";
const err = fieldError(spec, raw);
if (err) {
errors[spec.key] = err;
continue;
}
const current = view.values[spec.key];
const text = raw.trim();
if (text === "") {
// 原本就没有值 ⇒ 一个字节都不必动(后端同样把「键缺位」当「不改动」)。
if (current === undefined || current === null) continue;
// 原本有值、现在清空 ⇒ **清除哨兵**(删掉该键,回到下一档兜底)。
patch[spec.key] = null;
continue;
}
if (spec.kind === "bool") {
const next = text === "true";
if (typeof current === "boolean" && current === next) continue;
patch[spec.key] = next;
continue;
}
const next = Number(text);
if (typeof current === "number" && current === next) continue;
patch[spec.key] = next;
}
return { errors, patch };
}
/** patchKeys 返回补丁里出现的键(供回执文案与日志/审计展示,顺序稳定)。 */
export function patchKeys(patch: AdminMemoryTidyTuningPatch): string[] {
return Object.keys(patch).sort();
}
/** patchSummary 把补丁说成人话(保存后回执用,例如「平时整理间隔、同步入库闸」)。 */
export function patchSummary(patch: AdminMemoryTidyTuningPatch): string {
const keys = patchKeys(patch);
if (keys.length === 0) return "没有需要改的键";
return keys.map((k) => TUNING_LABELS[k] ?? k).join("、");
}
@@ -5,10 +5,15 @@
// 界面无任何提示)。改法:界面**一个平台一条** + 该平台下「+ 添加账号 ID」;平台是权威 // 界面无任何提示)。改法:界面**一个平台一条** + 该平台下「+ 添加账号 ID」;平台是权威
// 枚举**下拉**;账号 ID 给出「从该平台已配置账号里选」的候选;**写盘时仍是扁平数组**。 // 枚举**下拉**;账号 ID 给出「从该平台已配置账号里选」的候选;**写盘时仍是扁平数组**。
// //
// 锁三件事: // 锁这些事:
// ① 分组/展平是纯函数且**展平后就是后端契约的扁平数组**(`owner.yaml` 格式不变); // ① 分组/草稿/写盘形态(纯函数):**草稿无损**(`draftAccounts` 逐行保留,空行/未选平台的组
// 都在 —— 编辑期的中间态不许蒸发),**写盘有损**(`accountsForSave` 只留平台与账号 ID 都
// 非空的条目:半成品条目会让后端 `Validate()` 直接 400,完全空的条目被跳过);
// ② 下拉选项 = 权威枚举 + 存量里出现过的平台(未列出的旧值不被下拉吃掉); // ② 下拉选项 = 权威枚举 + 存量里出现过的平台(未列出的旧值不被下拉吃掉);
// ③ 渲染:一个平台一条(两个平台 ⇒ 两块)、有「+ 添加账号 ID」、有候选可点。 // ③ 候选账号 = 该平台已配置的渠道账号 id;
// ④ 渲染:一个平台一条(两个平台 ⇒ 两块)、有「+ 添加账号 ID」、有候选可点;
// ⑤ **手势往返**(主人 2026-10-07 报的缺陷:卡片里「+ 添加账号 ID」似乎没用):点一下必须
// 真的多出一行可输入的账号行;「+ 添加平台」走同一条往返,同样钉住。
// //
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs): // 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
// //
@@ -21,12 +26,14 @@
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、 // `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
// 不 import node:*。 // 不 import node:*。
import { createElement } from "react"; import { createElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types"; import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types";
import { OwnerAccountsEditor } from "./OwnerAccountsEditor"; import { OwnerAccountsEditor, OwnerAccountsView } from "./OwnerAccountsEditor";
import { import {
accountCandidates, accountCandidates,
accountsForSave,
draftAccounts,
flattenAccounts, flattenAccounts,
groupAccounts, groupAccounts,
platformIsConfigured, platformIsConfigured,
@@ -56,7 +63,7 @@ const PUBLISHED: EnumItem[] = [
{ id: "webhook", label: "Webhook" }, { id: "webhook", label: "Webhook" },
]; ];
// ── ① 分组 / 展平(纯函数) ───────────────────────────────────────────────────── // ── ① 分组 / 草稿 / 写盘形态(纯函数) ──────────────────────────────────────────
{ {
const flat: OwnerAccount[] = [ const flat: OwnerAccount[] = [
{ platform: "qq-obv11", accountId: "9616b27a" }, { platform: "qq-obv11", accountId: "9616b27a" },
@@ -68,15 +75,71 @@ const PUBLISHED: EnumItem[] = [
same(groups[0], { platform: "qq-obv11", accountIds: ["9616b27a", "aaaa1111"] }, "同平台的账号收进一块"); same(groups[0], { platform: "qq-obv11", accountIds: ["9616b27a", "aaaa1111"] }, "同平台的账号收进一块");
same(groups[1], { platform: "web", accountIds: ["web-user"] }, "顺序 = 首次出现顺序"); same(groups[1], { platform: "web", accountIds: ["web-user"] }, "顺序 = 首次出现顺序");
// 展平回扁平数组。注意:分组天然把**同一平台的账号排到相邻位置** ⇒ 保存后文件的 // 草稿(编辑期形态,`draftAccounts`)**无损**:逐行原样回来 —— 空行、还没选平台的组都在。
// 行序会变成「按平台聚集」。白名单是一个**集合**(命中即 owner,与顺序无关),所以 // 这一条是主人 2026-10-07 那个缺陷的正面表述:中间态不许在往返里蒸发。
// 这里按集合断言;顺序变化在面板上一眼可见,不是隐藏改动。 same(
groupAccounts(draftAccounts(groups)),
groups,
"组 → 草稿 → 组:原样回来(无损往返)"
);
same(
draftAccounts([{ platform: "kook", accountIds: ["9616b27a", "", " "] }]),
[
{ platform: "kook", accountId: "9616b27a" },
{ platform: "kook", accountId: "" },
{ platform: "kook", accountId: " " },
],
"空行留在草稿里(不 trim、不丢行:编辑期不改写主人正在输入的字)"
);
same(
groupAccounts(draftAccounts([{ platform: "", accountIds: [""] }])),
[{ platform: "", accountIds: [""] }],
"「还没选平台」的空组也在草稿里(「+ 添加平台」靠它)"
);
// 写盘形态(`flattenAccounts` / `accountsForSave`):分组天然把**同一平台的账号排到相邻位置**
// ⇒ 保存后文件的条目序会变成「按平台聚集」。白名单是一个**集合**(命中即 owner,与顺序无关),
// 所以这里按集合断言;顺序变化在面板上一眼可见,不是隐藏改动。
const sorted = (list: OwnerAccount[]): OwnerAccount[] => const sorted = (list: OwnerAccount[]): OwnerAccount[] =>
[...list].sort((a, b) => `${a.platform}/${a.accountId}`.localeCompare(`${b.platform}/${b.accountId}`)); [...list].sort((a, b) => `${a.platform}/${a.accountId}`.localeCompare(`${b.platform}/${b.accountId}`));
same(sorted(flattenAccounts(groups)), sorted(flat), "分组 → 展平 回到同一组白名单条目"); same(sorted(flattenAccounts(groups)), sorted(flat), "分组 → 展平 回到同一组白名单条目");
const shape = Object.keys(flattenAccounts(groups)[0]).sort(); const shape = Object.keys(flattenAccounts(groups)[0]).sort();
same(shape, ["accountId", "platform"], "写盘条目只有 {platform, accountId}(owner.yaml 格式不变)"); same(shape, ["accountId", "platform"], "写盘条目只有 {platform, accountId}(owner.yaml 格式不变)");
// 保存路径(草稿 → 写盘形态):只写填完的条目。
// 草稿里允许有空行,写盘时收掉;半成品条目(缺平台或缺 id)**必须**在这里收掉,否则
// 后端 `Validate()`(internal/user/load.go)对这类条目直接 400,主人会看到「保存失败」。
same(
accountsForSave([
{ platform: "qq-obv11", accountId: "9616b27a" },
{ platform: "qq-obv11", accountId: "" },
{ platform: "kook", accountId: "" },
]),
[{ platform: "qq-obv11", accountId: "9616b27a" }],
"保存:只写平台与账号 ID 都非空的条目(空行不进 owner.yaml)"
);
same(
accountsForSave([
{ platform: "", accountId: "9616b27a" },
{ platform: "kook", accountId: "" },
{ platform: "", accountId: "" },
]),
[],
"保存:半成品条目一个都不写(写了会被后端 400)"
);
same(accountsForSave([]), [], "保存:空白名单写出空数组(不是 null)");
same(sorted(accountsForSave(flat)), sorted(flat), "保存:已生效的条目原样往返(不会被写没)");
same(
sorted(accountsForSave(flattenAccounts(groups))),
sorted(flat),
"保存:写盘形态再收一次仍是同一组(幂等)"
);
same(
Object.keys(accountsForSave([{ platform: "kook", accountId: "42" }])[0]).sort(),
["accountId", "platform"],
"保存:落盘条目仍只有 {platform, accountId}(owner.yaml 格式一个字不改)"
);
// 空值:平台或账号 id 任一为空都不写(写了也匹配不上 actor 元数据,是噪声)。 // 空值:平台或账号 id 任一为空都不写(写了也匹配不上 actor 元数据,是噪声)。
same( same(
flattenAccounts([ flattenAccounts([
@@ -171,4 +234,247 @@ const PUBLISHED: EnumItem[] = [
ok(other.includes('disabled=""'), "只读角色下控件被禁用(不假装能改)"); ok(other.includes('disabled=""'), "只读角色下控件被禁用(不假装能改)");
} }
// ── ⑤ 手势 → 草稿 → 渲染:任何界面手势产生的中间态都不能在往返中蒸发 ─────────────
//
// 这一节钉的是主人报的那个缺陷:「配置指定适配器主人账号的卡片内的『添加账号ID』按钮似乎没用」。
// 它只发生在「手势 → 草稿 → 再投影回界面」这条往返上:草稿若是**有损**的扁平形态(展平时把空行
// 丢掉),刚点出来的空行当场蒸发、界面照旧 —— 点了等于没点。注意纯函数往返测试
// (`展平(分组(flat)) == flat`)**测不到**它:那个等式把「空行被丢掉」当成恒等,等于把有损性
// 当成了正确性。
//
// 本项目没有 jsdom(见 flowTimelineWiring.render.test.ts 的说明)⇒ 这里直接调用**无 hook** 的
// `OwnerAccountsView` 拿到真的元素树,**点真的那个按钮**(调它自己的 `onClick`),把 `onChange`
// 收到的**真草稿**再喂回去重渲染 —— 走的正是 `Users.tsx` 的接线(草稿是唯一状态,界面是它的投影)。
/** 元素树里的一个宿主节点(`button`/`input`/`select`/`div`…)。 */
interface HostEl {
type: string;
props: Record<string, unknown>;
}
/** 节点里的可见文字(按钮标签、提示语靠它找)。 */
function textOf(node: ReactNode): string {
if (node === null || node === undefined || typeof node === "boolean") return "";
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(textOf).join("");
return textOf((node as { props?: { children?: ReactNode } }).props?.children);
}
/** 走一遍元素树,收集宿主节点。函数组件会被**直接调用**(`Button` / `PlatformGroupRow` /
* `AccountIdRow` 都是无 hook 的纯呈现)—— 若往视图里塞 hook,这里会以 `Invalid hook call`
* 报错,那是提醒你把手势逻辑留在无 hook 的那一层。 */
function hostEls(node: ReactNode): HostEl[] {
const out: HostEl[] = [];
const walk = (n: ReactNode): void => {
if (n === null || n === undefined || typeof n === "boolean") return;
if (typeof n === "string" || typeof n === "number") return;
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
const el = n as { type?: unknown; props?: Record<string, unknown> };
const props = el.props ?? {};
if (typeof el.type === "function") {
walk((el.type as (p: Record<string, unknown>) => ReactNode)(props));
return;
}
if (typeof el.type === "string") out.push({ type: el.type, props });
walk(props.children as ReactNode);
};
walk(node);
return out;
}
/** 必须存在的东西(不存在就报错,而不是把 undefined 一路带下去)。 */
function must<T>(found: T | undefined, what: string): T {
checks += 1;
if (found === undefined) throw new Error(`FAILED: ${what}`);
return found;
}
/** 界面的替身:草稿存在这里,每次操作都从草稿重新投影(= `Users.tsx` 那条接线)。 */
class Harness {
draft: OwnerAccount[];
constructor(accounts: OwnerAccount[]) {
this.draft = accounts;
}
/** 当前这一步的元素树(宿主节点)。 */
tree(): HostEl[] {
const groups = groupAccounts(this.draft);
return hostEls(
createElement(OwnerAccountsView, {
groups,
options: platformOptions(PUBLISHED, groups.map((g) => g.platform)),
channels: null,
disabled: false,
onChange: (next: OwnerAccount[]) => {
this.draft = next;
},
})
);
}
/** 界面上能看见的字(「提示语说了什么」这类断言靠它)。 */
text(): string {
return this.tree()
.map((e) => textOf(e.props.children as ReactNode))
.join(" ");
}
/** 点标签为 `label` 的第 `n` 个按钮(找不到就报错,不静默放过)。 */
click(label: string, n = 0): void {
const hits = this.tree().filter(
(e) => e.type === "button" && textOf(e.props.children as ReactNode) === label
);
const hit = must(hits[n], `界面上第 ${n + 1} 个「${label}」按钮(共找到 ${hits.length} 个)`);
(hit.props.onClick as () => void)();
}
/** 账号 ID 行数(每行带定位标记)。 */
rows(): number {
return this.tree().filter((e) => e.type === "div" && e.props["data-account-row"] !== undefined)
.length;
}
selects(): number {
return this.tree().filter((e) => e.type === "select").length;
}
inputs(): HostEl[] {
return this.tree().filter((e) => e.type === "input" && e.props.value !== undefined);
}
/** 在第 `n` 行输入框里打字(走它自己的 onChange)。 */
type(n: number, value: string): void {
const el = must(this.inputs()[n], `第 ${n + 1} 个账号 ID 输入框`);
(el.props.onChange as (e: unknown) => void)({ target: { value } });
}
/** 在第 `n` 块里选平台(走它自己的 onChange)。 */
pickPlatform(n: number, platform: string): void {
const el = must(this.tree().filter((e) => e.type === "select")[n], `第 ${n + 1} 块平台下拉`);
(el.props.onChange as (e: unknown) => void)({ target: { value: platform } });
}
}
{
// 起点 = 主人现在的真实样子:qq-obv11 下已有一个账号,他想再加一个。
const ui = new Harness([{ platform: "qq-obv11", accountId: "9616b27a" }]);
same(ui.rows(), 1, "起点:该平台一行账号 ID");
same(ui.inputs().length, 1, "起点:一个账号 ID 输入框");
ui.click("+ 添加账号 ID");
same(ui.rows(), 2, "点「+ 添加账号 ID」⇒ 界面上真的多出一行(空行没在往返里蒸发)");
same(
ui.draft,
[
{ platform: "qq-obv11", accountId: "9616b27a" },
{ platform: "qq-obv11", accountId: "" },
],
"新行真的进了草稿(空的、挂在同一平台下)—— 不是「点了没点」"
);
same(
must(ui.inputs()[1], "新行输入框").props.value,
"",
"新行是一个可输入的空输入框(接着就能打字)"
);
// 在新行里打字 ⇒ 值进草稿、界面仍是两行:这条往返得是**可用**的,不是只能加不能填。
ui.type(1, "aaaa1111");
same(
ui.draft,
[
{ platform: "qq-obv11", accountId: "9616b27a" },
{ platform: "qq-obv11", accountId: "aaaa1111" },
],
"在新行里打的字落进草稿"
);
same(ui.rows(), 2, "打字后仍是两行(已填的值没在往返里丢)");
ok(!ui.text().includes("有 1 条空白账号 ID"), "两行都填了就不再报空白(提示不自相矛盾)");
// 空行的既有提示语义保留:加了行还没填时,界面明说「保存时会被忽略」,不是静默丢弃。
const hinted = new Harness([{ platform: "qq-obv11", accountId: "9616b27a" }]);
hinted.click("+ 添加账号 ID");
ok(
hinted.text().includes("有 1 条空白账号 ID:保存时会被忽略"),
"新加的空行有显式提示(保存时会被忽略)"
);
}
{
// 「+ 添加平台」走的是同一条往返(任务 3):点一下必须真的多出一块、块里带一行可填。
const blank = new Harness([]);
same(blank.rows(), 0, "起点:一个平台都没有");
blank.click("+ 添加平台");
same(
blank.draft,
[{ platform: "", accountId: "" }],
"点「+ 添加平台」⇒ 草稿里真的多出一个未选平台的空组(不是「点了没点」)"
);
same(blank.selects(), 1, "界面上真的多出一块平台(下拉)");
same(blank.rows(), 1, "这块里带一行可填的账号 ID");
blank.pickPlatform(0, "kook");
same(blank.draft, [{ platform: "kook", accountId: "" }], "在块里选平台会落到草稿(块没在往返里蒸发)");
blank.click("+ 添加账号 ID");
same(
blank.draft,
[
{ platform: "kook", accountId: "" },
{ platform: "kook", accountId: "" },
],
"选完平台再加一行:两行都挂在 kook 下"
);
same(blank.rows(), 2, "界面上两行");
// 已知边界(**扁平草稿的固有性质**,不是本次缺陷):第二块「未选平台」的组会和第一块并成一块
// —— `groupAccounts` 按平台聚合,两个空平台同键。行不会丢,只是同块(有下拉可接着选平台)。
const two = new Harness([]);
two.click("+ 添加平台");
two.click("+ 添加平台");
same(two.rows(), 2, "连点两次「+ 添加平台」:两行都在(空平台两块并成一块)");
same(two.selects(), 1, "空平台两块并成一块下拉");
}
{
// 「删除某平台唯一一行」= 撤掉该平台(平台选择随之消失)。这是**修前就有**的语义,本次不动,
// 但在这里显式钉住:日后若被当成新缺陷、或被顺手改掉,这条会先红。
const one = new Harness([{ platform: "kook", accountId: "42" }]);
one.click("删除");
same(one.draft, [], "删掉唯一一行 ⇒ 该平台块整体消失(既有语义)");
same(one.rows(), 0, "界面上也不再有这一块");
same(one.selects(), 0, "平台下拉也一并撤掉");
}
// ⑤ 收尾:把「手势刚产生的草稿」接到「写盘形态」上。草稿留着空行(上面钉住了),那保存时
// 就必须有人把它收干净 —— 后端 `Validate()`(internal/user/load.go)对「平台空 / 账号 id 空」
// 这种半成品条目**直接 400**(主人看到「保存失败」),只有完全空的条目才是跳过。
{
const half = new Harness([]);
half.click("+ 添加平台");
half.type(0, "9616b27a"); // 只填了 id,平台还没来得及选
same(half.draft, [{ platform: "", accountId: "9616b27a" }], "草稿里这一行留着(编辑期不丢行)");
same(accountsForSave(half.draft), [], "写盘时收掉半成品条目(不发给后端 ⇒ 不会 400)");
const filled = new Harness([{ platform: "qq-obv11", accountId: "9616b27a" }]);
filled.click("+ 添加账号 ID");
same(
accountsForSave(filled.draft),
[{ platform: "qq-obv11", accountId: "9616b27a" }],
"写盘时收掉空行:owner.yaml 只拿到填完的条目"
);
filled.type(1, " aaaa1111 "); // 手滑多打了空格
same(filled.draft[1].accountId, " aaaa1111 ", "编辑期不改写正在输入的字(逐键 trim 会吃掉空格)");
same(
accountsForSave(filled.draft),
[
{ platform: "qq-obv11", accountId: "9616b27a" },
{ platform: "qq-obv11", accountId: "aaaa1111" },
],
"写盘时去掉两侧空白(带空格的键永远匹配不上 actor 元数据)"
);
}
console.log(`owner accounts editor test: ${checks} checks passed`); console.log(`owner accounts editor test: ${checks} checks passed`);
@@ -12,7 +12,13 @@ import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types
* (`owner.IsOwner` 匹配不上 actor 元数据 ⇒ 主人被当陌生人,界面上没有任何提示)。 * (`owner.IsOwner` 匹配不上 actor 元数据 ⇒ 主人被当陌生人,界面上没有任何提示)。
* 平台改为权威枚举下拉 + 账号 id 可从该平台已配置账号里选,消灭这类静默失效。 * 平台改为权威枚举下拉 + 账号 id 可从该平台已配置账号里选,消灭这类静默失效。
* *
* 本模块是纯函数(无 DOM、无请求),因此分组/展平/下拉选项/候选都能在 node 下逐条测。 * 本模块是纯函数(无 DOM、无请求),因此分组/草稿/写盘形态/下拉选项/候选都能在 node 下逐条测。
*
* **两种扁平形态,别混用**(这是主人 2026-10-07 报的「添加账号 ID 按钮似乎没用」的根因所在):
* ① **草稿**(`draftAccounts`,编辑期):逐行原样保留,空行/未选平台的组都在 ——
* 界面手势产生的中间态**绝不能在往返中蒸发**;
* ② **写盘形态**(`accountsForSave`,保存期):只留平台与账号 id 都非空的条目。
* 把①错当②用(编辑期就走写盘形态),空行当场消失 ⇒ 点了等于没点,界面上还看不出原因。
*/ */
/** 一个平台分组:界面上的一条。 */ /** 一个平台分组:界面上的一条。 */
@@ -22,7 +28,7 @@ export interface OwnerAccountGroup {
} }
/** 把扁平数组按平台分组(保持首次出现顺序,同一平台的多条账号收进一组)。 */ /** 把扁平数组按平台分组(保持首次出现顺序,同一平台的多条账号收进一组)。 */
export function groupAccounts(accounts: OwnerAccount[]): OwnerAccountGroup[] { export function groupAccounts(accounts: readonly OwnerAccount[]): OwnerAccountGroup[] {
const groups: OwnerAccountGroup[] = []; const groups: OwnerAccountGroup[] = [];
const byPlatform = new Map<string, OwnerAccountGroup>(); const byPlatform = new Map<string, OwnerAccountGroup>();
for (const a of accounts) { for (const a of accounts) {
@@ -38,10 +44,27 @@ export function groupAccounts(accounts: OwnerAccount[]): OwnerAccountGroup[] {
return groups; return groups;
} }
/** 展平成后端契约要的扁平数组。**平台与账号 id 都非空才写**:任一为空都匹配不上任何 /** 组 → **草稿**(编辑期形态):逐行原样保留 —— 空行、以及「还没选平台」的组都在。
* actor 元数据(写了也是噪声,还会让主人误以为已配置)。空的账号行/空平台组被忽略, *
* 界面上有对应提示,不是静默丢弃。 */ * 为什么必须保行:草稿是**界面手势的载体** —— 每次改动都走「草稿 → 分组 → 改 → 回草稿」,
export function flattenAccounts(groups: OwnerAccountGroup[]): OwnerAccount[] { * 写回若有损(把空行丢掉),刚点出来的「+ 添加账号 ID」空行当场蒸发 ⇒ 点了等于没点
* (主人 2026-10-07 报的正是这个)。空行只在**保存时**才丢(见 `accountsForSave`),
* 界面上另有「有 N 条空白账号 ID:保存时会被忽略」的显式提示,不是静默丢弃。
*
* 另外:编辑期**不改写**主人正在输入的字(不 trim)。逐键 trim 会把还在输入的空白吃掉;
* 规范化(去空白 + 丢空行)留到写盘那一步做。 */
export function draftAccounts(groups: readonly OwnerAccountGroup[]): OwnerAccount[] {
const out: OwnerAccount[] = [];
for (const g of groups) {
for (const accountId of g.accountIds) out.push({ platform: g.platform, accountId });
}
return out;
}
/** 展平成后端契约要的扁平数组(**写盘形态**)。**平台与账号 id 都非空才写**:任一为空都
* 匹配不上任何 actor 元数据(写了也是噪声,还会让主人误以为已配置)。空的账号行/空平台组
* 被忽略,界面上有对应提示,不是静默丢弃。 */
export function flattenAccounts(groups: readonly OwnerAccountGroup[]): OwnerAccount[] {
const out: OwnerAccount[] = []; const out: OwnerAccount[] = [];
for (const g of groups) { for (const g of groups) {
const platform = g.platform.trim(); const platform = g.platform.trim();
@@ -54,6 +77,16 @@ export function flattenAccounts(groups: OwnerAccountGroup[]): OwnerAccount[] {
return out; return out;
} }
/** 草稿 → 写盘形态(`PUT /api/v1/admin/owner-profile` 的 `accounts`,即
* `configs/users/owner.yaml` 的条目)。**保存路径只走它**,别自己拼这两步。
*
* 编辑期草稿里允许有空行,但后端 `Validate()` 对「平台空、账号 id 非空」这种半成品条目
* 直接 400(`internal/user/load.go`),完全空的条目则被跳过 —— 所以这一步是必须的,不是洁癖。
* 去掉两侧空白同理:带空格的键永远匹配不上 actor 元数据。 */
export function accountsForSave(accounts: readonly OwnerAccount[]): OwnerAccount[] {
return flattenAccounts(groupAccounts(accounts));
}
/** 下拉选项:后端权威枚举(`GET /api/v1/meta/enums` 的 `platforms`)在前,随后补上 /** 下拉选项:后端权威枚举(`GET /api/v1/meta/enums` 的 `platforms`)在前,随后补上
* 存量里出现过的平台 —— 未列出的旧值**原样保留**(display-raw:绝不下拉吃掉它, * 存量里出现过的平台 —— 未列出的旧值**原样保留**(display-raw:绝不下拉吃掉它,
* 否则一次保存就把主人已经生效的配置删了)。 */ * 否则一次保存就把主人已经生效的配置删了)。 */
@@ -0,0 +1,111 @@
// 「插件与能力」页按上游 server 分组的**纯逻辑**测试(DevDocs/audit/MCP工具按上游分组-2026-10-24.md)。
//
// 锁四件事:
// ① **归属只看 origin,不看名字**:MCP 工具名里没有任何前缀(`mcp:` 是 KindMCP
// 条目的注册键),所以判据必须是后端盖的章 —— 本测试给工具起一个带 `mcp:` 字样的
// 名字但**无 origin**,它必须落进「宿主内置」;
// ② **transport 不编**:后端没报告 transport 时标题省略括号,而不是假设 stdio;
// ③ **空 server 不冒充内置**:报告了 mcp 来源却没说哪台 server 的工具,仍归 MCP 段
// (标题写「未知 MCP server」),不能被并进「宿主内置」—— 那是不同的结论;
// ④ **不得静默丢弃**:`kind:"mcp"` 登记条目与未知 kind(如 iot)都有专门的取值口。
//
// 项目没有前端测试框架,所以这是一个零依赖的 plain-node 检查(同目录既有手法):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/pluginOrigin.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-plugin-origin.test.cjs \
// && node /tmp/cyrene-plugin-origin.test.cjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
// 不 import node:*。
import type { AdminPluginRecord } from "../../lib/types";
import {
mcpServerEntries,
originGroupTitle,
otherCapabilityEntries,
toolOriginBuckets,
} from "./pluginOrigin";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same(got: unknown, want: unknown, what: string): void {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
ok(g === w, `${what} — got ${g}, want ${w}`);
}
/** 造一条能力记录(origin 只在给定时出现,模拟后端 omitempty)。 */
function rec(
name: string,
kind: string,
origin?: { kind: string; server?: string; transport?: string },
): AdminPluginRecord {
const base: AdminPluginRecord = { name, kind, description: "", allowInGroup: false, allowInStranger: false };
return origin ? { ...base, origin } : base;
}
// ① 两个 server 各两把工具 + 两把内置工具(其中一把名字里带 mcp: 字样的假前缀)。
const rows: AdminPluginRecord[] = [
rec("read_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }),
rec("write_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }),
rec("search", "tool", { kind: "mcp", server: "github", transport: "http" }),
rec("mcp:read_file", "tool"), // 名字像 MCP,但没有来源章 ⇒ 内置
rec("web_search", "tool"),
];
const buckets = toolOriginBuckets(rows);
// ① 归属只看 origin:两个 MCP 分节(按 server 名排序),其余归内置。
same(
buckets.mcp.map((g) => [g.server, g.transport, g.rows.map((r) => r.name)]),
[
["fs", "stdio", ["read_file", "write_file"]],
["github", "http", ["search"]],
],
"MCP 工具按 origin.server 分节(按 server 名排序)",
);
same(
buckets.builtin.map((r) => r.name),
["mcp:read_file", "web_search"],
"无 origin 的归宿主内置 —— 名字里带 mcp: 不算来源",
);
// ② 标题:transport 有则带括号,无则省略(不编 stdio);计数是「把工具」。
same(originGroupTitle(buckets.mcp[0]), "来自 fs(stdio)· 2 把工具", "带 transport 的组标题");
same(
originGroupTitle({ key: "mcp:x", server: "x", transport: "", rows: [rec("a", "tool")] }),
"来自 x · 1 把工具",
"未报告 transport 时省略括号,不编默认值",
);
// ③ 报告了 mcp 却没说哪台 server:仍属 MCP 段,不得冒充内置。
const anonymous = toolOriginBuckets([rec("orphan_tool", "tool", { kind: "mcp" }), rec("web_search", "tool")]);
same(anonymous.mcp.length, 1, "缺 server 名的 MCP 工具仍是 MCP 段的一员");
same(anonymous.mcp[0].server, "", "缺 server 名时 server 留空,不编一个名字");
same(originGroupTitle(anonymous.mcp[0]), "来自 未知 MCP server · 1 把工具", "缺 server 名的标题如实写未知");
same(anonymous.builtin.map((r) => r.name), ["web_search"], "内置桶只装真的没有 mcp 来源的工具");
// ④ 不得静默丢弃:KindMCP 登记条目与未知 kind 各有取值口。
const mixed: AdminPluginRecord[] = [
rec("read_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }),
rec("mcp:fs", "mcp"),
rec("kb_search", "skill"),
rec("lamp", "iot"),
rec("kook", "channel"),
];
same(mcpServerEntries(mixed).map((r) => r.name), ["mcp:fs"], "kind:mcp 登记条目要能被页面拿到");
same(otherCapabilityEntries(mixed).map((r) => r.name), ["lamp"], "未知 kind(iot)不得被静默过滤");
same(toolOriginBuckets(mixed).builtin.length, 0, "只有工具行进工具桶(技能/渠道/登记条目都不进)");
// 空输入不炸,且三个出口都是空数组而不是 undefined。
const empty = toolOriginBuckets([]);
same([empty.mcp.length, empty.builtin.length], [0, 0], "空清单得到空结果");
console.log(`pluginOrigin: ${checks} checks passed`);
@@ -0,0 +1,93 @@
// 「插件与能力」页的**来源分组**逻辑(纯函数、零 React 依赖)。
//
// 主人 2026-10-24 的要求:「能否将每个 MCP 服务器所承载的插件和工具按上游(来源)服务器
// 分类并与其他插件相对隔离?」结论(DevDocs/audit/MCP工具按上游分组-2026-10-24.md):
// 归属**只能来自注册时的元数据**(`origin`),不能解析工具名 —— MCP 工具名由 server
// 自己给,宿主不加任何前缀(`mcp:` 是 KindMCP 条目的注册键,不是工具的命名空间)。
//
// 因此本模块只做三件事:
// ① 把 `kind:"tool"` 的行按 `origin.server` 切成分节,无 MCP 来源的归「宿主内置」;
// ② 生成分节标题(`来自 fs(stdio)· 12 把工具`)——transport 未报告时省略括号,
// 绝不编一个默认值;
// ③ 把不再是「工具/技能/渠道」行的事实摆出来(`mcp` 登记条目、其它未知 kind),
// 供页面**如实显示**而不是静默丢弃(design/09 §5.2.1 红线③)。
//
// 这里判的是「呈现归属」,不涉及权限:工具能不能被调用仍只由 tool_policies 的逐工具
// 布尔 + actor 元数据决定,分组既不收窄也不放宽任何权限。
import type { AdminPluginRecord } from "../../lib/types";
/** 一个上游 MCP server 承载的工具分节。 */
export interface ToolOriginGroup {
/** React key:同一 server 的所有工具共用一个。 */
key: string;
/** server 名(plugins.json / MCP_SERVERS_JSON 的 name);空串 = 报告了 MCP 却没说哪台。 */
server: string;
/** stdio / sse / http;空串 = 后端未报告。 */
transport: string;
rows: AdminPluginRecord[];
}
/** 工具段的两级分节结果。 */
export interface ToolOriginBuckets {
/** 按上游 server 分节(按 server 名排序);只含来自 MCP 的工具。 */
mcp: ToolOriginGroup[];
/** 没有 MCP 来源的工具:宿主内置 / 来源未知。 */
builtin: AdminPluginRecord[];
}
/** 页面自己成段渲染的 kind;其余 kind 必须落到「其它能力」段,不得静默丢弃。 */
export const RENDERED_KINDS = ["tool", "skill", "channel", "mcp"] as const;
/** record 是否来自一台 MCP server(判据是后端盖的来源章,不是名字)。 */
export function isMCPOrigin(rec: AdminPluginRecord): boolean {
return rec.origin?.kind === "mcp";
}
/** 把工具行按上游 server 分节;无 MCP 来源的归「宿主内置」桶。
*
* 只收 `kind === "tool"` 的行:技能/渠道/MCP 登记条目由页面自己成段渲染(见
* `RENDERED_KINDS` 与另两个取值口),它们**不是**工具、也不该被算进任何一个工具分节。
* 传整份清单进来是安全的 —— 忽略 ≠ 丢弃,其余 kind 在页面上各有位置。 */
export function toolOriginBuckets(rows: AdminPluginRecord[]): ToolOriginBuckets {
const groups = new Map<string, ToolOriginGroup>();
const builtin: AdminPluginRecord[] = [];
for (const rec of rows) {
if (rec.kind !== "tool") continue;
if (!isMCPOrigin(rec)) {
builtin.push(rec);
continue;
}
const server = (rec.origin?.server ?? "").trim();
const transport = (rec.origin?.transport ?? "").trim();
const key = `mcp:${server}`;
const group = groups.get(key);
if (group) {
group.rows.push(rec);
// 同一 server 的传输形态应当一致;万一不一致,保留先到的那个而不是拼接,
// 免得标题读成「stdio/sse」(拼接会让人以为一台 server 同时两种传输)。
continue;
}
groups.set(key, { key, server, transport, rows: [rec] });
}
const mcp = [...groups.values()].sort((a, b) => (a.server < b.server ? -1 : a.server > b.server ? 1 : 0));
builtin.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
return { mcp, builtin };
}
/** 分节标题:`来自 fs(stdio)· 12 把工具`;transport / server 缺失时如实省略。 */
export function originGroupTitle(group: ToolOriginGroup): string {
const count = `${group.rows.length} 把工具`;
const server = group.server || "未知 MCP server";
return group.transport ? `来自 ${server}(${group.transport})· ${count}` : `来自 ${server} · ${count}`;
}
/** `kind:"mcp"` 的登记条目(每台 server 一条,名字是 `mcp:<server>`)。 */
export function mcpServerEntries(rows: AdminPluginRecord[]): AdminPluginRecord[] {
return rows.filter((rec) => rec.kind === "mcp");
}
/** 既不是工具/技能/渠道、也不是 MCP 登记条目的能力(如 iot):必须如实显示。 */
export function otherCapabilityEntries(rows: AdminPluginRecord[]): AdminPluginRecord[] {
return rows.filter((rec) => !(RENDERED_KINDS as readonly string[]).includes(rec.kind));
}
+9
View File
@@ -155,3 +155,12 @@ export {
adminTidyCandidatesApplyBatch, adminTidyCandidatesApplyBatch,
adminTidyRun, adminTidyRun,
} from "./api/adminMemoryTidyApply"; } from "./api/adminMemoryTidyApply";
export {
adminMemoryTidyTuning,
adminMemoryTidyTuningSave,
} from "./api/adminMemories";
export type {
AdminMemoryTidyTuningPatch,
AdminMemoryTidyTuningSpec,
AdminMemoryTidyTuningView,
} from "./api/adminMemories";
+75
View File
@@ -0,0 +1,75 @@
import { request } from "./core";
/* ----------------------------------------------------------------------------
* 记忆整理**面板调优**(切片 MEMPANEL / M4)。真源:
* internal/services/gateway/admin_memory_tuning.go(形状与错误码)
* internal/services/gateway/admin_memory_tuning_file.go(取值规则 specs)
*
* GET /admin/memories/tidy/tuning 读:盘上 7 键值 + 取值规则
* PUT /admin/memories/tidy/tuning 写:三态补丁(键缺位 = 不改动 / 合法值 = 采用 /
* 非法值 = 400 且盘上不动;`null` = 清掉该键)
*
* 两条都是 writeAuth(owner|admin)——与同族的 `/admin/memories` 同档:它是私有记忆面
* 的运行期旋钮,不是给 viewer 的监控投影。
*
* ⚠️ 失败走 `error.code`,**绝不**回一份「已保存」的假成功:
* - `invalid_payload` 400:值非法 / 未知键(**盘上一个字节都没动**)
* - `tuning_corrupt` 409:盘上那份配置文件读不走 ⇒ 拒绝覆盖(要人工修/删)
* - `tuning_unavailable` 500:数据目录未接线
* - `tuning_write_failed` 500:落盘失败(盘上内容未改动)
*
* 响应里的 `hot` 是**如实**的:true = 保存后当场生效;false = 已落盘,但运行期没接到
* 可热生效的实现(`hotError` 说明原因),此时界面必须写「重启后端后生效」。
* -------------------------------------------------------------------------- */
/** 调的落点(面板配置文件的**相对**路径,形如 `config/memory-tidy.json`)。 */
const TUNING_PATH = "/admin/memories/tidy/tuning";
/** 一个可写键的取值规则(后端下发,面板不另抄一份枚举/上下界)。 */
export interface AdminMemoryTidyTuningSpec {
/** 键名,如 `intervalSec` / `ingestGate`。 */
key: string;
/** `int` | `bool`(面板据此选控件类型)。 */
kind: string;
/** `sec` | `hour`(仅 int 键;面板据此写单位)。 */
unit?: string;
min?: number;
max?: number;
/** 缺位时生效值从哪来:`env:MEMORY_TIDY_*` / `panel:<key>` / `code-default`。 */
fallback: string;
}
/** GET / PUT 的 data(PUT 的响应是**重新读盘**的结果,即真值回显)。 */
export interface AdminMemoryTidyTuningView {
/** 面板配置文件相对 `$DATA_DIR` 的路径。 */
path: string;
/** 文件是否已存在且有内容(false = 还没播种,后端下次启动会自动创建)。 */
present: boolean;
/** 本次结果是否**当场生效**。false = 已落盘,要等后端重启(见 hotError)。 */
hot: boolean;
/** `hot:false` 时的原因。 */
hotError?: string;
specs: AdminMemoryTidyTuningSpec[];
/** **盘上**的值:键缺位 ⇒ 该键不出现(= 跟随兜底),不是 0。 */
values: Record<string, number | boolean>;
/** 缺位时**下一档(`.env`)**的进程生效值(与 /admin/config-facts 同源)。 */
envValues?: Record<string, number>;
}
/** 写补丁:只放**真的要改**的键;`null` = 清掉该键(回到下一档兜底)。 */
export type AdminMemoryTidyTuningPatch = Record<string, number | boolean | null>;
/** GET /api/v1/admin/memories/tidy/tuning —— 读盘上的调优值 + 取值规则。 */
export function adminMemoryTidyTuning(): Promise<AdminMemoryTidyTuningView> {
return request<AdminMemoryTidyTuningView>(TUNING_PATH);
}
/** PUT /api/v1/admin/memories/tidy/tuning —— 三态补丁写盘(并尽量热生效)。 */
export function adminMemoryTidyTuningSave(
patch: AdminMemoryTidyTuningPatch
): Promise<AdminMemoryTidyTuningView> {
return request<AdminMemoryTidyTuningView>(TUNING_PATH, {
method: "PUT",
body: JSON.stringify(patch),
});
}
+20
View File
@@ -8,6 +8,17 @@ export interface MemoryRecord {
text: string; text: string;
} }
/** 一条能力的上游来源(GET /api/v1/admin/plugins 的可选 `origin` 键,契约 v0.83)。
*
* 它回答「这条能力来自哪台上游 server」,来自**注册时的元数据**——绝不是从
* `name` 里解析前缀得来的(MCP 工具名里没有任何前缀)。宿主内置能力的该键
* **整个缺席**(不是 `{}`),前端据此把它们归「宿主内置」。 */
export interface CapabilityOrigin {
kind: string;
server?: string;
transport?: string;
}
/** A capability/plugin as returned by GET /api/v1/admin/plugins. */ /** A capability/plugin as returned by GET /api/v1/admin/plugins. */
export interface AdminPluginRecord { export interface AdminPluginRecord {
name: string; name: string;
@@ -15,6 +26,8 @@ export interface AdminPluginRecord {
description: string; description: string;
allowInGroup: boolean; allowInGroup: boolean;
allowInStranger: boolean; allowInStranger: boolean;
/** 上游来源;缺席 = 宿主内置 / 来源未知(老后端同样缺席)。 */
origin?: CapabilityOrigin;
} }
/** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28). /** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28).
@@ -357,6 +370,13 @@ export interface OwnerProfile {
defaultPersona: string; defaultPersona: string;
identityFile: string; identityFile: string;
notes: string; notes: string;
/**
* Channel account whitelist. While the panel is being edited this array is a **draft**: a row may
* legitimately be blank (`accountId: ""`, or no platform picked yet) because every UI gesture
* round-trips through it — dropping blank rows here makes a freshly added row disappear, i.e. the
* button looks dead. Only the save path narrows it (`accountsForSave`), so owner.yaml and the PUT
* contract keep carrying entries with both `platform` and `accountId` set.
*/
accounts: OwnerAccount[]; accounts: OwnerAccount[];
} }
export interface OwnerAccount { export interface OwnerAccount {
+12
View File
@@ -119,6 +119,18 @@ func portListening(ctx context.Context, port int) bool {
// 侧看到的工具结果(`browserError`)来自这条错误 —— 不上报原话,用户就只看到我们这一侧的猜测 // 侧看到的工具结果(`browserError`)来自这条错误 —— 不上报原话,用户就只看到我们这一侧的猜测
// (真机踩过:窗口上跳出浏览器的抱怨,日志里像浏览器坏了,工具结果里什么都没有)。 // (真机踩过:窗口上跳出浏览器的抱怨,日志里像浏览器坏了,工具结果里什么都没有)。
// 没有输出时也如实点明,那本身是线索(参数被拒之类通常会带话)。 // 没有输出时也如实点明,那本身是线索(参数被拒之类通常会带话)。
//
// 拼接后整条都是**调用方可见**的,因此两半各自的出镜口径必须交代清楚:
//
// - `preview` 是浏览器可控的自由文本 ⇒ 已在 preview() 里过了 chromeOutputPreview
// (凭据形状打码 + 600 rune 上限)。这里**不再**加一道带上限的守卫:同一份上限套在
// 整条上,会把下面这条错误自己的**可操作**建议截掉(`profileBusyHint` 里带 profileDir
// 路径,adopt_test 钉着它必须可见);
// - `err` 那一半是我们自己拼的(ErrLaunch 分类标记、endpointURL 回环地址、两条 hint 里
// 的文件系统路径),**不含对端文本** —— 对端说的话一律经 preview 走,没有第二条入口。
//
// 换言之:整条被覆盖,靠的是「对端文本只有一个入口(preview)」这个结构约束,而不是在
// 拼接处再洗一遍。
func withChromeOutput(err error, preview string) error { func withChromeOutput(err error, preview string) error {
if preview == "" { if preview == "" {
return fmt.Errorf("%w;浏览器没有输出任何内容", err) return fmt.Errorf("%w;浏览器没有输出任何内容", err)
+25 -2
View File
@@ -5,10 +5,13 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
) )
// cdpCallTimeout 是单条 CDP 命令的兜底超时(handshake / 读超时)。正常调用还会被 // cdpCallTimeout 是单条 CDP 命令的兜底超时(handshake / 读超时)。正常调用还会被
@@ -30,8 +33,28 @@ type cdpError struct {
Message string `json:"message"` Message string `json:"message"`
} }
// Error 实现 error 接口。 // Error 渲染协议错误(实现 error 接口)。
func (e *cdpError) Error() string { return fmt.Sprintf("CDP %d: %s", e.Code, e.Message) } //
// 🔴 Message 是**上游自由文本**:Chrome 与各类兼容实现可以往里面放任何东西 —— 包括
// 它刚被要求操作的表达式、地址或页面内容。原样进错误等于把那段内容抄进日志与模型
// 可见视图,因此默认**不出镜**:
// - 只有像协议枚举的短串(trace.ErrorToken:≤64 字节、仅 [A-Za-z0-9_.-])才保留
// —— 真正的枚举(context_not_found 之类)能过,散文与回显一律过不了;
// - 其余只留**长度**(字节数),够判断「对端给了句话」而不带那句话。
//
// 方法名由调用点补齐(cdpClient.call / Call 的 %w 文案),诊断面并不只剩一个码。
func (e *cdpError) Error() string {
var b strings.Builder
fmt.Fprintf(&b, "CDP %d", e.Code)
switch token := trace.ErrorToken(e.Message); {
case token != "":
b.WriteString(": ")
b.WriteString(token)
case e.Message != "":
fmt.Fprintf(&b, " (message %d bytes withheld)", len(e.Message))
}
return b.String()
}
// cdpClient 是最小 CDP 客户端:一条 WebSocket、一个读协程、按 id 关联的待答表。 // cdpClient 是最小 CDP 客户端:一条 WebSocket、一个读协程、按 id 关联的待答表。
// //
+52 -6
View File
@@ -3,6 +3,7 @@ package browser
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -105,11 +106,23 @@ func TestCDPClient_CallRoundTrip(t *testing.T) {
} }
} }
// TestCDPClient_ProtocolErrorIsWrapped 钉住协议错误被 %w 包成 ErrCDP(上层据此 // TestCDPClient_ProtocolErrorKeepsNoFreeText 钉住两件事:协议错误被 %w 包成 ErrCDP
// 判定「连接级问题 ⇒ 丢会话重起」)。 // (上层据此判定「连接级问题 ⇒ 丢会话重起」),而 CDP 的 error.message **不出镜**。
func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) { //
// 为什么 message 不能出镜:它是**上游自由文本** —— Chrome 与各类兼容实现可以往里面
// 放它刚被要求操作的东西(表达式、地址、页面内容)。只有像协议枚举的短串
// (trace.ErrorToken)才保留,其余只留字节数。
//
// 夹具刻意用**带空格的散文 + 会被回显的页面文本**:真正的 CDP message 就长这样
// ("Cannot find context with specified id"),而像令牌的单字("boom")本来就会过
// 守卫 —— 用例不该拿它当「自由文本」。
//
// 变异判据:把 cdpError.Error() 换回 `fmt.Sprintf("CDP %d: %s", e.Code, e.Message)`
// ⇒ 哨兵文本出现在错误串里,本用例红。
func TestCDPClient_ProtocolErrorKeepsNoFreeText(t *testing.T) {
const message = "Cannot resolve context for <script>PAGE-ECHO-SECRET</script>"
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
return nil, &cdpError{Code: -32000, Message: "boom"} return nil, &cdpError{Code: -32000, Message: message}
}) })
wsURL, _ := pageTargetWS(context.Background(), f.base) wsURL, _ := pageTargetWS(context.Background(), f.base)
c, err := newCDPClient(context.Background(), wsURL) c, err := newCDPClient(context.Background(), wsURL)
@@ -118,14 +131,47 @@ func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
} }
defer func() { _ = c.Close() }() defer func() { _ = c.Close() }()
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil) err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
if err == nil || !strings.Contains(err.Error(), "boom") { if err == nil {
t.Fatalf("Call 的错误 = %v,应含 CDP 的 message", err) t.Fatal("协议错误应当返回 error")
}
if strings.Contains(err.Error(), "PAGE-ECHO-SECRET") {
t.Fatalf("CDP 的自由文本进了错误串:%v", err)
}
if !strings.Contains(err.Error(), "CDP -32000") {
t.Errorf("错误没带上协议码: %v", err)
}
if want := fmt.Sprintf("message %d bytes withheld", len(message)); !strings.Contains(err.Error(), want) {
t.Errorf("错误没带上被扣下文本的长度(期望 %q): %v", want, err)
}
// 方法名是代码给的名字,必须仍在 —— 扣掉 message 之后诊断面靠它定位。
if !strings.Contains(err.Error(), "Runtime.evaluate") {
t.Errorf("错误丢了方法名: %v", err)
} }
if !strings.Contains(err.Error(), "CDP 调用失败") { if !strings.Contains(err.Error(), "CDP 调用失败") {
t.Errorf("错误没带上 ErrCDP 文案: %v", err) t.Errorf("错误没带上 ErrCDP 文案: %v", err)
} }
} }
// TestCDPClient_ProtocolErrorKeepsProtocolToken 是「协议枚举仍可读」的正向证据:
// 取值像令牌(≤64 字节、仅 [A-Za-z0-9_.-])的 message 原样保留。
//
// 变异判据:把 cdpError.Error() 改成「message 一律不带」⇒ 本用例红。
func TestCDPClient_ProtocolErrorKeepsProtocolToken(t *testing.T) {
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
return nil, &cdpError{Code: -32602, Message: "context_not_found"}
})
wsURL, _ := pageTargetWS(context.Background(), f.base)
c, err := newCDPClient(context.Background(), wsURL)
if err != nil {
t.Fatalf("newCDPClient: %v", err)
}
defer func() { _ = c.Close() }()
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
if err == nil || !strings.Contains(err.Error(), "context_not_found") {
t.Fatalf("协议枚举形状的 message 应保留:%v", err)
}
}
// TestCDPClient_ContextCancelStopsWaiting 钉住「取消能立刻返回」,不是等对端回话。 // TestCDPClient_ContextCancelStopsWaiting 钉住「取消能立刻返回」,不是等对端回话。
func TestCDPClient_ContextCancelStopsWaiting(t *testing.T) { func TestCDPClient_ContextCancelStopsWaiting(t *testing.T) {
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
@@ -0,0 +1,154 @@
package browser
// 本文件钉住「浏览器自己说的话」这条自由文本出口的两条约束 —— 既**有界**(600 rune +
// 12 行)又**打码**(凭据形状不外泄),而且两者都**不许**伤到排障信息:普通原话必须仍然
// 看得见(这是 launch_test / adopt_test 既有的「失败要如实报」口径)。
//
// 「删实现能打红」的落点:把 preview() 里的 chromeOutputPreview 调用退回原样直吐,本文件
// 的凭据断言(以及日志读面那条)会全红。
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
"time"
"unicode/utf8"
)
// TestChromeOutputPreviewRedactsCredentials 是这条出口的**核心断言**:浏览器把带凭据的
// 地址/请求头打进 stderr 时,出镜的预览里只有 [redacted],没有凭据本体;同时排障用的
// 普通原话一个字都不许少。
func TestChromeOutputPreviewRedactsCredentials(t *testing.T) {
t.Parallel()
const (
complaint = "ERROR:process_singleton.cc(343)] 参数被拒: --remote-allow-origins=*"
credURL = "ERROR:navigation] 载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345"
bearer = "WARNING:net] 请求头 Authorization: Bearer abcdefghij1234567890"
)
var out chromeOutput
for _, line := range []string{complaint, credURL, bearer} {
if _, err := out.Write([]byte(line + "\n")); err != nil {
t.Fatalf("chromeOutput.Write 永不返回错误: %v", err)
}
}
got := out.preview()
t.Logf("实测预览=%q", got)
for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} {
if strings.Contains(got, secret) {
t.Errorf("凭据形状 %q 原样出镜了: %q", secret, got)
}
}
if !strings.Contains(got, "[redacted]") {
t.Errorf("凭据应被打成 [redacted](看得见的打码,不是静默删除): %q", got)
}
// 反面:打码不能顺手把「浏览器为什么拒绝」一起吃掉 —— 那正是这条出口存在的理由。
for _, want := range []string{complaint, "载入失败", "请求头 Authorization"} {
if !strings.Contains(got, want) {
t.Errorf("排障用的普通原话 %q 不该被洗掉: %q", want, got)
}
}
}
// TestChromeOutputPreviewIsBounded 钉住上限的两半:正文被截到 chromeOutputPreviewRunes
// 并留 `…`;而「其实说了多少字节」这条**我们自己的**说明必须活着(上限只压正文,不吃
// 量级)。没有它,读者会把半截输出误当成完整输出。
func TestChromeOutputPreviewIsBounded(t *testing.T) {
t.Parallel()
t.Run("单行超长只留上限并标截断", func(t *testing.T) {
t.Parallel()
var out chromeOutput
if _, err := out.Write([]byte(strings.Repeat("A", 4000) + "\n")); err != nil {
t.Fatalf("Write: %v", err)
}
got := out.preview()
if !strings.HasSuffix(got, "…") {
t.Errorf("超上限必须留可见的截断标记: 长度=%d 尾部=%q", utf8.RuneCountInString(got), tailRunes(got, 8))
}
if n := utf8.RuneCountInString(got); n != chromeOutputPreviewRunes+1 {
t.Errorf("正文应正好截到 %d rune + 一个 `…`,实际 %d rune", chromeOutputPreviewRunes, n)
}
})
t.Run("字节截断时仍报总量", func(t *testing.T) {
t.Parallel()
var out chromeOutput
// 5000 字节 > chromeOutputMaxBytes(4096)⇒ 缓存本身就被截,preview 必须点明总量。
if _, err := out.Write([]byte(strings.Repeat("B", 5000))); err != nil {
t.Fatalf("Write: %v", err)
}
got := out.preview()
if !strings.Contains(got, "已截断,共 5000 字节") {
t.Errorf("字节被截时必须报出真实总量(不能拿留下的那部分当全部): %q", tailRunes(got, 40))
}
})
}
// TestDial_ChromeOutputOnFailureIsRedacted 走真实失败路径(假浏览器 exit 0 ⇒
// profileBusyHint + withChromeOutput),断言**拼接后的整条**(hint 那半 + 浏览器原话那半)
// 在用户侧可见的错误串与失败日志里都没有凭据形状,且两半的信息都还在。
func TestDial_ChromeOutputOnFailureIsRedacted(t *testing.T) {
t.Parallel()
const (
complaint = "不支持的标记: --remote-allow-origins=*"
credLine = "载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345"
bearerLine = "请求头 Authorization: Bearer abcdefghij1234567890"
)
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{
Stderr: []string{complaint, credLine, bearerLine}, ExitCode: 0,
}, 5*time.Second, log)
_, stop, err := l.Dial(context.Background())
if stop != nil {
t.Error("失败路径不该交出停止函数")
}
if err == nil {
t.Fatal("进程 exit 0 却不写端口记录 ⇒ 必须报错")
}
if !errors.Is(err, ErrLaunch) {
t.Fatalf("仍须包住 ErrLaunch: %v", err)
}
msg := err.Error()
logs := buf.String()
t.Logf("实测 error=%v\n实测日志=%s", err, logs)
for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} {
if strings.Contains(msg, secret) {
t.Errorf("返回给调用方的错误里出现了凭据形状 %q(用户侧工具结果直接来自这条串): %v", secret, err)
}
if strings.Contains(logs, secret) {
t.Errorf("失败日志里出现了凭据形状 %q(密钥不落日志):\n%s", secret, logs)
}
}
if !strings.Contains(msg, "[redacted]") {
t.Errorf("打码应当看得见([redacted]): %v", err)
}
// 两半都得活着:hint 那半(可操作建议 + profileDir 路径)不能被上限截掉,
// 浏览器原话那半是这条出口存在的理由。
for _, want := range []string{
"绝不杀别人的浏览器", l.profileDir,
complaint, "载入失败",
} {
if !strings.Contains(msg, want) {
t.Errorf("整条错误里缺少 %q(过度打码/截断): %v", want, err)
}
}
if !strings.Contains(logs, complaint) {
t.Errorf("失败日志里丢了浏览器原话 %q(排障信息):\n%s", complaint, logs)
}
}
// tailRunes 返回 s 的末 n 个 rune,供失败断言打印尾部而不是把 4000 字符灌进报告。
func tailRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[len(r)-n:])
}
+124
View File
@@ -0,0 +1,124 @@
package browser
// 本文件是**对端自由文本出口**的收敛点:有三处会把页面/浏览器自己说的话原样交给我们,
// 它们出镜(进 error ⇒ 日志 + 模型可见视图)之前必须在这里过一道。
//
// 三处**不是同一类**,因此守卫也不同(口径见 internal/runtime/trace/errortoken.go
// 的「按字段类别判定」):
//
// - `Page.navigate` 的 `errorText` 是**协议枚举**:值域是 Chrome 的 net error 码
// (`net::ERR_NAME_NOT_RESOLVED` 之类),短、标识符形状 ⇒ 用 trace.ErrorToken 守:
// 像枚举就保留,散文一律丢弃;
// - `Runtime.evaluate` 的 `exceptionDetails.text` 是**页面自由文本**:页面的 JS 可以
// throw 任何东西 —— 包括它读到的 DOM 正文、地址栏里带 token 的 URL。⇒ 默认不带
// 全文,按「带上限地带」处理:压平空白 + trace.RedactTextLimited 截断;
// - 浏览器自己的 stdout/stderr 预览(launchoutput.go 的 `preview()`)也是自由文本:
// 参数被拒时 Chromium 会把**本次 argv** 与地址原话打出来(`chromeOutputPreview`)。
//
// 🔴 两类守卫**不能互换**:拿 ErrorToken 去「清洗」自由文本是错的口径(它只会把整段
// 话删掉,不是清洗);拿 RedactTextLimited 去守枚举字段也不对(它不判形状,挡不住
// 散文)。清洗实现仍然只有一份,本文件只做「选哪一道 + 补形状前提」。
import (
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maxExceptionTextRunes 是 `exceptionDetails.text` 允许出镜的 rune 上限:**200**。
//
// 为什么不复用既有的两个档位:
// - **80**(`trace.RedactText` 的摘要档)会把真实的异常消息拦腰截断 ——
// `TypeError: Cannot read properties of undefined (reading 'addEventListener')`
// 已经 73 rune,再带上半句调用点就过 80,而调用点是唯一的定位线索;
// - **4096**(网关日志页 `maxLogMsgRunes` 那一档)是给「运维要读全文」的日志行用的;
// 这个字段是**页面自己说的话**,4096 等于允许页面把 4KB 正文塞进一条错误串 ——
// 而这条错误串同时进日志与模型可见视图。
//
// 200 容得下「异常消息 + 一段调用点」,又把页面可控文本压在「一句话」量级。超限时
// trace.RedactTextLimited 会补 `…`,因此截断本身是看得见的,不会被误读成全文。
const maxExceptionTextRunes = 200
// netErrorNamespace 是 Chrome net error 码的命名空间前缀(`net::ERR_...`)。
//
// 为什么要先剥掉它再交给 ErrorToken:ErrorToken 的字符集是 `[A-Za-z0-9_.-]`,**不含
// `:`**,整串直接送进去会把合法的网络错误码一起判掉 —— 而「合法网络错误码形态保留」
// 是这条出口的硬要求(上层与工具都在断言它,见 render_test ③)。这个前缀是**协议
// 常量**,不是从对端抄来的文本,因此剥它是安全的。
const netErrorNamespace = "net::"
// netErrorCodePrefix 是 net error 枚举的名字前缀(`ERR_`):所有真实取值都以它开头。
// 于是这一条额外把「恰好是单个令牌的自由文本」(`hunter2` 之类)也挡掉 —— 形状判定
// 仍由 trace.ErrorToken 做,本文件只补这一个前提,不复制第二份守卫实现。
const netErrorCodePrefix = "ERR_"
// netErrorCode 只放行「像 net error 枚举」的 errorText:`[net::]ERR_<标识符>`。
// 其余(散文、被回显的 URL、带凭据的文本)返回空串,由调用方按「已隐去」处理。
func netErrorCode(raw string) string {
text := strings.TrimSpace(raw)
namespace := ""
if rest, ok := strings.CutPrefix(text, netErrorNamespace); ok {
namespace, text = netErrorNamespace, rest
}
if !strings.HasPrefix(text, netErrorCodePrefix) {
return ""
}
token := trace.ErrorToken(text)
if token == "" {
return ""
}
return namespace + token
}
// navigateCause 把 `Page.navigate` 的 errorText 变成可出镜的原因子句。
// 调用前提:raw 非空(navigate 只在 errorText 非空时判定为失败)。
//
// 不是枚举时一个字符的自由文本都不带,只留**字节数** —— 够判断「对端说了句话」,
// 不带那句话(与 cdpError.Error 同一口径)。
func navigateCause(raw string) string {
if code := netErrorCode(raw); code != "" {
return code
}
return fmt.Sprintf("网络错误文本已隐去(%d 字节)", len(raw))
}
// pageExceptionText 是 `exceptionDetails.text` 出镜前的唯一处理:压平空白(一个换行
// 就能在按行聚合的日志里伪造出「另一条记录」,与 sdklog / judgeReason 同一做法)+
// 凭据形状打码 + 截断(清洗实现只有一份:trace.RedactTextLimited)。
func pageExceptionText(raw string) string {
return trace.RedactTextLimited(strings.Join(strings.Fields(raw), " "), maxExceptionTextRunes)
}
// evaluateCause 把 `Runtime.evaluate` 的 exceptionDetails.text 变成可出镜的原因子句。
func evaluateCause(raw string) string {
if text := pageExceptionText(raw); text != "" {
return text
}
// 压平之后什么都不剩(对端只给了空白):如实说「没给文本」,不假装异常无因。
return "页面异常(对端未给文本)"
}
// chromeOutputPreviewRunes 是浏览器 stdout/stderr 预览出镜时的 rune 上限:**600**
// (= 12 行 × 50 rune)。
//
// 为什么是这个量级:预览的**行**预算已经是 12 行(chromeOutputMaxLines),50 rune 大致
// 是一个短分句 —— Chromium 的抱怨句式是「原因在前」(`ERROR:...] 参数被拒:...`),
// 所以 600 保得住原因那一句,又把「一整块转储」挡在模型可见的错误串之外。
//
// 为什么不沿用另外两档:80(`trace.RedactText` 摘要档)连**一行**浏览器日志都放不下;
// 4096(`chromeOutputMaxBytes` 缓存档、网关日志页 maxLogMsgRunes)是「运维读全文」的
// 量级,对浏览器可控的字段等于允许把 4KB 正文塞进工具结果。
//
// 为什么 600 不会把「到底说了多少」藏起来:本上限只压在**预览正文**上,调用方
// (launchoutput.go)随后仍会追加「已截断,共 N 字节」——读者看到的永远是「这里有 600
// rune 的原话 + 它其实说了 N 字节」,截断与量级都看得见。
const chromeOutputPreviewRunes = 600
// chromeOutputPreview 是浏览器 stdout/stderr 预览出镜前的唯一处理:凭据形状打码 +
// 截断(清洗实现只有一份:trace.RedactTextLimited)。它服务**两个**读面 —— 失败日志的
// `chrome_output` 字段与返回给调用方的错误串(launch.go 的 launchOnce),因此收敛在
// preview() 里,两个读面不会判得不一致。
func chromeOutputPreview(text string) string {
return trace.RedactTextLimited(text, chromeOutputPreviewRunes)
}
+200
View File
@@ -0,0 +1,200 @@
package browser
import (
"context"
"errors"
"strconv"
"strings"
"testing"
"unicode/utf8"
)
// navigateErrorCDP 造一个「导航必失败、errorText 是给定文本」的假 CDP:只应答 Page.enable
// 与 Page.navigate,导航之后一律拒绝(失败之后不该再有求值)。
func navigateErrorCDP(t *testing.T, errorText string) *fakeCDP {
t.Helper()
return newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) {
switch method {
case "Page.enable":
return map[string]any{}, nil
case "Page.navigate":
return map[string]any{"errorText": errorText}, nil
}
return nil, &cdpError{Code: -32601, Message: "导航失败后不该再发 " + method}
})
}
// evaluateExceptionCDP 造一个「导航与就绪都正常、取正文那一次求值抛异常」的假 CDP:
// 异常文本由调用方给(模拟页面 JS 往 exceptionDetails.text 里塞任何东西)。
func evaluateExceptionCDP(t *testing.T, exceptionText string) *fakeCDP {
t.Helper()
return newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) {
switch method {
case "Page.enable", "Page.navigate":
return map[string]any{}, nil
case "Page.getFrameTree":
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
case "Page.createIsolatedWorld":
return map[string]any{"executionContextId": 1}, nil
case "Runtime.evaluate":
expr, _ := params["expression"].(string)
if strings.Contains(expr, "readyState") {
return map[string]any{"result": map[string]any{"value": map[string]any{
"url": "https://example.com/", "title": "t", "readyState": "complete",
}}}, nil
}
return map[string]any{"exceptionDetails": map[string]any{"text": exceptionText}}, nil
}
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
})
}
// 出口 A(`Page.navigate` 的 errorText):散文形状的值一个字符都不许出镜 —— 代理与网关
// 会把它回显成任意文本,包括它刚收到的那条带 token 的 URL。判据是「原文本里每个有辨识度
// 的片段都不在错误串里」,且出镜的是**字节数**(够判断对端说了句话,不带那句话)。
//
// 删掉 navigateCause 的调用(退回 `fmt.Errorf("%w: %s", ErrNavigate, text)`)时本用例必红。
func TestManager_RenderTextNavigateErrorTextNeverEchoesFreeText(t *testing.T) {
t.Parallel()
const raw = "Failed loading https://example.com/?access_token=SUPERSECRET123 (via proxy)"
m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, raw)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrNavigate) {
t.Fatalf("err = %v, want ErrNavigate", err)
}
msg := err.Error()
for _, leak := range []string{"SUPERSECRET123", "access_token", "https://example.com/?", "Failed loading", "proxy"} {
if strings.Contains(msg, leak) {
t.Errorf("错误串泄露了 errorText 的自由文本片段 %q: %q", leak, msg)
}
}
if !strings.Contains(msg, "已隐去") {
t.Errorf("应点明文本已隐去(否则读起来像对端没说话): %q", msg)
}
if !strings.Contains(msg, strconv.Itoa(len(raw))) {
t.Errorf("应带出被隐去文本的字节数 %d(唯一的排查线索): %q", len(raw), msg)
}
}
// 出口 A 的正向:合法的 net error 枚举**必须原样保留** —— 它是调用方与工具分类失败的
// 唯一依据(render_test ③ 也在断言它)。这条同时钉住「net:: 前缀要先剥掉再交给
// ErrorToken」这个实现前提:整串直接送进 ErrorToken 会因 `:` 被判空,正向就丢了。
func TestManager_RenderTextNavigateErrorTextKeepsNetErrorCode(t *testing.T) {
t.Parallel()
for _, code := range []string{
"net::ERR_NAME_NOT_RESOLVED",
"net::ERR_CONNECTION_REFUSED",
"net::ERR_TIMED_OUT",
"ERR_ABORTED",
} {
m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, code)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrNavigate) {
t.Fatalf("%s: err = %v, want ErrNavigate", code, err)
}
if !strings.Contains(err.Error(), code) {
t.Errorf("%s 是合法的网络错误码,必须原样保留,实得 %q", code, err.Error())
}
}
}
// 出口 B(`Runtime.evaluate` 的 exceptionDetails.text):页面 JS 可以 throw 任何东西,
// 包括它读到的凭据。判据是「凭据形状被打码、消息本体仍可排障」—— 不是整段丢弃(自由
// 文本的处置口径是「带上限地带」,见 errtext.go)。
//
// 删掉 evaluateCause 的调用(退回原样直吐)时本用例必红。
func TestManager_RenderTextExceptionTextRedactsCredentials(t *testing.T) {
t.Parallel()
const raw = "TypeError: fetch failed https://api.example.com/v1?api_key=sk-live-ABCDEFGH12345"
m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrEvaluate) {
t.Fatalf("err = %v, want ErrEvaluate", err)
}
msg := err.Error()
for _, leak := range []string{"ABCDEFGH12345", "sk-live"} {
if strings.Contains(msg, leak) {
t.Errorf("错误串泄露了凭据 %q: %q", leak, msg)
}
}
if !strings.Contains(msg, "[redacted]") {
t.Errorf("凭据形状应被打成 [redacted](看得见的打码,不是静默删除): %q", msg)
}
if !strings.Contains(msg, "TypeError") {
t.Errorf("异常消息本体是唯一的定位线索,不该被整段丢掉: %q", msg)
}
}
// 出口 B 的上限:页面可以把 4KB 正文塞进异常文本,而这条错误串同时进日志与模型可见视图。
// 判据:超限时截断看得见(`…`),且超出上限的尾部**确实没出镜**;换行被压平(一个换行
// 就能在按行聚合的日志里伪造出另一条记录)。
func TestManager_RenderTextExceptionTextIsBoundedAndFlattened(t *testing.T) {
t.Parallel()
const tail = "TAILMARKER"
raw := strings.Repeat("A", maxExceptionTextRunes) + "\n" + tail
m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrEvaluate) {
t.Fatalf("err = %v, want ErrEvaluate", err)
}
msg := err.Error()
if strings.Contains(msg, tail) {
t.Errorf("超出上限的尾部不该出镜: %q", msg)
}
if !strings.HasSuffix(msg, "…") {
t.Errorf("截断必须看得见(尾部补省略号),不许让半截文本读起来像全文: %q", msg)
}
if strings.ContainsAny(msg, "\r\n") {
t.Errorf("异常文本里的换行必须压平(否则可在按行聚合的日志里伪造记录): %q", msg)
}
}
// pageExceptionText 的档位就是 maxExceptionTextRunes:超限出 maxExceptionTextRunes 个
// rune 加一个省略号,一个不多。
func TestPageExceptionTextLimitsRunes(t *testing.T) {
t.Parallel()
got := pageExceptionText(strings.Repeat("长", maxExceptionTextRunes+50))
if n, want := utf8.RuneCountInString(got), maxExceptionTextRunes+1; n != want {
t.Errorf("rune 数 = %d, want %d(%d + 省略号)", n, want, maxExceptionTextRunes)
}
if !strings.HasSuffix(got, "…") {
t.Errorf("截断应补省略号: %q", got)
}
// 未超限时原样返回,不补省略号(截断标记只表示「确实被截了」)。
short := pageExceptionText("TypeError: body is gone")
if short != "TypeError: body is gone" {
t.Errorf("未超限不该改动文本: %q", short)
}
// 只给空白 ⇒ 空串,由 evaluateCause 如实说「对端未给文本」。
if got := evaluateCause(" \n\t "); got != "页面异常(对端未给文本)" {
t.Errorf("空白异常文本的处理 = %q", got)
}
}
// netErrorCode 只放行「像 net error 枚举」的值:形状判定仍由 trace.ErrorToken 做,本
// 函数只补「net:: 命名空间可剥 + 必须以 ERR_ 开头」这两个前提 —— 于是「恰好是单个令牌
// 的自由文本」(`hunter2`)不会被误当成错误码保留。
func TestNetErrorCodeOnlyAcceptsEnumerations(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
}{
{"net::ERR_NAME_NOT_RESOLVED", "net::ERR_NAME_NOT_RESOLVED"},
{" net::ERR_ABORTED ", "net::ERR_ABORTED"},
{"ERR_FAILED", "ERR_FAILED"},
{"hunter2", ""},
{"Failed loading https://example.com/", ""},
{"net::ERR_NOT_A_CODE because spaces", ""},
{"net::hunter2", ""},
{"", ""},
}
for _, c := range cases {
if got := netErrorCode(c.in); got != c.want {
t.Errorf("netErrorCode(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+13 -2
View File
@@ -20,10 +20,12 @@ const (
// 的落地前提是「看得见失败」—— 启动失败时浏览器把原因写在自己的 stdout/stderr 上 // 的落地前提是「看得见失败」—— 启动失败时浏览器把原因写在自己的 stdout/stderr 上
// (参数被拒、profile 锁、崩溃),不接住它,日志里就只剩我们这一侧的猜测。 // (参数被拒、profile 锁、崩溃),不接住它,日志里就只剩我们这一侧的猜测。
// //
// 两条口径: // 三条口径:
// //
// - **有界**:只留前 N 字节 / 上报前 N 行。正常启动时 Chrome 也会往 stderr 写常规 // - **有界**:只留前 N 字节 / 上报前 N 行。正常启动时 Chrome 也会往 stderr 写常规
// 信息,全打进日志就是刷屏;缓存也无上限地吃内存。 // 信息,全打进日志就是刷屏;缓存也无上限地吃内存。
// - **打码**:这是浏览器可控的自由文本,出镜前必过 chromeOutputPreview(凭据形状
// 打码 + rune 上限,见 errtext.go)。它进的不只是日志,还有用户侧的工具结果。
// - **不主动打**:本类型只缓存,**不输出**。成功时只报字节数(Debug),失败时由 // - **不主动打**:本类型只缓存,**不输出**。成功时只报字节数(Debug),失败时由
// `localChrome.Dial` 一次性把前 N 行与本次 argv 一起报出来(见 launch.go)。 // `localChrome.Dial` 一次性把前 N 行与本次 argv 一起报出来(见 launch.go)。
type chromeOutput struct { type chromeOutput struct {
@@ -65,6 +67,13 @@ func (o *chromeOutput) totalBytes() int {
// preview 返回失败上报用的「前 N 行」:每行内的空白压成单空格、空行去掉;被截断时 // preview 返回失败上报用的「前 N 行」:每行内的空白压成单空格、空行去掉;被截断时
// 在末尾点明**截断与总字节数**(绝不把半截输出说得像完整输出)。没有输出时返回空串, // 在末尾点明**截断与总字节数**(绝不把半截输出说得像完整输出)。没有输出时返回空串,
// 调用方据此判断「浏览器一个字都没说」。 // 调用方据此判断「浏览器一个字都没说」。
//
// 🔴 正文出镜前必过 chromeOutputPreview(凭据形状打码 + 600 rune 上限,见 errtext.go):
// 这个字符串**是浏览器可控的自由文本**,而它会同时进失败日志与**返回给调用方的错误串**
// (launchOnce ⇒ 用户侧工具结果)。真机上 Chromium 会把本次 argv、被拒的参数、地址原话
// 打出来 —— 一旦里面出现 `?access_token=` 或 `Bearer ...`,原样透出就是凭据落进日志与
// 模型上下文。上限只压正文:下面的「已截断,共 N 字节」后缀是我们自己的定长说明,不进
// 这份预算,因此截断与量级都照实可见。
func (o *chromeOutput) preview() string { func (o *chromeOutput) preview() string {
o.mu.Lock() o.mu.Lock()
raw := truncateRune(o.head) raw := truncateRune(o.head)
@@ -84,7 +93,9 @@ func (o *chromeOutput) preview() string {
break break
} }
} }
text := strings.Join(kept, " | ") // 先拼再清洗(而不是逐行清洗):跨行的凭据形状(`access_token=` 与值被换行拆开)
// 只有拼起来之后才看得见。
text := chromeOutputPreview(strings.Join(kept, " | "))
if truncated && text != "" { if truncated && text != "" {
text += " | ...(已截断,共 " + strconv.Itoa(total) + " 字节)" text += " | ...(已截断,共 " + strconv.Itoa(total) + " 字节)"
} }
+5 -2
View File
@@ -100,7 +100,8 @@ func (s *session) navigate(ctx context.Context, url string) (pageState, error) {
return pageState{}, err return pageState{}, err
} }
if text := strings.TrimSpace(res.ErrorText); text != "" { if text := strings.TrimSpace(res.ErrorText); text != "" {
return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, text) // errorText 是**协议枚举**面:像 net error 码就保留,散文一律丢弃(见 errtext.go)。
return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, navigateCause(text))
} }
if err := s.waitReady(ctx); err != nil { if err := s.waitReady(ctx); err != nil {
return pageState{}, err return pageState{}, err
@@ -163,7 +164,9 @@ func (s *session) evaluate(ctx context.Context, expr string, out any) error {
return err return err
} }
if res.ExceptionDetails != nil { if res.ExceptionDetails != nil {
return fmt.Errorf("%w: %s", ErrEvaluate, res.ExceptionDetails.Text) // text 是**页面自由文本**面:页面 JS 可以 throw 任何东西(含 DOM 正文、带 token
// 的 URL)⇒ 压平空白 + 凭据打码 + 截断后再出镜(见 errtext.go)。
return fmt.Errorf("%w: %s", ErrEvaluate, evaluateCause(res.ExceptionDetails.Text))
} }
if out == nil || len(res.Result.Value) == 0 { if out == nil || len(res.Result.Value) == 0 {
return nil return nil
+13 -7
View File
@@ -33,7 +33,6 @@ func (c *Client) Connect(ctx context.Context) error {
if err != nil { if err != nil {
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, wrapConnectErr(err)) return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, wrapConnectErr(err))
} }
info := serverInfoFrom(session, c.hostFeatures()) info := serverInfoFrom(session, c.hostFeatures())
c.mu.Lock() c.mu.Lock()
c.session = session c.session = session
@@ -62,7 +61,7 @@ func (c *Client) Close() error {
if session != nil { if session != nil {
if err := session.Close(); err != nil { if err := session.Close(); err != nil {
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, err) return fmt.Errorf("mcp close %q: %w", c.cfg.Name, upstreamError(err))
} }
} }
return nil return nil
@@ -82,7 +81,7 @@ func (c *Client) ListTools(ctx context.Context) ([]ToolInfo, error) {
out := make([]ToolInfo, 0, 8) out := make([]ToolInfo, 0, 8)
for tool, err := range session.Tools(ctx, nil) { for tool, err := range session.Tools(ctx, nil) {
if err != nil { if err != nil {
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, err) return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, upstreamError(err))
} }
out = append(out, toolInfoFrom(tool)) out = append(out, toolInfoFrom(tool))
} }
@@ -112,7 +111,7 @@ func (c *Client) callTool(ctx context.Context, name string, args map[string]any,
} }
res, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: name, Arguments: prepared}) res, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: name, Arguments: prepared})
if err != nil { if err != nil {
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err) return ToolResult{}, fmt.Errorf("mcp call tool %q on %q: %w", name, c.cfg.Name, upstreamError(err))
} }
return ToolResult{ return ToolResult{
Content: toContentBlocks(res.Content), Content: toContentBlocks(res.Content),
@@ -165,7 +164,9 @@ func (c *Client) handleToolListChanged(context.Context, *mcpsdk.ToolListChangedR
// newSDKClient 按配置构建官方 SDK 客户端:只宣告宿主真正具备的客户端能力。 // newSDKClient 按配置构建官方 SDK 客户端:只宣告宿主真正具备的客户端能力。
func (c *Client) newSDKClient() *mcpsdk.Client { func (c *Client) newSDKClient() *mcpsdk.Client {
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: clientName, Version: clientVersion}, &mcpsdk.ClientOptions{ client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: clientName, Version: clientVersion}, &mcpsdk.ClientOptions{
Logger: c.log, // 🔴 交给 SDK 的 Logger 必须过 redactingLogger(sdklog.go):SDK 自己会把对端
// 错误原样打进日志(keepalive 那条已实测),那条路绕开了本包 upstreamError 的出口。
Logger: redactingLogger(c.log),
Capabilities: c.clientCapabilities(), Capabilities: c.clientCapabilities(),
CreateMessageHandler: c.wrapSampling(), CreateMessageHandler: c.wrapSampling(),
ElicitationHandler: c.wrapElicitation(), ElicitationHandler: c.wrapElicitation(),
@@ -188,12 +189,17 @@ func (c *Client) sessionOptions() *mcpsdk.ClientSessionOptions {
} }
// wrapConnectErr 把 SDK 报的「协议版本不支持」映射到本包哨兵错误,便于调用方判定。 // wrapConnectErr 把 SDK 报的「协议版本不支持」映射到本包哨兵错误,便于调用方判定。
//
// 它同时是握手失败的**脱敏点**:原始 SDK 错误(第三方 server 的自由文本)只经
// upstreamError 留下 code 与长度,绝不被 %w 带进文案 —— 而哨兵映射仍然成立,因为
// upstreamFailure 的 cause 保留了整条链,errors.Is 不受影响。
func wrapConnectErr(err error) error { func wrapConnectErr(err error) error {
safe := upstreamError(err)
var wire *sdkjsonrpc.Error var wire *sdkjsonrpc.Error
if errors.As(err, &wire) && wire.Code == mcpsdk.CodeUnsupportedProtocolVersion { if errors.As(err, &wire) && wire.Code == mcpsdk.CodeUnsupportedProtocolVersion {
return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, err) return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, safe)
} }
return err return safe
} }
// normalizeArgs 把 agent 传来的参数值规整成 MCP arguments 对象(必须是 JSON 对象)。 // normalizeArgs 把 agent 传来的参数值规整成 MCP arguments 对象(必须是 JSON 对象)。
+71
View File
@@ -0,0 +1,71 @@
package mcp
import (
"errors"
"fmt"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
// 本文件是「第三方 MCP server 的错误怎么写进本包错误」的**唯一**收敛点。
//
// 为什么需要它:MCP server 是第三方进程/远端服务,它的错误文本是**上游自由文本** ——
// 它可以把刚收到的工具参数、读到的文件内容、甚至是它自己的系统提示词塞进
// `error.message`。原样经 SDK error 进本包错误,那条文本就会随 `errors.Is` 分类链
// 一路进日志、进管理面板、进模型可见视图。与 trace.UpstreamError 同一口径:
// **默认不带自由文本**,只留可分类的骨架。
//
// 留下的骨架(够定位,不够回显):
// - 哪台 server / 哪把工具 —— 由调用点的 %w 文案给(名字来自配置与 tools/list,
// 不是错误文本);
// - JSON-RPC code —— 协议枚举(-32601 method not found、-32602 invalid params …),
// 是本文件唯一允许出镜的「对端给的值」,且以数字渲染;
// - message 的**字节数** —— 能区分「对端给了句话」与「对端什么都没说」,不带那句话。
// upstreamFailure 是一次第三方 server 失败在本包错误链里的形态。它的 Error() 只
// 渲染 code 与长度;原始原因挂在 cause 上,只供 errors.Is / errors.As 分类。
//
// 为什么不直接把 SDK 的 err 用 %w 包上:%w 只加前缀,被包的那条文本仍然在
// err.Error() 里 —— 包一层不是修复,只是换个位置泄漏(同 trace.URLCause 的口径)。
type upstreamFailure struct {
// code 是 JSON-RPC 错误码;hasCode 为 false 表示这条错误不是线上响应里的
// 结构化错误(传输层失败、SDK 内部错误、ctx 取消等),此时不带任何码。
code int64
hasCode bool
// length 是被扣下的自由文本的字节数。
length int
// cause 只用于分类,绝不参与 Error()。
cause error
}
// Error 渲染一行只含 code / 长度的诊断;自由文本一个字节都不出。
func (f *upstreamFailure) Error() string {
if f.hasCode {
return fmt.Sprintf("mcp server error: code=%d message_bytes=%d", f.code, f.length)
}
return fmt.Sprintf("mcp server error: %T message_bytes=%d", f.cause, f.length)
}
// Unwrap 让 errors.Is/As 仍然看得见原始原因(context.Canceled、
// context.DeadlineExceeded、*jsonrpc.Error 的码…),而 Error() 绝不渲染它。
// 少了它,取消会被上层当成普通工具失败继续跑。
func (f *upstreamFailure) Unwrap() error { return f.cause }
// upstreamError 把一次第三方 server 失败收敛成**不带自由文本**的错误。
//
// 收 code 用的 errors.As 会遍历整条链:SDK 常把线上错误再包一层(fmt 包装、
// JSON-RPC 信封),所以不能只断言最外层。
func upstreamError(err error) error {
if err == nil {
return nil
}
failure := &upstreamFailure{cause: err, length: len(err.Error())}
var wire *sdkjsonrpc.Error
if errors.As(err, &wire) {
failure.code = wire.Code
failure.hasCode = true
// WireError.Error() 就是 Message 本身,所以这里量的正是被扣下的那段文本。
failure.length = len(wire.Message)
}
return failure
}
+94
View File
@@ -0,0 +1,94 @@
package mcp
// 本文件钉住「第三方 MCP server 的错误文本不进错误与日志」这条口径(errors.go)。
//
// 生产风险:MCP server 是第三方进程/远端服务,它可以把刚收到的工具参数、读到的文件
// 内容、甚至自己的系统提示词塞进 error.message。原样经 SDK error 进本包错误,那条
// 文本就会随分类链一路进日志、进管理面板、进模型可见视图。
//
// 变异判据(每条都对应一个会红的实现):
// - 把 upstreamError 换成裸 err(或 `%w` 包原始 err)⇒ 哨兵文本出现在错误串里;
// - 把 upstreamFailure 的 Error() 改成渲染 cause ⇒ 同上;
// - 摘掉 Unwrap ⇒ 「分类仍成立」那条断言红(取消会被当成普通工具失败)。
import (
"context"
"errors"
"fmt"
"strings"
"testing"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// errSentinel 是「绝不能出现在错误串里」的第三方自由文本标记。
const errSentinel = "MCP-UPSTREAM-SENTINEL-原文回显"
// sentinelServer 起一台会在 tools/call 里返回带哨兵文本的 JSON-RPC 错误的 server。
func sentinelServer() *mcpsdk.Server {
srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "sentinel-mcp", Version: "1.0.0"}, nil)
srv.AddTool(&mcpsdk.Tool{
Name: "boom",
InputSchema: map[string]any{"type": "object"},
}, func(context.Context, *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
return nil, &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: errSentinel}
})
return srv
}
// TestCallTool_KeepsServerErrorTextOut 是 CallTool 的正面证据:错误里只剩
// server / 工具 / 协议码 / 长度,哨兵文本一个字节都没有。
func TestCallTool_KeepsServerErrorTextOut(t *testing.T) {
t.Parallel()
c := connectFakeClient(t, sentinelServer())
_, err := c.CallTool(context.Background(), "boom", map[string]any{})
if err == nil {
t.Fatal("server 返回协议错误时 CallTool 应当报错")
}
if strings.Contains(err.Error(), errSentinel) {
t.Fatalf("第三方 server 的自由文本进了错误串:%v", err)
}
for _, want := range []string{"boom", "fake", "code=-32603"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误里应保留可分类的骨架 %q:%v", want, err)
}
}
}
// TestUpstreamError_KeepsClassificationButNotText 直接钉住 errors.go 的契约:
// Error() 只有骨架,errors.Is/As 仍然看得见原始原因。
func TestUpstreamError_KeepsClassificationButNotText(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("sdk wrapper: %w", &sdkjsonrpc.Error{Code: -32601, Message: errSentinel})
err := upstreamError(cause)
if strings.Contains(err.Error(), errSentinel) {
t.Fatalf("自由文本进了错误串:%v", err)
}
if !strings.Contains(err.Error(), "code=-32601") {
t.Errorf("错误里丢了 JSON-RPC 码:%v", err)
}
// 分类面:WireError 的 Is 比的是码,链不能断。
if !errors.Is(err, &sdkjsonrpc.Error{Code: -32601}) {
t.Errorf("errors.Is 丢失了 JSON-RPC 码分类:%v", err)
}
var wire *sdkjsonrpc.Error
if !errors.As(err, &wire) || wire.Code != -32601 {
t.Errorf("errors.As 取不到原始 JSON-RPC 错误:%v", err)
}
}
// TestUpstreamError_KeepsContextCancellation 钉住「取消仍可分类」:上层靠
// errors.Is(err, context.Canceled) 区分「用户取消」与「工具失败」,
// 收敛文本时绝不能把这条链切断。
func TestUpstreamError_KeepsContextCancellation(t *testing.T) {
t.Parallel()
err := upstreamError(fmt.Errorf("mcp: %w", context.Canceled))
if !errors.Is(err, context.Canceled) {
t.Fatalf("errors.Is(err, context.Canceled) = false,取消被吞掉了:%v", err)
}
if strings.Contains(err.Error(), "context canceled") {
t.Errorf("取消的原文也被带进了错误串:%v", err)
}
}
+74
View File
@@ -0,0 +1,74 @@
package mcp
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住 MCP 工具的**来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md):
// 每把注册进注册表的工具都要带「哪台 server + 什么传输」,供面板按 server 分节。
// TestRegistryRegisterStampsToolOrigin 覆盖**两条接入路径**的共同汇点:
// - 运行时热装:plugin.Manager.start → mcp.NewRegistry → **Register**;
// - `.env` 的 MCP_SERVERS_JSON 启动期装:cmd/cyrene.registerMCPServer →
// mcp.NewRegistry → **同一个 Register**(不经插件管理器)。
//
// 两条路都在这里进注册表,因此这一处的断言就同时钉住了它们;也从反面证明
// 「从插件管理器现场反推归属」会漏掉 env 路径。
func TestRegistryRegisterStampsToolOrigin(t *testing.T) {
reg := registry.New()
cfg := MCPConfig{Name: "calc", TransportType: "stdio"}
r := NewRegistry(reg, cfg, WithTransport(startFakeServer(t, newFakeServer())))
if err := r.Register(context.Background()); err != nil {
t.Fatalf("Register: %v", err)
}
t.Cleanup(func() { _ = r.Unregister(context.Background()) })
want := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "calc", Transport: "stdio"}
tools := reg.List(registry.KindTool)
if len(tools) != 2 {
t.Fatalf("注册进注册表的工具数 = %d, want 2(echo/add)", len(tools))
}
for _, cap := range tools {
if cap.Origin != want {
t.Fatalf("工具 %q 的来源章 = %+v, want %+v", cap.Name, cap.Origin, want)
}
}
// 来源是 server 名,不是注册键 —— 与 `mcp:calc` 那条 KindMCP 条目无关。
if err := r.Unregister(context.Background()); err != nil {
t.Fatalf("Unregister: %v", err)
}
if left := reg.List(registry.KindTool); len(left) != 0 {
t.Fatalf("停用 server 后还剩 %d 把工具:条目必须连同来源章一起消失,不留孤儿", len(left))
}
}
// TestTransportNameOf 钉住来源章里 transport 的取值:它必须报**实际会用的**传输,
// 而不是原样下发可能为空的 TransportType(配置常见留空,由推断决定)。
// 判据与 newTransport 的推断同源,因此面板显示的传输永远是真在用的那个。
func TestTransportNameOf(t *testing.T) {
cases := []struct {
name string
cfg MCPConfig
want string
}{
{"显式 stdio", MCPConfig{TransportType: "stdio"}, "stdio"},
{"显式 sse", MCPConfig{TransportType: "sse"}, "sse"},
{"显式 http", MCPConfig{TransportType: "http"}, "http"},
{"streamable 归一为 http", MCPConfig{TransportType: "streamable"}, "http"},
{"留空 + 命令 ⇒ stdio", MCPConfig{Command: "npx"}, "stdio"},
{"留空 + 地址 ⇒ sse", MCPConfig{URL: "https://example.test/sse"}, "sse"},
{"命令优先于地址(与 newTransport 同序)", MCPConfig{Command: "npx", URL: "https://example.test"}, "stdio"},
{"都没有 ⇒ 空串(不编一个)", MCPConfig{}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := transportNameOf(tc.cfg); got != tc.want {
t.Fatalf("transportNameOf(%+v) = %q, want %q", tc.cfg, got, tc.want)
}
})
}
}
+124
View File
@@ -0,0 +1,124 @@
package mcp
// 本文件是「交给官方 SDK 的 Logger」的唯一收敛点。
//
// 为什么需要它(探针证据见 sdklog_probe_test.go):官方 SDK v1.8.0 的客户端侧会自己
// 打日志,其中 keepalive 那条把**对端错误原样**交给 logger:
//
// logger.Error("keepalive ping failed; closing session", "error", err, …)
//
// 而 err 是 SDK 自己的包装(`calling "ping": <对端 error.message>`),**不是**本包
// upstreamError 的产物 —— 于是第三方 server 的自由文本可以借 SDK 的手进我们的日志,
// 绕开 client.go 里那条「默认不带自由文本」的出口。探针实测:未加本文件时日志里出现
// 了对端回显的整句话。
//
// 收敛口径(与 client.go 同一刻画,不做第二套):
// - **线上错误**(链上有 *jsonrpc.Error):自由文本一律不带,只留
// `upstreamError` 渲染的 code + message_bytes;
// - **本地错误**(context deadline / canceled、传输错误):文本是代码自己产生的,
// 保留并以 maxSDKLogRunes 截断 —— 运维靠它判断「为什么 session 被关掉」;
// - 消息本身与所有字符串属性同样过 trace.RedactTextLimited(抹凭据形状 + 截断)。
//
// 为什么不是「一律 RedactText」:RedactText 只抹**凭据形状**,不删「她的话」——
// 对端把用户正文回显进 error.message 时,凭据清洗一个字符都不会动它。要挡住这一类,
// 判据必须是「这条文本来自线上」而不是「它像不像凭据」。
import (
"context"
"errors"
"log/slog"
"strings"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maxSDKLogRunes 是 SDK 日志行(消息与字符串属性)允许出镜的 rune 上限。
//
// 取 300 的理由:这些行是「方法名 + 原因」的合成串(`calling "ping": …`),
// 300 rune 容得下任何真实诊断,同时挡住 MiB 级对象把日志行撑爆;与网关日志页
// 的 maxLogMsgRunes(4096,整页一行要留全文)刻意不同 —— 这里的行是我们自己
// 产出的诊断,不需要全文,只需要「哪一步失败、为什么」。
const maxSDKLogRunes = 300
// redactingLogger 返回交给 SDK 的日志器:同一底层 handler,包一层日志面收敛。
// 它不改变级别与输出格式(Enabled 原样委托),因此宿主注入的 Logger 行为不变。
func redactingLogger(l *slog.Logger) *slog.Logger {
return slog.New(&redactingHandler{inner: l.Handler()})
}
// redactingHandler 是 redactingLogger 的 handler 形态。
type redactingHandler struct {
inner slog.Handler
}
// Enabled 原样委托:收敛发生在写出时,不影响任何级别判定。
func (h *redactingHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.inner.Enabled(ctx, level)
}
// Handle 先收敛一条记录(消息 + 全部属性),再交给底层 handler。
func (h *redactingHandler) Handle(ctx context.Context, r slog.Record) error {
out := slog.NewRecord(r.Time, r.Level, scrubSDKText(r.Message), r.PC)
r.Attrs(func(a slog.Attr) bool {
out.AddAttrs(scrubSDKAttr(a))
return true
})
return h.inner.Handle(ctx, out)
}
// WithAttrs 让预置属性(logger.With(...))同样过收敛 —— 否则「先 With 再打」的
// 记录会绕过本文件。
func (h *redactingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
scrubbed := make([]slog.Attr, len(attrs))
for i, a := range attrs {
scrubbed[i] = scrubSDKAttr(a)
}
return &redactingHandler{inner: h.inner.WithAttrs(scrubbed)}
}
// WithGroup 透传分组,组内属性仍由 Handle 逐条收敛。
func (h *redactingHandler) WithGroup(name string) slog.Handler {
return &redactingHandler{inner: h.inner.WithGroup(name)}
}
// scrubSDKText 是一条自由文本进入日志前唯一处理:压平空白(一个换行就能在按行聚合
// 的日志里伪造出「另一条记录」)+ 凭据形状清洗 + 截断。
func scrubSDKText(s string) string {
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), maxSDKLogRunes)
}
// scrubSDKAttr 收敛一条属性:字符串与 error 是自由文本面,分组递归,其余(数字、
// 时间、布尔)原样保留 —— 它们是体积与状态,没有内容。
func scrubSDKAttr(a slog.Attr) slog.Attr {
switch a.Value.Kind() {
case slog.KindString:
a.Value = slog.StringValue(scrubSDKText(a.Value.String()))
case slog.KindAny:
if err, ok := a.Value.Any().(error); ok && err != nil {
a.Value = slog.StringValue(scrubSDKError(err))
}
case slog.KindGroup:
group := a.Value.Group()
scrubbed := make([]slog.Attr, len(group))
for i, item := range group {
scrubbed[i] = scrubSDKAttr(item)
}
a.Value = slog.GroupValue(scrubbed...)
}
return a
}
// scrubSDKError 区分两类错误来源(口径见文件头):
// - 链上有 *jsonrpc.Error ⇒ 文本来自对端,走 upstreamError 的骨架(code +
// message_bytes),一个字节的自由文本都不留;
// - 否则是本地产生的错误(context deadline / canceled、传输层、SDK 自身),
// 保留原因(运维要靠它判断失败性质),只做凭据清洗与截断。
func scrubSDKError(err error) string {
var wire *sdkjsonrpc.Error
if errors.As(err, &wire) {
return upstreamError(err).Error()
}
return scrubSDKText(err.Error())
}
@@ -0,0 +1,175 @@
package mcp
// 本文件是「SDK 自己的 Logger 日志面会不会漏第三方 server 的自由文本」的**探针**,
// 由塔 2026-10-26 复审 p2 要求:先证明有没有泄漏,再决定要不要上基础设施。
//
// 探针覆盖 go-sdk v1.8.0 客户端侧两处会打日志的点:
// 1. 正常的方法调用失败(tools/call 返回 JSON-RPC error)—— SDK 是否记录它;
// 2. keepalive ping 被对端用 JSON-RPC error 拒绝 —— SDK 明确会
// `logger.Warn("keepalive ping failed;…", "error", err)`,err 就是**对端的自由文本**。
//
// 结论(v1.8.0 实测):第 1 条不打日志;第 2 条会,且原样带出 message —— 因此
// newSDKClient 交给 SDK 的 Logger 必须经过 redactingLogger(见 sdklog.go)。
//
// 变异判据:把 newSDKClient 的 redactingLogger 摘掉 ⇒ 本文件的哨兵断言红。
import (
"bytes"
"context"
"strings"
"sync"
"testing"
"time"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// sdkLogSentinel 是第三方 server 塞进 JSON-RPC error.message 的哨兵(模拟「她刚才
// 说的那句话」被对端回显)。
const sdkLogSentinel = "SENTINEL-MCPLOG 她刚才说的那句话"
// pingErrorTransport 包一层传输:把客户端的 `ping` 拦下来,直接用一条**带哨兵文本的
// JSON-RPC error** 回答它(不转发给真 server),从而稳定复现「对端 free text 进
// keepalive 日志」这条路径。
//
// 为什么要在传输层拦 ping:SDK server 的 ping 应答是内置的(总回空 result),无法让
// 它回错误;而 ping 失败正是客户端侧唯一一处「把对端 error 原样交给 logger」的地方。
type pingErrorTransport struct {
inner mcpsdk.Transport
}
func (t *pingErrorTransport) Connect(ctx context.Context) (mcpsdk.Connection, error) {
conn, err := t.inner.Connect(ctx)
if err != nil {
return nil, err
}
return newPingErrorConn(conn), nil
}
// pingErrorConn 把内层连接的消息泵到一个自己的 channel,好让拦下的 ping 能**插队**
// 回一条错误响应(直接在 Read 里塞响应不行:读循环已经阻塞在内层 Read 上)。
type pingErrorConn struct {
inner mcpsdk.Connection
msgs chan sdkjsonrpc.Message
done chan struct{}
once sync.Once
}
func newPingErrorConn(inner mcpsdk.Connection) *pingErrorConn {
c := &pingErrorConn{
inner: inner,
msgs: make(chan sdkjsonrpc.Message, 16),
done: make(chan struct{}),
}
go c.pump()
return c
}
// pump 把内层消息搬进 msgs;连接结束就关掉 msgs(读侧据此如实报错)。
func (c *pingErrorConn) pump() {
defer close(c.msgs)
for {
msg, err := c.inner.Read(context.Background())
if err != nil {
return
}
select {
case c.msgs <- msg:
case <-c.done:
return
}
}
}
func (c *pingErrorConn) Write(ctx context.Context, msg sdkjsonrpc.Message) error {
req, ok := msg.(*sdkjsonrpc.Request)
if !ok || req.Method != "ping" {
return c.inner.Write(ctx, msg)
}
select {
case c.msgs <- &sdkjsonrpc.Response{
ID: req.ID,
Error: &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: sdkLogSentinel},
}:
return nil
case <-c.done:
return context.Canceled
}
}
func (c *pingErrorConn) Read(ctx context.Context) (sdkjsonrpc.Message, error) {
select {
case msg, ok := <-c.msgs:
if !ok {
return nil, context.Canceled
}
return msg, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (c *pingErrorConn) Close() error {
c.once.Do(func() { close(c.done) })
return c.inner.Close()
}
// SessionID 满足 mcpsdk.Connection(SDK 把它当作传输的身份标签,探针原样透传)。
func (c *pingErrorConn) SessionID() string { return c.inner.SessionID() }
// TestSDKLogger_ToolCallErrorIsNotLogged 证明探针不是空转:tools/call 的 JSON-RPC
// 错误**确实**会被我们的(redacted)日志器看到 —— 只是 SDK 并不记录它。
func TestSDKLogger_ToolCallErrorIsNotLogged(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
c := New(MCPConfig{Name: "fake"},
WithTransport(startFakeServer(t, sentinelServer())),
WithLogger(log))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
defer func() { _ = c.Close() }()
if _, err := c.CallTool(context.Background(), "boom", map[string]any{}); err == nil {
t.Fatal("server 返回协议错误时 CallTool 应当报错")
}
if got := buf.String(); strings.Contains(got, errSentinel) {
t.Fatalf("tools/call 路径的 SDK 日志里出现哨兵(原先认为它不打日志,结论要改):\n%s", got)
}
t.Logf("tools/call 路径 SDK 日志(预期不含哨兵):\n%s", buf.String())
}
// TestSDKLogger_KeepaliveErrorTextStaysOut 是探针的主断言:keepalive 被对端用
// JSON-RPC error 拒绝时,SDK 会把 err 原样打进 WARN —— 哨兵绝不能出现在日志里。
func TestSDKLogger_KeepaliveErrorTextStaysOut(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
tr := &pingErrorTransport{inner: startFakeServer(t, newFakeServer())}
c := New(MCPConfig{Name: "fake"},
WithTransport(tr),
// 固定到 keepalive 生效的旧协议版本(同 keepalive_test.go 的口径)。
WithProtocolVersion("2025-11-25"),
WithKeepAlive(20*time.Millisecond, 1),
WithLogger(log))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
defer func() { _ = c.Close() }()
waitFor(t, 5*time.Second, "keepalive 报出 ping 失败", func() bool {
return strings.Contains(buf.String(), "keepalive ping failed")
})
logged := buf.String()
// 探针本身要可证伪:日志里确实有那条 keepalive 记录,否则「没哨兵」是空转。
if !strings.Contains(logged, "keepalive ping failed") {
t.Fatalf("探针没触发 keepalive 日志,结论不可用:\n%s", logged)
}
if strings.Contains(logged, sdkLogSentinel) {
t.Fatalf("SDK 的 keepalive 日志把对端自由文本原样打出来了:\n%s", logged)
}
t.Logf("keepalive 路径 SDK 日志(预期含记录、不含哨兵):\n%s", logged)
}
+59
View File
@@ -0,0 +1,59 @@
package mcp
// 本文件钉住 sdklog.go 的两条分支(线上错误 / 本地错误),sdklog_probe_test.go 则
// 用真 server 证明这条日志面确实走过收敛。
//
// 变异判据:把 scrubSDKError 的分支判据摘掉(一律保留原文)⇒ 第一条子用例红;
// 反过来「一律只留骨架」⇒ 第二、三条子用例红(运维会丢掉「为什么 session 被关掉」)。
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"testing"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
// TestScrubSDKError_SplitsWireFromLocal 是分类判据的正面证据。
func TestScrubSDKError_SplitsWireFromLocal(t *testing.T) {
t.Parallel()
// 1. 线上错误(链上有 *jsonrpc.Error):自由文本一个字节都不留,只留码与长度。
wire := fmt.Errorf("calling %q: %w", "ping",
&sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: "SENTINEL-MCPLOG 她刚才说的话"})
got := scrubSDKError(wire)
if strings.Contains(got, "SENTINEL-MCPLOG") {
t.Fatalf("线上错误的自由文本进了日志:%q", got)
}
if !strings.Contains(got, "code=-32603") {
t.Fatalf("线上错误的协议码被丢掉了:%q", got)
}
// 2. 本地错误:原因必须保留(运维靠它判断失败性质)。
if got := scrubSDKError(context.DeadlineExceeded); !strings.Contains(got, "deadline exceeded") {
t.Fatalf("本地错误的原因被吞掉了:%q", got)
}
// 3. 本地错误里的凭据形状仍要打码,换行仍要压平。
leaky := errors.New("dial failed: Authorization: Bearer sk-live-0123456789abcdef\nsecond line")
got = scrubSDKError(leaky)
if strings.Contains(got, "sk-live") {
t.Fatalf("本地错误里的凭据没被打码:%q", got)
}
if strings.Contains(got, "\n") || !strings.Contains(got, "second line") {
t.Fatalf("本地错误的换行没被压平(或压平把内容吞了):%q", got)
}
}
// TestScrubSDKAttr_KeepsScalars 钉住「只收敛自由文本面」:数字/布尔/时间是体积与
// 状态,不该被字符串化或截断。
func TestScrubSDKAttr_KeepsScalars(t *testing.T) {
t.Parallel()
if got := scrubSDKAttr(slog.Int("consecutiveFailures", 3)); got.Value.Kind().String() != "Int64" {
t.Fatalf("数字属性被改成了 %s(应保持数字)", got.Value.Kind())
}
}
+56
View File
@@ -0,0 +1,56 @@
package mcp
// 本文件是 MCP 客户端 registry 的**状态读面**(切片 M20:MCP 服务器的管理页)。
//
// 为什么放在 mcp 包而不是插件管理器:工具名与描述的唯一真源是**已注册进能力注册表
// 的那些条目**(`Register`/`refresh` 写进去、`Unregister` 摘掉的正是它们)。管理器只
// 知道「这个 server 活没活」,它没有工具描述;让它自己去猜工具清单就会出现「面板上的
// 工具列表」与「模型真正能调的工具」两份事实。这里只读,不改任何注册/刷新逻辑。
// ToolSummary 是一把工具在状态快照里的形状:名字 + 截断后的描述。
//
// 描述必然要截断:MCP 服务器的描述可以是一整段 Markdown(实测有几百字的),而状态页是
// 给人扫一眼的排障面,把原文全塞进表格只会把有用的列挤没。
type ToolSummary struct {
// Name 是工具名(模型调用时用的标识,不截断:它是排障时要对的那个字符串)。
Name string `json:"name"`
// Description 是截断后的描述(原始描述见 mcp 客户端的 tools/list)。
Description string `json:"description,omitempty"`
}
// ToolSummaries 返回本 registry 当前注册的工具摘要(按注册顺序)。
//
// 「当前注册」= `ToolNames` 里还活着的名字;被 `refresh` 摘掉的工具不会留下影子。
// descriptionLimit ≤ 0 表示不截断。描述取自能力注册表的条目:**注册失败的工具不在
// 其中**,因此清单与模型可见的工具集合同源。
func (r *Registry) ToolSummaries(descriptionLimit int) []ToolSummary {
if r == nil {
return nil
}
names := r.ToolNames()
out := make([]ToolSummary, 0, len(names))
for _, name := range names {
desc := ""
if r.reg != nil {
if cap, err := r.reg.Get(name); err == nil {
desc = cap.Description
}
}
out = append(out, ToolSummary{Name: name, Description: TruncateRunes(desc, descriptionLimit)})
}
return out
}
// TruncateRunes 把 s 截到最多 limit 个字符(按 rune 计,不切坏多字节字符);被截断时
// 末尾补一个省略号,让「这里还有」在界面上可见(静默截断会被读成「原文就这么长」)。
// limit ≤ 0 表示不截断。
func TruncateRunes(s string, limit int) string {
if limit <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= limit {
return s
}
return string(runes[:limit]) + "…"
}
+13
View File
@@ -7,6 +7,8 @@ import (
"strings" "strings"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
) )
// ToolInfo 是 MCP 服务器暴露的一把工具。 // ToolInfo 是 MCP 服务器暴露的一把工具。
@@ -60,6 +62,17 @@ func (t *mcpTool) Name() string { return t.info.Name }
// Description 返回 MCP 工具描述。 // Description 返回 MCP 工具描述。
func (t *mcpTool) Description() string { return t.info.Description } func (t *mcpTool) Description() string { return t.info.Description }
// Origin 回报这把工具的上游来源(DevDocs/audit/MCP工具按上游分组-2026-10-24.md):
// server 名与传输形态都在手上的 `client.cfg` 里,因此归属是**注册时的事实**,
// 不靠工具名推断。宿主在 registry.RegisterTool 处读到它并盖在能力条目上。
func (t *mcpTool) Origin() sdk.Origin {
return sdk.Origin{
Kind: sdk.OriginKindMCP,
Server: t.client.cfg.Name,
Transport: transportNameOf(t.client.cfg),
}
}
// Parameters 返回 MCP input schema;服务器未给 schema 时返回无约束空对象 schema。 // Parameters 返回 MCP input schema;服务器未给 schema 时返回无约束空对象 schema。
func (t *mcpTool) Parameters() (map[string]any, error) { func (t *mcpTool) Parameters() (map[string]any, error) {
if t.info.InputSchema == nil { if t.info.InputSchema == nil {
+24
View File
@@ -33,6 +33,30 @@ func newTransport(cfg MCPConfig) (mcpsdk.Transport, error) {
} }
} }
// transportNameOf 返回 cfg **实际会用到**的传输名(stdio / sse / http),与
// newTransport 的推断同源:显式 TransportType 优先(http / streamable 归一为 http),
// 为空则「有 Command 走 stdio,有 URL 走 sse」。两者都没有时返回空串 —— 配置不成立,
// 上游来源里该键缺席,而不是编一个 stdio。
//
// 它服务于能力条目的来源章(mcpTool.Origin):面板要能写「来自 fs(stdio)」,
// 而 cfg.TransportType 常常留空(由推断决定),因此不能直接下发原始配置值。
func transportNameOf(cfg MCPConfig) string {
switch cfg.TransportType {
case "http", "streamable":
return "http"
case "sse", "stdio":
return cfg.TransportType
}
switch {
case cfg.Command != "":
return "stdio"
case cfg.URL != "":
return "sse"
default:
return ""
}
}
// httpURLTransport 构建 URL 型传输:streamable=true 走 Streamable HTTP(2025-11-25 // httpURLTransport 构建 URL 型传输:streamable=true 走 Streamable HTTP(2025-11-25
// 规范首选),否则走旧式 SSE。 // 规范首选),否则走旧式 SSE。
func httpURLTransport(cfg MCPConfig, streamable bool) (mcpsdk.Transport, error) { func httpURLTransport(cfg MCPConfig, streamable bool) (mcpsdk.Transport, error) {
+195
View File
@@ -0,0 +1,195 @@
package plugin
// 本文件是「按 server 独立启停」的落点(切片 M20):启用态**持久化**在 plugins.json 的
// `enabled` 字段上,因此停用在重启后仍然有效;热切换的语义是「停用 = 摘掉工具,启用 =
// 连上并重新发现」,两条路都经插件管理器(design/09 §5.2.1「插件管理器 = 唯一中枢」)。
//
// 为什么这些函数从 manager.go 搬出来:manager.go 已接近「文件 ≤500 行」的上限,而启停
// 生命周期本身就有四件事——落盘、内存态、面板入口、运行期观察——挤在一起会既超行数又
// 说不清顺序。顺序在这里写死:**先落盘、再动活 server、最后改面板入口**;落盘失败即
// 原样返回错误,绝不出现「面板说停用了、重启又连上」。
//
// 刻意不做的事:
// - 停用**不动** plugins.json 的条目(停用 ≠ 卸载,卸载才删配置);
// - 启用的失败**不改**已落盘的 enabled(失败仍禁用,如实报错,不假装开着)。
import (
"context"
"errors"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// ErrPluginNotFound 报告指定的插件既没活着也不在 plugins.json 里。
var ErrPluginNotFound = errors.New("plugin: not found")
// ErrPluginStatePersist 报告「启用态没写成」:内存/连接态可能已经变了,但 plugins.json
// 还是旧值 —— 因此**重启后会退回旧状态**。
//
// 它与「连不上」是两件相反的事,调用方必须能分开(文案说反就是骗人):连不上 ⇒ server
// 现在不可用;落盘失败 ⇒ server 可能正连着、工具可用,只是这个事实活不过一次重启。
var ErrPluginStatePersist = errors.New("plugin: enabled state not persisted")
// notFound 构造一条带名字的「找不到」。
func notFound(name string) error {
if name == "" {
return fmt.Errorf("%w: empty name", ErrPluginNotFound)
}
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
}
// SetEnabled 启用或停用一台已安装的 server。
//
// 语义(design/09 §5.2.1 生命周期一栏):
// - 停用:落盘 `enabled:false` → 关掉活的 server(其工具随之下线,模型看不见)→ 隐藏
// 它的管理面板入口(`when:{enabled:true}` 的入口不再出现)。配置保留,重启后仍是停用。
// - 启用:按 plugins.json 的配置重新连接 + 发现 + 注册 → 入口回来 → 落盘 `enabled:true`。
// **连接失败时返回值即错误,且落盘的 enabled 保持 false**(不假装开着)。
//
// 两条路都会推一次 `panels_changed`(停用推 disable、启用推 enable)。
// 找不到该 server 时返回 ErrPluginNotFound。
func (m *Manager) SetEnabled(ctx context.Context, name string, on bool) error {
name = strings.TrimSpace(name)
if name == "" {
return notFound("")
}
if !on {
return m.disable(ctx, name)
}
return m.enable(ctx, name)
}
// Reconnect 断开并重新连接一台已启用的 server(重新发现工具,重新注册)。
//
// 它与「先停用再启用」的差别只在面板帧的原因标签与「已停用时拒绝」:重连不改变启用态,
// 所以它既不写 enabled,也不该替用户启用一个被停用的 server(那会让「为什么它忽然活了」
// 变成一个查不出的谜)。
func (m *Manager) Reconnect(ctx context.Context, name string) (Installed, error) {
name = strings.TrimSpace(name)
cfg, ok := m.persistedConfig(name)
if !ok {
if srv := m.liveServer(name); srv != nil {
cfg, ok = srv.cfg, true
}
}
if !ok {
return Installed{}, notFound(name)
}
if !cfg.IsEnabled() {
return Installed{}, fmt.Errorf("%w: %s", ErrPluginDisabled, name)
}
return m.install(ctx, cfg, false, PanelChangeInstalled)
}
// disable 停用一台 server:落盘 → 下线 → 隐藏入口。
//
// 落盘在**前面**:先写文件的好处是「写不进去时 server 还在跑」,而不是先摘掉工具再
// 报一个落盘错误(那样内存与文件分叉,用户看到的与重启后得到的不是一回事)。
func (m *Manager) disable(ctx context.Context, name string) error {
srv := m.liveServer(name)
cfg, persisted := m.persistedConfig(name)
if srv == nil && !persisted {
return notFound(name)
}
if !persisted {
// 文件里没有这条,但 server 活着(手工塞进来的):用活 server 的配置补一条,
// 否则「停用」重启后会变成「不存在」。
cfg = srv.cfg
}
if err := m.persistEnabled(cfg, false); err != nil {
return fmt.Errorf("plugin %q: %w", name, err)
}
if srv != nil {
m.take(srv.cfg.Name)
m.stop(ctx, srv)
}
m.noteDisabled(cfg)
m.setPanelsEnabled(cfg.Name, false)
m.log.Info("plugin disabled", "name", cfg.Name)
return nil
}
// enable 重新启用一台已安装的 server:按 plugins.json 的配置重新连接。
// 已经活着的 server 是幂等空操作(只把文件里的 enabled 纠正为 true,不推多余帧)。
func (m *Manager) enable(ctx context.Context, name string) error {
cfg, ok := m.persistedConfig(name)
if !ok {
if srv := m.liveServer(name); srv != nil {
return m.persistEnabled(srv.cfg, true)
}
return notFound(name)
}
if srv := m.liveServer(name); srv != nil {
if cfg.IsEnabled() {
return nil
}
return m.persistEnabled(cfg, true)
}
// 只有连上才落 enabled=true:连接/发现失败时文件保持停用,返回值就是那条失败。
res, err := m.install(ctx, cfg.WithEnabled(true), false, PanelChangeEnabled)
if err != nil {
return err
}
return m.persistEnabled(res.Config, true)
}
// persistEnabled 把启用态写回 plugins.json(upsert by name,其余字段原样保留)。
//
// 它**不**碰内存里的活 server:调用方按自己的顺序决定何时连线/断线。
// 失败用 ErrPluginStatePersist 包起来:调用方要能把它与「连不上」区分开 —— 这两个失败
// 的事实相反(一个没连上、一个可能已经连上而磁盘没记住)。
func (m *Manager) persistEnabled(cfg runtimeconfig.PluginConfig, on bool) error {
if err := m.persistUpsert(cfg.WithEnabled(on)); err != nil {
return fmt.Errorf("%w: 插件 %q 的启用态未落盘: %w", ErrPluginStatePersist, cfg.Name, err)
}
return nil
}
// liveServer 返回活着的 server(按名字或 id 匹配,无则 nil)。
func (m *Manager) liveServer(name string) *server {
m.mu.Lock()
defer m.mu.Unlock()
if srv := m.servers[name]; srv != nil {
return srv
}
for _, srv := range m.servers {
if srv.cfg.ID != "" && srv.cfg.ID == name {
return srv
}
}
return nil
}
// takeAny 按名字或 id 摘掉活着的 server(take 只认精确的名字键)。
func (m *Manager) takeAny(name string) *server {
if srv := m.take(name); srv != nil {
return srv
}
m.mu.Lock()
defer m.mu.Unlock()
for key, srv := range m.servers {
if srv.cfg.ID != "" && srv.cfg.ID == name {
delete(m.servers, key)
return srv
}
}
return nil
}
// persistedConfig 按名(或 id)在 plugins.json 里找一条配置。文件读不出来时按
// 「没有这条」处理并记日志:停用/启用不该因为一个坏文件而变成 500。
func (m *Manager) persistedConfig(name string) (runtimeconfig.PluginConfig, bool) {
cfgs, err := runtimeconfig.LoadPlugins(m.path)
if err != nil {
m.log.Warn("plugins.json load failed", "path", m.path, "error", err)
return runtimeconfig.PluginConfig{}, false
}
for _, cfg := range cfgs {
if cfg.Name == name || (cfg.ID != "" && cfg.ID == name) {
return cfg, true
}
}
return runtimeconfig.PluginConfig{}, false
}
+223
View File
@@ -0,0 +1,223 @@
package plugin
// 本文件锁定「按 server 独立启停」的三条硬要求(切片 M20):
// ① 热切换:停用后工具**真的**从注册表消失(模型看不见它们),启用后回来;
// ② 持久化:`enabled:false` 落进 plugins.json,重启(Restore)后仍是停用 —— 而且
// **一次都不去连**(不是「连了一下再判定停用」);
// ③ 失败如实:启用失败时不假装开着,落盘的 enabled 保持 false,快照给 lastError。
import (
"context"
"errors"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// newEnabledManager 构造一台连到假 MCP server 的已安装插件,返回管理器与它的注册表。
func newEnabledManager(t *testing.T, path string, endpoint string, tools ...string) (*Manager, *registry.Registry) {
t.Helper()
reg := registry.New()
mgr := NewManager(reg, path, nil)
t.Cleanup(func() { mgr.Close(context.Background()) })
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: "calc", Transport: runtimeconfig.TransportHTTP, Endpoint: endpoint,
}); err != nil {
t.Fatalf("Install: %v", err)
}
return mgr, reg
}
// TestDisableUnregistersToolsAndPersists 锁定 ①+② 的一半:停用即摘工具、写 enabled:false。
func TestDisableUnregistersToolsAndPersists(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add", "mul")
mgr, reg := newEnabledManager(t, path, srv.URL)
if names := toolNames(reg); !names["add"] || !names["mul"] {
t.Fatalf("安装后工具 = %v,期望 add+mul", names)
}
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
// 工具必须真的不在注册表里:模型的面就是这样来的。
if names := toolNames(reg); len(names) != 0 {
t.Fatalf("停用后工具仍可被模型看到 = %v", names)
}
if _, err := reg.Get("mcp:calc"); err == nil {
t.Fatal("停用后 mcp:calc 能力仍在")
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if len(cfgs) != 1 || cfgs[0].IsEnabled() {
t.Fatalf("落盘配置 = %+v,期望仅一条且已停用", cfgs)
}
if cfgs[0].Enabled == nil {
t.Fatal("停用必须写显式的 enabled=false(缺席=启用,无法表达停用)")
}
st := statusOfOne(t, mgr, "calc")
if st.State != ServerStateDisabled || st.Connected == nil || *st.Connected {
t.Fatalf("停用后状态 = %+v,期望 disabled/connected=false", st)
}
if st.ToolCount != nil {
t.Fatalf("停用的 server 不该报工具数(不知道它有什么),得到 %d", *st.ToolCount)
}
}
// TestEnableBringsToolsBack 锁定 ①的另一半:启用重新连接并重新注册。
func TestEnableBringsToolsBack(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add", "mul")
mgr, reg := newEnabledManager(t, path, srv.URL)
ctx := context.Background()
if err := mgr.SetEnabled(ctx, "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
if err := mgr.SetEnabled(ctx, "calc", true); err != nil {
t.Fatalf("SetEnabled(true): %v", err)
}
if names := toolNames(reg); !names["add"] || !names["mul"] {
t.Fatalf("启用后工具 = %v,期望 add+mul 回来", names)
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if len(cfgs) != 1 || !cfgs[0].IsEnabled() {
t.Fatalf("启用后落盘 = %+v,期望 enabled=true", cfgs)
}
st := statusOfOne(t, mgr, "calc")
if st.State != ServerStateConnected || st.ToolCount == nil || *st.ToolCount != 2 {
t.Fatalf("启用后状态 = %+v,期望 connected 且 2 把工具", st)
}
if len(st.Tools) != 2 {
t.Fatalf("工具明细 = %+v,期望 2 条", st.Tools)
}
}
// TestRestoreHonoursDisabledFlag 锁定 ②:重启后停用仍然是停用,而且**一次都不连**。
func TestRestoreHonoursDisabledFlag(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
mgr, _ := newEnabledManager(t, path, srv.URL)
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
mgr.Close(context.Background())
before := srv.Config.Handler.(*fakeMCP).hitCount()
reg2 := registry.New()
mgr2 := NewManager(reg2, path, nil)
defer mgr2.Close(context.Background())
mgr2.Restore(context.Background())
if names := toolNames(reg2); len(names) != 0 {
t.Fatalf("重启后停用的 server 仍注册了工具 = %v", names)
}
if after := srv.Config.Handler.(*fakeMCP).hitCount(); after != before {
t.Fatalf("重启期对停用的 server 发起了连接:hits %d -> %d", before, after)
}
st := statusOfOne(t, mgr2, "calc")
if st.State != ServerStateDisabled || st.Enabled {
t.Fatalf("重启后状态 = %+v,期望仍是 disabled", st)
}
}
// TestInstallDisabledDoesNotConnect 锁定 Install 也尊重 enabled:装上但停用 = 落盘且不连。
func TestInstallDisabledDoesNotConnect(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
reg := registry.New()
mgr := NewManager(reg, path, nil)
defer mgr.Close(context.Background())
got, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: "calc", Endpoint: srv.URL, Enabled: enabledFlag(false),
})
if err != nil {
t.Fatalf("Install(disabled): %v", err)
}
if got.Live {
t.Fatalf("停用安装不该有活 server:%+v", got)
}
if hits := srv.Config.Handler.(*fakeMCP).hitCount(); hits != 0 {
t.Fatalf("停用安装发起了 %d 次连接", hits)
}
if names := toolNames(reg); len(names) != 0 {
t.Fatalf("停用安装注册了工具 = %v", names)
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil || len(cfgs) != 1 || cfgs[0].IsEnabled() {
t.Fatalf("落盘 = %+v(err=%v),期望一条停用配置", cfgs, err)
}
}
// TestEnableFailureKeepsDisabled 锁定 ③:启用失败 ⇒ 返回错误 + 落盘仍是停用 + 快照给 lastError。
func TestEnableFailureKeepsDisabled(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
mgr, _ := newEnabledManager(t, path, srv.URL)
ctx := context.Background()
if err := mgr.SetEnabled(ctx, "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
// 让 server 连不上:关掉进程内的假 server。
srv.Close()
if err := mgr.SetEnabled(ctx, "calc", true); err == nil {
t.Fatal("启用一台连不上的 server 应当报错")
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil || len(cfgs) != 1 {
t.Fatalf("落盘 = %+v(err=%v)", cfgs, err)
}
if cfgs[0].IsEnabled() {
t.Fatalf("启用失败却把 enabled 写成了 true:%+v", cfgs[0])
}
// 状态仍是 disabled(启用没成功,它就还是停用态),而 lastError 说清了这次失败 ——
// 「有意停用」与「想启用但失败」由这两列分别表达,不互相冒充。
st := statusOfOne(t, mgr, "calc")
if st.State != ServerStateDisabled || st.Enabled {
t.Fatalf("启用失败后状态 = %q/enabled=%v,期望 disabled/false", st.State, st.Enabled)
}
if st.Connected == nil || *st.Connected {
t.Fatalf("启用失败后 connected = %v,期望 false(确认没连)", st.Connected)
}
if st.LastError == "" {
t.Fatal("启用失败后 lastError 为空:面板说不出为什么挂了")
}
if st.LastErrorAt == "" {
t.Fatal("启用失败后 lastErrorAt 为空")
}
}
// TestReconnectRejectsDisabled 锁定:重连不替用户启用(那会让「它为什么忽然活了」变成谜)。
func TestReconnectRejectsDisabled(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
mgr, _ := newEnabledManager(t, path, srv.URL)
ctx := context.Background()
if err := mgr.SetEnabled(ctx, "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
if _, err := mgr.Reconnect(ctx, "calc"); !errors.Is(err, ErrPluginDisabled) {
t.Fatalf("对停用的 server 重连 = %v,期望 ErrPluginDisabled", err)
}
if _, err := mgr.Reconnect(ctx, "ghost"); !errors.Is(err, ErrPluginNotFound) {
t.Fatalf("重连不存在的 server = %v,期望 ErrPluginNotFound", err)
}
}
// enabledFlag 是 `&false` 的简写(PluginConfig.Enabled 是三态指针)。
func enabledFlag(v bool) *bool { return &v }
+50 -108
View File
@@ -33,6 +33,19 @@ const (
mcpCapabilityPrefix = "mcp:" mcpCapabilityPrefix = "mcp:"
) )
// MCPServerName 从 `mcp:<server>` 登记条目的注册键里取出 server 名。
// 第二个返回值为 false 表示这不是一条 KindMCP 登记条目的键(或者 server 名为空)。
//
// 它是**注册键格式**的唯一真相(`mcpCapabilityPrefix` 只在这里出现),因此网关在需要
// 「这条登记条目是哪台 server」时应当调它,而不是自己在别处再拼一遍前缀。
func MCPServerName(capName string) (string, bool) {
server, ok := strings.CutPrefix(capName, mcpCapabilityPrefix)
if !ok || server == "" {
return "", false
}
return server, true
}
// Installed is one plugin as reported to the admin API: its persisted config, // Installed is one plugin as reported to the admin API: its persisted config,
// whether a live server currently backs it, and how many tools it registered. // whether a live server currently backs it, and how many tools it registered.
type Installed struct { type Installed struct {
@@ -61,6 +74,10 @@ type Manager struct {
mu sync.Mutex mu sync.Mutex
servers map[string]*server servers map[string]*server
// observed 是每台 server 最近一次被观察到的运行期事实(成功连上的时刻、最近一次失败
// 的文本与时刻)。它与 servers(活没活)是两个不同的事实:一台连不上的 server 不在
// servers 里,但它的失败记录必须留着 —— 否则「为什么挂了」在快照里就是一个空。
observed map[string]observed
// panelKeys 记住每个已安装插件**当时用过的**面板目录键(插件名 → 目录键)。 // panelKeys 记住每个已安装插件**当时用过的**面板目录键(插件名 → 目录键)。
// 它是 memo 而不是第二份真源:目录键由 PanelPluginID 从 cfg 推出,这里只把 // 它是 memo 而不是第二份真源:目录键由 PanelPluginID 从 cfg 推出,这里只把
// 结果留下,好让 Uninstall 在活 server 已不在时仍能精确清掉该插件的面板。 // 结果留下,好让 Uninstall 在活 server 已不在时仍能精确清掉该插件的面板。
@@ -80,6 +97,7 @@ func NewManager(reg *registry.Registry, path string, log *slog.Logger) *Manager
path: path, path: path,
log: log, log: log,
servers: make(map[string]*server), servers: make(map[string]*server),
observed: make(map[string]observed),
panelKeys: make(map[string]string), panelKeys: make(map[string]string),
} }
} }
@@ -116,6 +134,11 @@ func (m *Manager) Install(ctx context.Context, cfg runtimeconfig.PluginConfig) (
// install is Install with the persist step optional (Restore reuses it without // install is Install with the persist step optional (Restore reuses it without
// rewriting the file it just read). changeReason labels the panel change pushed // rewriting the file it just read). changeReason labels the panel change pushed
// when the plugin becomes live (install vs enable). // when the plugin becomes live (install vs enable).
//
// It respects cfg.Enabled: a config that says enabled=false is **persisted but not
// connected** (no live server, no tools), which is how "installed but switched off"
// survives a restart. Restore goes through this same path, so startup honours the
// flag too.
func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, persist bool, changeReason string) (Installed, error) { func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, persist bool, changeReason string) (Installed, error) {
cfg = cfg.Normalize() cfg = cfg.Normalize()
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
@@ -129,8 +152,13 @@ func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, p
if prev != nil { if prev != nil {
m.stop(ctx, prev) m.stop(ctx, prev)
} }
if !cfg.IsEnabled() {
return m.installDisabled(cfg, persist, prev)
}
srv, err := m.start(ctx, cfg) srv, err := m.start(ctx, cfg)
if err != nil { if err != nil {
// 失败也要留痕:状态快照要能说出「为什么挂了」,而不是把失败悄悄丢进日志。
m.noteFailure(cfg, err)
m.restore(prev) m.restore(prev)
return Installed{}, err return Installed{}, err
} }
@@ -142,11 +170,30 @@ func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, p
} }
} }
m.put(srv) m.put(srv)
m.noteSeen(cfg)
m.log.Info("plugin installed", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport, "tools", srv.toolCount) m.log.Info("plugin installed", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport, "tools", srv.toolCount)
m.markInstalled(cfg, changeReason) m.markInstalled(cfg, changeReason)
return Installed{Config: cfg, Live: true, ToolCount: srv.toolCount}, nil return Installed{Config: cfg, Live: true, ToolCount: srv.toolCount}, nil
} }
// installDisabled 走「已安装但停用」这条路:只落配置,不连接、不发现、不注册任何工具,
// 并把面板入口隐藏(`when:{enabled:true}` 的入口不出现)。
//
// 调用前 prev(同名活 server)已经被停掉:配置现在说的是「停用」,让旧 server 继续挂着
// 就成了两份互相矛盾的事实。落盘失败时把 prev 恢复回来(回滚到「启用且活着」那个状态)。
func (m *Manager) installDisabled(cfg runtimeconfig.PluginConfig, persist bool, prev *server) (Installed, error) {
if persist {
if err := m.persistUpsert(cfg); err != nil {
m.restore(prev)
return Installed{}, err
}
}
m.noteDisabled(cfg)
m.markDisabled(cfg)
m.log.Info("plugin installed disabled", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport)
return Installed{Config: cfg}, nil
}
// Uninstall removes the plugin matching name: it unregisters the live server's // Uninstall removes the plugin matching name: it unregisters the live server's
// tools and closes it, then deletes the persisted config. It reports whether // tools and closes it, then deletes the persisted config. It reports whether
// anything matched, so the caller can fall back to a plain capability removal. // anything matched, so the caller can fall back to a plain capability removal.
@@ -166,6 +213,7 @@ func (m *Manager) Uninstall(ctx context.Context, name string) (bool, error) {
panelName = srv.cfg.Name panelName = srv.cfg.Name
} }
m.forgetPanels(panelName) m.forgetPanels(panelName)
m.forgetObserved(panelName)
m.log.Info("plugin uninstalled", "name", name) m.log.Info("plugin uninstalled", "name", name)
} }
return removed, nil return removed, nil
@@ -231,114 +279,6 @@ func (m *Manager) Close(ctx context.Context) {
} }
} }
// SetEnabled 启用或停用一个已安装插件。
//
// 语义(§5.2.1 生命周期一栏):停用会关闭活的 server(其工具随之下线)并隐藏它的
// 管理面板入口(`when: {enabled:true}` 的入口不再出现),**配置保留在 plugins.json**
// —— 因此重启后它仍然「已安装」,只是要不要连着由这次调用决定;启用则重新连接并
// 让入口回来。两条路都会推一次 `panels_changed`。
//
// ⚠️ 已知缺口(本片如实登记):启用状态本身**目前不持久化**(PluginConfig 没有该
// 字段,给 plugins.json 加字段属 runtimeconfig 面,不在本片文件范围内),因此
// **重启后停用态丢失、按已安装恢复为启用**。需要跨重启保持就寝语义时,先给
// plugins.json 加 `enabled` 字段(并让 Restore 读它)。
//
// 找不到该插件(既不在活 server 里也不在 plugins.json 里)时返回 ErrPluginNotFound。
func (m *Manager) SetEnabled(ctx context.Context, name string, on bool) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("%w: empty name", ErrPluginNotFound)
}
if !on {
return m.disable(ctx, name)
}
return m.enable(ctx, name)
}
// ErrPluginNotFound 报告 SetEnabled 指定的插件既没活着也不在 plugins.json 里。
var ErrPluginNotFound = errors.New("plugin: not found")
// disable 停用一个插件:摘掉活的 server,保留配置,并隐藏面板入口。
//
// 它刻意**不**动 plugins.json:「停用」不是「卸载」,卸载才是删配置
// (design/09 §5.2.1 把 install/uninstall 与 enable/disable 并列,说明两者不同)。
func (m *Manager) disable(ctx context.Context, name string) error {
srv := m.takeAny(name)
_, persisted := m.persistedConfig(name)
if srv == nil && !persisted {
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
}
if srv != nil {
m.stop(ctx, srv)
}
m.setPanelsEnabled(name, false)
m.log.Info("plugin disabled", "name", name)
return nil
}
// enable 重新启用一个已安装插件:按 plugins.json 里的配置重新连接。
// 已经活着的插件是幂等空操作(面板集合没变 ⇒ 不推帧)。
func (m *Manager) enable(ctx context.Context, name string) error {
if m.liveServer(name) != nil {
return nil
}
cfg, ok := m.persistedConfig(name)
if !ok {
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
}
if _, err := m.install(ctx, cfg, false, PanelChangeEnabled); err != nil {
return err
}
return nil
}
// liveServer 返回活着的 server(按名字或 id 匹配,无则 nil)。
func (m *Manager) liveServer(name string) *server {
m.mu.Lock()
defer m.mu.Unlock()
if srv := m.servers[name]; srv != nil {
return srv
}
for _, srv := range m.servers {
if srv.cfg.ID != "" && srv.cfg.ID == name {
return srv
}
}
return nil
}
// takeAny 按名字或 id 摘掉活着的 server(take 只认精确的名字键)。
func (m *Manager) takeAny(name string) *server {
if srv := m.take(name); srv != nil {
return srv
}
m.mu.Lock()
defer m.mu.Unlock()
for key, srv := range m.servers {
if srv.cfg.ID != "" && srv.cfg.ID == name {
delete(m.servers, key)
return srv
}
}
return nil
}
// persistedConfig 按名(或 id)在 plugins.json 里找一条配置。文件读不出来时按
// 「没有这条」处理并记日志:停用/启用不该因为一个坏文件而变成 500。
func (m *Manager) persistedConfig(name string) (runtimeconfig.PluginConfig, bool) {
cfgs, err := runtimeconfig.LoadPlugins(m.path)
if err != nil {
m.log.Warn("plugins.json load failed", "path", m.path, "error", err)
return runtimeconfig.PluginConfig{}, false
}
for _, cfg := range cfgs {
if cfg.Name == name || (cfg.ID != "" && cfg.ID == name) {
return cfg, true
}
}
return runtimeconfig.PluginConfig{}, false
}
// start connects cfg, registers its tools and surfaces its KindMCP capability. // start connects cfg, registers its tools and surfaces its KindMCP capability.
// On a registration failure it cleans up and returns the error. // On a registration failure it cleans up and returns the error.
func (m *Manager) start(ctx context.Context, cfg runtimeconfig.PluginConfig) (*server, error) { func (m *Manager) start(ctx context.Context, cfg runtimeconfig.PluginConfig) (*server, error) {
@@ -389,6 +329,8 @@ func (m *Manager) restore(prev *server) {
return return
} }
m.put(srv) m.put(srv)
// 回滚成功也是一次成功连接:快照的 lastSeenAt 不该停留在这之前的时刻。
m.noteSeen(prev.cfg)
} }
// take removes and returns the live server named name, or nil. // take removes and returns the live server named name, or nil.
@@ -62,6 +62,15 @@ type fakeMCP struct {
mu sync.Mutex mu sync.Mutex
auth string auth string
hits int
}
// hitCount 返回该 server 收到的 POST 次数(「有没有真的连过」的证据:停用的 server
// 在重启后不该被连一次)。
func (f *fakeMCP) hitCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.hits
} }
func (f *fakeMCP) setTools(tools ...string) { func (f *fakeMCP) setTools(tools ...string) {
@@ -88,6 +97,7 @@ func (f *fakeMCP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
f.mu.Lock() f.mu.Lock()
f.auth = r.Header.Get("Authorization") f.auth = r.Header.Get("Authorization")
f.hits++
tools := append([]string(nil), f.tools...) tools := append([]string(nil), f.tools...)
f.mu.Unlock() f.mu.Unlock()
if f.wantAuth != "" && r.Header.Get("Authorization") != "Bearer "+f.wantAuth { if f.wantAuth != "" && r.Header.Get("Authorization") != "Bearer "+f.wantAuth {
+203
View File
@@ -0,0 +1,203 @@
package plugin
// 本文件是「MCP 服务器」管理页的**声明面**(design/09 §5.2 的 adminPanels,形状即
// `pkg/plugins/sdk` 的 Panel):清单表(状态列 + 行内动作)+ 安装表单。
//
// 为什么这页由插件管理器自己声明(而不是某个外部插件):它是**管理器自己的页面** ——
// 数据面就是 `*Manager` 的 `Statuses()`,动作面就是 `Install/Reconnect/SetEnabled/
// Uninstall`。把声明放到别处就要再导出一套「代管理器说话」的中转,两处必然分叉。
//
// 声明只说三件事:要什么字段、怎么渲染、动作叫什么。它不注入 JS/CSS/HTML、不给 URL
// (只有动作名)、不开任何路由 —— 分发走既有的通用动作端点
// `POST /api/v1/admin/plugins/{id}/actions/{action}`(写动作要求 owner/admin + 审计)。
//
// 与前端通用渲染器的核对结论(本片任务书要求先验,`frontend/web/src/admin/plugin/`):
// - 表格列只有 `key`/`label`(没有列类型/徽标)⇒ 状态列显示**闭集码**(与 web-search
// 页的 `state` 列同一口径),工具栏退化成一列截断文本;
// - 行内动作没有二次确认,且一律按**写**动作鉴权 ⇒ 卸载只能一键即发;
// - `args`(字符串列表)用 `multiSelect`、`env`(键值对)用 `kv`:这两项**能直接渲染**
// (无候选的 multiSelect 降级成逗号/换行分隔文本框,提交值仍是 `string[]`;
// kv 是 JSON 对象输入),因此本页不需要新增字段类型、也不需要改前端。
import (
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// MCPPanelPluginID 是「MCP 服务器」页在面板目录里的命名空间。
//
// 它是**宿主内置提供者**的 id,不是某个热装 MCP server 的名字。两者可能撞名(主人可以
// 把 server 也叫 mcp),因此热装插件的生命周期一律不许改动这个名字的目录条目
// (见 isReservedPanelKey)。
const MCPPanelPluginID = "mcp"
// MCPPanelID 是面板 id。前端路由为 `/admin/plugin/mcp/servers`。
const MCPPanelID = "servers"
// 本页声明的动作名。它们只是名字(不是 URL),分发端点是既有的
// `POST /api/v1/admin/plugins/mcp/actions/<name>`。
const (
// ActionListMCPServers 列出全部 server 的状态快照(表格数据源,读面)。
ActionListMCPServers = "plugin.mcpServers"
// ActionInstallMCPServer 安装(或刷新)一台 server(表单提交,写面)。
ActionInstallMCPServer = "plugin.installMcpServer"
// ActionReconnectMCPServer 断开重连并重新发现工具(行内动作,写面)。
ActionReconnectMCPServer = "plugin.reconnectMcpServer"
// ActionEnableMCPServer 启用一台被停用的 server(行内动作,写面)。
ActionEnableMCPServer = "plugin.enableMcpServer"
// ActionDisableMCPServer 停用一台 server(行内动作,写面)。
ActionDisableMCPServer = "plugin.disableMcpServer"
// ActionUninstallMCPServer 卸载一台 server(行内动作,写面)。
ActionUninstallMCPServer = "plugin.uninstallMcpServer"
// ActionMCPTransports 是传输形态的候选来源(读面):`optionsFrom` 为
// OptionsSourceTransports 时前端按此动作名取候选。
ActionMCPTransports = "plugin.mcpTransports"
)
// OptionsSourceTransports 是表单 `transport` 字段的候选来源(声明面写法 `plugin:` 前缀)。
const OptionsSourceTransports = "plugin:mcpTransports"
// 「MCP 服务器」表单与表格的字段 key。它们是**面板契约**的一部分(前端提交的键名),
// 因此集中在这里,处理器引用常量而不另写字面量。
const (
// FieldServerName 是 server 名(唯一标签,也是能力命名空间 `mcp:<name>`)。
FieldServerName = "name"
// FieldTransport 是传输形态(stdio / http / sse)。
FieldTransport = "transport"
// FieldCommand 是 stdio 的启动命令。
FieldCommand = "command"
// FieldArgs 是 stdio 的命令参数(字符串列表)。
FieldArgs = "args"
// FieldURL 是 http/sse 的服务地址(与运行时字段 endpoint 同义,面板用 url 更直白)。
FieldURL = "url"
// FieldAPIKey 是下发给 server 的 Bearer 凭据(secret:读侧永不回显)。
FieldAPIKey = "apiKey"
// FieldEnv 是 stdio 子进程的额外环境变量(键值对)。
FieldEnv = "env"
)
// MCPAdminPanels 返回「MCP 服务器」页的声明。
func MCPAdminPanels() []sdk.Panel {
return []sdk.Panel{{
ID: MCPPanelID,
Title: "MCP 服务器",
Description: "接入外部 MCP server 并管理它们的运行状态:每台可独立启停、重连、" +
"卸载,清单里直接看到连没连上、暴露了哪些工具、最近一次错在哪。" +
"配置落在 $DATA_DIR/plugins.json,装/卸/启停**立即生效**(下一轮对话即可用," +
"无需重启)。",
Group: sdk.GroupPlugins,
Order: 10,
Icon: "server",
// 内置提供者恒为启用;写条件是为了让「启用事实」由面板中枢给出(单一事实来源),
// 而不是由声明自己假设。
When: &sdk.When{Enabled: flagBool(true)},
Sections: []sdk.Section{
{
Type: sdk.SectionTable,
ID: "list",
Title: "已接入的服务器",
Source: &sdk.Source{Action: ActionListMCPServers},
// 列 key 与 ServerRow 的字段同源;`target` / `toolsText` 是给表格用的
// 显示派生量(地址或命令二选一、工具名拼一行),机器字段仍是
// `endpoint`/`command`/`tools[]`。
Columns: []sdk.Column{
{Key: "name", Label: "名称"},
{Key: "transport", Label: "传输"},
{Key: "target", Label: "地址 / 命令"},
{Key: "state", Label: "状态"},
{Key: "toolsText", Label: "工具"},
{Key: "lastError", Label: "最近一次错误"},
{Key: "lastSeenAt", Label: "最近连接"},
},
RowActions: []sdk.RowAction{
{ID: "reconnect", Label: "重连", Action: ActionReconnectMCPServer},
{ID: "enable", Label: "启用", Action: ActionEnableMCPServer},
{ID: "disable", Label: "停用", Action: ActionDisableMCPServer},
{ID: "uninstall", Label: "卸载", Action: ActionUninstallMCPServer},
},
},
{
Type: sdk.SectionForm,
ID: "install",
Title: "安装 / 刷新服务器",
Fields: []sdk.Field{
{Key: FieldServerName, Label: "名称", Type: sdk.FieldText, Required: true,
Placeholder: "如 fs / github;同名即刷新,能力命名空间为 mcp:<名称>"},
{Key: FieldTransport, Label: "传输", Type: sdk.FieldSelect,
OptionsFrom: OptionsSourceTransports,
Placeholder: "留空 = 按 command / url 推断(有命令走 stdio,有地址走 http)"},
{Key: FieldCommand, Label: "启动命令(stdio)", Type: sdk.FieldText,
Placeholder: "npx / uvx / 可执行文件路径"},
{Key: FieldArgs, Label: "命令参数(stdio)", Type: sdk.FieldMultiSelect,
Placeholder: "逗号或换行分隔,如 -y,@modelcontextprotocol/server-filesystem,/tmp"},
{Key: FieldURL, Label: "服务地址(http / sse)", Type: sdk.FieldText,
Placeholder: "https://..."},
{Key: FieldAPIKey, Label: "API Key", Type: sdk.FieldSecret,
Placeholder: "留空 = 不带鉴权(非空则作为 Bearer 下发)"},
{Key: FieldEnv, Label: "环境变量(stdio)", Type: sdk.FieldKV,
Placeholder: `{"TOKEN":"..."}`},
},
Submit: &sdk.Submit{Action: ActionInstallMCPServer},
},
},
}}
}
// DeclareMCPPanel 把「MCP 服务器」页登记到面板中枢(幂等:重复调用等于重新声明)。
//
// 它是组合根在**声明阶段**要调的那一步;动作处理器另由 `(*Manager).RegisterMCPPanelActions`
// 登记(声明与登记是两个事实,见 actions.go 的口径)。
func DeclareMCPPanel(hub *Panels) ([]string, error) {
if hub == nil {
return nil, fmt.Errorf("plugin: nil panel hub")
}
return hub.Declare(MCPPanelPluginID, MCPAdminPanels())
}
// AttachMCPPanel 把「MCP 服务器」页完整接到本管理器的已接线中枢上:先声明、再登记动作
// 处理器。组合根在 `wirePanelHub`(AttachPanels 之后)调它一次即可。
//
// 两步都在这里做完,是因为它们必须成对出现:只声明不登记 ⇒ 每个动作都诚实回 501;
// 只登记不声明 ⇒ 动作不可达(Dispatch 先查声明)。中枢未接线时报错而不是静默成功。
func (m *Manager) AttachMCPPanel() error {
hub := m.Panels()
if hub == nil {
err := fmt.Errorf("%w: %s(面板中枢未接线,MCP 管理页无处挂)",
ErrInvalidActionRegistration, MCPPanelPluginID)
m.log.Error("mcp panel not declared; its actions will answer 501",
"pluginId", MCPPanelPluginID, "error", err)
return err
}
if _, err := DeclareMCPPanel(hub); err != nil {
m.log.Error("mcp panel declaration rejected; its actions will answer 501",
"pluginId", MCPPanelPluginID, "error", err)
return fmt.Errorf("plugin: declare mcp panel: %w", err)
}
if err := m.RegisterMCPPanelActions(); err != nil {
m.log.Error("mcp panel actions not registered; they will answer 501",
"pluginId", MCPPanelPluginID, "error", err)
return err
}
m.log.Info("mcp panel attached", "pluginId", MCPPanelPluginID, "panelId", MCPPanelID)
return nil
}
// isReservedPanelKey 报告该目录键属于**宿主内置面板提供者**(不是热装插件的命名空间)。
//
// 护栏的方向是单向的:热装插件的安装/停用/卸载绝不许改动内置提供者的目录条目。理由很
// 实在 —— 内置提供者的 id 是给人看的短名字(mcp / web-search / browser),而 server 的
// 名字由主人随手起;一台恰好同名的 server 在卸载时若照常 MarkUninstalled,那整页内置页
// 就在**重启前**消失了(`forgetPanels → MarkUninstalled` 连声明与动作索引一起清)。
//
// 名单写死成 switch 而不是包级 map:新增内置页时没补这一行,review 时一眼可见(与
// paneldecl.shapeOf 同一取舍)。当前三个内置提供者:本包的 mcp 页(MCP 服务器)、
// `plugins/tools/io/search` 的 web-search 页、`plugins/tools/browser` 的 browser 页。
func isReservedPanelKey(key string) bool {
switch key {
case MCPPanelPluginID, "web-search", "browser":
return true
default:
return false
}
}
@@ -0,0 +1,405 @@
package plugin
// 本文件是「MCP 服务器」页的**执行面**(切片 M20):动作处理器 + 载荷解析 + 失败分类。
//
// 三条分工(与 design/09 §5.2.1 的动作分发一栏同向):
// - 鉴权与审计在宿主端点(网关 `adminPluginAction`:写动作要求 owner/admin,每次落审计);
// - **能不能调**由声明决定(Dispatch 先查声明),处理器只负责「这个名字要做什么」;
// - 失败要说清病因:ActionError 带稳定错误码 + 一句人话 + 目标状态码,绝不把上游报文
// 原样吐给前端(§5.4.6),也绝不把凭据写进返回值或日志。
//
// 载荷形状由前端通用渲染器决定(`frontend/web/src/admin/plugin/tableState.ts` /
// `formState.ts`):表格数据源发空体;行内动作发 `{"row":{…行数据…}}`;表单提交发
// `{"values":{…}}`(multiSelect → `[]string`、kv → JSON 对象、switch → bool、其余 → string)。
import (
"context"
"errors"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本页动作的失败码(机器可读、稳定;前端按码本地化,不解析文案)。
const (
// mcpCodeInvalidConfig 报告提交的配置本身不合法(缺 name / 缺 command / 缺 endpoint)。
mcpCodeInvalidConfig = "invalid_config"
// mcpCodeInstallFailed 报告连接或发现失败(配置**不会**落盘)。
mcpCodeInstallFailed = "install_failed"
// mcpCodeReconnectFailed 报告重连失败(原连接已被摘下)。
mcpCodeReconnectFailed = "reconnect_failed"
// mcpCodeEnableFailed 报告启用失败(server 保持停用,不假装开着)。
mcpCodeEnableFailed = "enable_failed"
// mcpCodeDisableFailed 报告停用失败(落盘失败;连接可能仍在)。
mcpCodeDisableFailed = "disable_failed"
// mcpCodeUninstallFailed 报告卸载失败(落盘失败;工具是否已下线取决于错误发生的时机)。
mcpCodeUninstallFailed = "uninstall_failed"
// mcpCodeServerNotFound 报告点名的 server 既不在 plugins.json 里也不活着。
mcpCodeServerNotFound = "server_not_found"
// mcpCodeServerDisabled 报告对一台已停用的 server 发起了「重连」。
mcpCodeServerDisabled = "server_disabled"
)
// RegisterMCPPanelActions 给「MCP 服务器」页登记动作处理器。
//
// 它必须在**声明之后**调用(`AttachMCPPanel` 就按这个顺序做):登记期会核对「登记的
// 处理器有没有对应声明」,顺序反过来只会得到一串「声明还没到」的 WARN。
func (m *Manager) RegisterMCPPanelActions() error {
return m.RegisterActions(MCPPanelPluginID, map[string]ActionHandler{
ActionListMCPServers: m.actListServers,
ActionInstallMCPServer: m.actInstallServer,
ActionReconnectMCPServer: m.actReconnectServer,
ActionEnableMCPServer: m.actEnableServer,
ActionDisableMCPServer: m.actDisableServer,
ActionUninstallMCPServer: m.actUninstallServer,
ActionMCPTransports: m.actTransports,
})
}
// actListServers 返回全部 server 的状态快照(表格数据源)。
//
// 它是**读面**(viewer 也能调):快照里没有任何凭据(见 status.go 的口径②)。
func (m *Manager) actListServers(_ context.Context, _ ActionRequest) (any, error) {
statuses := m.Statuses()
rows := make([]ServerRow, 0, len(statuses))
for _, st := range statuses {
rows = append(rows, serverRowOf(st))
}
return map[string]any{"rows": rows}, nil
}
// actTransports 返回传输形态候选(`transport` 字段的 `optionsFrom`)。
//
// 候选的 value 与运行时接受的取值逐字一致(`runtimeconfig.Transport*`),label 是给人看
// 的一句说明:写 `http` 而以为是「老式 SSE」是最常见的一次配错。
func (m *Manager) actTransports(_ context.Context, _ ActionRequest) (any, error) {
return map[string]any{"options": []map[string]string{
{"value": runtimeconfig.TransportStdio, "label": "stdio(启动本地子进程)"},
{"value": runtimeconfig.TransportHTTP, "label": "http(Streamable HTTP)"},
{"value": runtimeconfig.TransportSSE, "label": "sse(老式 SSE 长连接)"},
}}, nil
}
// actInstallServer 安装(或刷新)一台 server:连接 + 发现 + 注册后才落盘。
func (m *Manager) actInstallServer(ctx context.Context, req ActionRequest) (any, error) {
values, err := actionValues(req.Payload)
if err != nil {
return nil, err
}
cfg, err := installConfigOf(values)
if err != nil {
return nil, err
}
if err := cfg.Validate(); err != nil {
return nil, NewActionError(mcpCodeInvalidConfig, fmt.Sprintf("配置不完整:%v", err), 400, err)
}
res, err := m.Install(ctx, cfg)
if err != nil {
m.log.Warn("mcp install failed", "name", cfg.Name, "transport", cfg.Transport, "error", err)
return nil, NewActionError(mcpCodeInstallFailed,
fmt.Sprintf("连接/发现 MCP server %q 失败,配置未落盘", cfg.Name), 502, err)
}
return map[string]any{
"installed": true,
"name": res.Config.Name,
"transport": res.Config.Transport,
"toolCount": res.ToolCount,
}, nil
}
// installConfigOf 把表单值翻译成插件配置。
//
// `args` / `env` 的形状错**必须在这里变成 400**:前端的 `kv` 控件是自由 JSON textarea,
// 主人写 `{"PORT":8080}` 是最自然的写法,若后端把非字符串值静默丢掉,结果就是「装完提示
// 成功、server 也连上了、env 却没生效」—— 正是任务书点名要防的「看着配好了、其实没生效」。
// 同理 `args` 里的非字符串元素也不许悄悄消失。
func installConfigOf(values map[string]any) (runtimeconfig.PluginConfig, error) {
args, err := actionStringList(values[FieldArgs])
if err != nil {
return runtimeconfig.PluginConfig{}, invalidConfigError(err)
}
env, err := actionStringMap(values[FieldEnv])
if err != nil {
return runtimeconfig.PluginConfig{}, invalidConfigError(err)
}
return runtimeconfig.PluginConfig{
Name: actionText(values, FieldServerName),
Kind: runtimeconfig.PluginKindMCP,
Transport: actionText(values, FieldTransport),
Endpoint: firstNonEmpty(actionText(values, FieldURL), actionText(values, "endpoint")),
Command: actionText(values, FieldCommand),
Args: args,
APIKey: actionText(values, FieldAPIKey),
Env: env,
}.Normalize(), nil
}
// invalidConfigError 把一条「配置没写对」包成结构化动作失败(400)。
//
// 文案里只出现**键名与类型**,绝不回显值:env 的值可能就是凭据。
func invalidConfigError(err error) error {
return NewActionError(mcpCodeInvalidConfig, fmt.Sprintf("配置没写对:%v", err), 400, err)
}
// actReconnectServer 断开并重新连接一台已启用的 server(重新发现工具)。
func (m *Manager) actReconnectServer(ctx context.Context, req ActionRequest) (any, error) {
name, err := actionRowName(req.Payload)
if err != nil {
return nil, err
}
res, err := m.Reconnect(ctx, name)
switch {
case errors.Is(err, ErrPluginNotFound):
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
case errors.Is(err, ErrPluginDisabled):
return nil, NewActionError(mcpCodeServerDisabled,
fmt.Sprintf("server %q 已停用:先「启用」再重连", name), 409, err)
case err != nil:
m.log.Warn("mcp reconnect failed", "name", name, "error", err)
return nil, NewActionError(mcpCodeReconnectFailed,
fmt.Sprintf("重连 %q 失败,详细原因见后端日志", name), 502, err)
}
return map[string]any{"name": res.Config.Name, "reconnected": true, "toolCount": res.ToolCount}, nil
}
// actEnableServer 启用一台 server(连接成功后才把 enabled=true 落盘)。
func (m *Manager) actEnableServer(ctx context.Context, req ActionRequest) (any, error) {
name, err := actionRowName(req.Payload)
if err != nil {
return nil, err
}
if err := m.SetEnabled(ctx, name, true); err != nil {
if errors.Is(err, ErrPluginNotFound) {
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
}
m.log.Warn("mcp enable failed", "name", name, "error", err)
// 两种失败的事实不同,文案也必须不同:连不上 ⇒ 它仍是停用的;**已经连上但启用态
// 没落盘** ⇒ 现在能用、重启后会回到停用(说成「仍处于停用状态」就是与事实相反)。
if errors.Is(err, ErrPluginStatePersist) {
return nil, NewActionError(mcpCodeEnableFailed,
fmt.Sprintf("%q 已连上,但启用态未落盘:重启后会回到停用,详细原因见后端日志", name), 502, err)
}
return nil, NewActionError(mcpCodeEnableFailed,
fmt.Sprintf("启用 %q 失败:它仍处于停用状态,详细原因见后端日志", name), 502, err)
}
return map[string]any{"name": name, "enabled": true, "connected": true}, nil
}
// actDisableServer 停用一台 server(工具随之下线,配置保留)。
func (m *Manager) actDisableServer(ctx context.Context, req ActionRequest) (any, error) {
name, err := actionRowName(req.Payload)
if err != nil {
return nil, err
}
if err := m.SetEnabled(ctx, name, false); err != nil {
if errors.Is(err, ErrPluginNotFound) {
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
}
m.log.Warn("mcp disable failed", "name", name, "error", err)
// 停用的落盘在**前面**(见 enabled.go):写不进去时 server 还在跑、工具还在。
return nil, NewActionError(mcpCodeDisableFailed,
fmt.Sprintf("停用 %q 失败:启用态未能落盘,server 仍在运行(可再试一次),详细原因见后端日志", name), 500, err)
}
return map[string]any{"name": name, "enabled": false, "connected": false}, nil
}
// actUninstallServer 卸载一台 server(摘工具 + 删配置)。
func (m *Manager) actUninstallServer(ctx context.Context, req ActionRequest) (any, error) {
name, err := actionRowName(req.Payload)
if err != nil {
return nil, err
}
removed, err := m.Uninstall(ctx, name)
if err != nil {
m.log.Warn("mcp uninstall failed", "name", name, "error", err)
return nil, NewActionError(mcpCodeUninstallFailed,
fmt.Sprintf("卸载 %q 失败,详细原因见后端日志", name), 500, err)
}
if !removed {
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, nil)
}
return map[string]any{"name": name, "uninstalled": true}, nil
}
// ServerRow 是「MCP 服务器」表格的一行:状态快照 + 两列显示派生量。
//
// 快照字段(Status)逐字嵌入,因此一行既是表格数据、也是机器可读的状态契约;两个派生列
// 只为表格排版而存在(一列只能显示一个标量)。
type ServerRow struct {
Status
// Target 是「地址 / 命令」列的显示值(http/sse ⇒ 地址;stdio ⇒ 命令)。
Target string `json:"target,omitempty"`
// ToolsText 是「工具」列的显示值(工具名以「、」拼接并截断)。
ToolsText string `json:"toolsText,omitempty"`
}
// serverRowOf 把一条快照翻成表格行。
func serverRowOf(st Status) ServerRow {
target := st.Endpoint
if target == "" {
target = st.Command
}
return ServerRow{Status: st, Target: target, ToolsText: toolsText(st.Tools)}
}
// --- 载荷解析 -----------------------------------------------------------------
// actionValues 取出表单提交的 `values` 对象(缺省 / 形状不对都如实报错)。
func actionValues(payload any) (map[string]any, error) {
obj, ok := payload.(map[string]any)
if !ok {
return nil, NewActionError(ActionCodeInvalidPayload, "请求体缺少 values 对象", 400, nil)
}
raw, ok := obj["values"]
if !ok {
return nil, NewActionError(ActionCodeInvalidPayload, "请求体缺少 values 对象", 400, nil)
}
values, ok := raw.(map[string]any)
if !ok {
return nil, NewActionError(ActionCodeInvalidPayload, "values 必须是对象", 400, nil)
}
return values, nil
}
// actionRowName 取出行内动作点名的 server 名(`{"row":{"name":…}}`,也接受 `{"name":…}`)。
func actionRowName(payload any) (string, error) {
obj, ok := payload.(map[string]any)
if !ok {
return "", NewActionError(ActionCodeInvalidPayload, "行内动作缺少 row 对象", 400, nil)
}
row, ok := obj["row"].(map[string]any)
if !ok {
// 容错:某些客户端直接发 `{name, …}`。名字仍然只有这一个真源,不猜别的键。
row = obj
}
name := actionText(row, FieldServerName)
if name == "" {
return "", NewActionError(ActionCodeInvalidPayload, "row 里缺少 name", 400, nil)
}
return name, nil
}
// actionText 取一个字符串值(非字符串 / 缺省都给空串 —— 空串在配置里就是「没填」)。
func actionText(values map[string]any, key string) string {
raw, ok := values[key]
if !ok {
return ""
}
s, ok := raw.(string)
if !ok {
return ""
}
return strings.TrimSpace(s)
}
// actionStringList 取一个字符串列表(`multiSelect` 提交的是 `[]string`,JSON 解码后是
// `[]any`)。
//
// 形状错一律报错:非数组、元素非字符串都不许**静默丢弃**(少一个参数、或多出一个空参数,
// 都会让 server 起来后行为不对,而面板还报「装好了」)。空项按前端 `splitListValue` 的同一
// 口径去掉(那只来自多余的分隔符,不是用户意图)。
func actionStringList(raw any) ([]string, error) {
switch items := raw.(type) {
case nil:
return nil, nil
case []string:
return compactStrings(items), nil
case []any:
out := make([]string, 0, len(items))
for i, item := range items {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("args 的第 %d 项不是字符串(收到 %s);每个参数都必须是字符串", i+1, jsonKindOf(item))
}
out = append(out, s)
}
return compactStrings(out), nil
default:
return nil, fmt.Errorf("args 必须是字符串数组(收到 %s)", jsonKindOf(raw))
}
}
// actionStringMap 取一个键值对(`kv` 提交的是 JSON 对象)。
//
// 值**只接受字符串**,其它类型一律报错而不是丢掉那个键:`{"PORT":8080}` 是最自然的写法,
// 静默丢掉它等于「装完提示成功、env 却没生效」(前端 `KvControl` 只校验「是个对象」,
// 所以这条路 UI 天然可达)。空键同理 —— 丢一个键与拒一个键必须选后者。
func actionStringMap(raw any) (map[string]string, error) {
var pairs map[string]any
switch obj := raw.(type) {
case nil:
return nil, nil
case map[string]any:
pairs = obj
case map[string]string:
pairs = make(map[string]any, len(obj))
for k, v := range obj {
pairs[k] = v
}
default:
return nil, fmt.Errorf("env 必须是键值对对象(收到 %s)", jsonKindOf(raw))
}
out := make(map[string]string, len(pairs))
for k, v := range pairs {
key := strings.TrimSpace(k)
if key == "" {
return nil, fmt.Errorf("env 里有空键;键就是环境变量名,不能为空")
}
s, ok := v.(string)
if !ok {
return nil, fmt.Errorf("env.%s 的值不是字符串(收到 %s);要写数字/布尔请加引号,如 \"8080\"", key, jsonKindOf(v))
}
out[key] = s
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// jsonKindOf 给出一个 JSON 值的中文类型名(只为错误文案:说清「收到的是什么」)。
//
// 它刻意不打印值本身:env 的值可能就是凭据。
func jsonKindOf(v any) string {
switch v.(type) {
case nil:
return "null"
case string:
return "字符串"
case bool:
return "布尔"
case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return "数字"
case []any:
return "数组"
case map[string]any:
return "对象"
default:
return fmt.Sprintf("%T", v)
}
}
// compactStrings 去掉两端空白与空项;结果为空时返回 nil。
func compactStrings(items []string) []string {
out := make([]string, 0, len(items))
for _, item := range items {
if s := strings.TrimSpace(item); s != "" {
out = append(out, s)
}
}
if len(out) == 0 {
return nil
}
return out
}
// firstNonEmpty 返回首个非空值(安装表单里 url 与 endpoint 是同义键)。
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
+430
View File
@@ -0,0 +1,430 @@
package plugin
// 本文件锁定「MCP 服务器」页(切片 M20)的三件事:
// ① 声明本身合法(形状按 paneldecl 校验通过 —— 前端按声明泛化渲染,形状错就是半渲染);
// ② 声明与登记**成对**(每个声明的动作都有处理器:只有声明 ⇒ 每个动作都 501);
// ③ 动作面端到端可用:装 → 看 → 重连 → 停 → 启用 → 卸,每一步都推 panels_changed,
// 失败按结构化错误码如实分类(install_failed / server_not_found / server_disabled)。
import (
"context"
"net/http"
"net/http/httptest"
"os"
"runtime"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// TestMCPPanelDeclarationIsValid 锁定 ①:整份声明过 paneldecl 的形状校验。
func TestMCPPanelDeclarationIsValid(t *testing.T) {
t.Parallel()
if err := paneldecl.Validate(MCPPanelPluginID, MCPAdminPanels()); err != nil {
t.Fatalf("「MCP 服务器」页声明非法:%v", err)
}
}
// TestMCPPanelActionsWired 锁定 ②:声明的动作与登记的处理器必须一一对应。
//
// 这条断言的价值是把「声明里写了、没人实现」从「用户点出一个 501」提前到测试里:少一个
// 处理器,本测试直接红。
func TestMCPPanelActionsWired(t *testing.T) {
t.Parallel()
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
declared := hub.DeclaredActions(MCPPanelPluginID)
handled := hub.ActionHandlerNames(MCPPanelPluginID)
if len(declared) == 0 || len(handled) == 0 {
t.Fatalf("声明 %v / 处理器 %v;两者都不该为空", declared, handled)
}
set := make(map[string]bool, len(handled))
for _, name := range handled {
set[name] = true
}
for _, name := range declared {
if !set[name] {
t.Fatalf("动作 %q 已声明但没有处理器(点了会 501)", name)
}
}
// 候选来源(`optionsFrom: plugin:mcpTransports`)必须能被解析成已登记的动作名,
// 否则前端的传输下拉会静默降级成文本框。
if name := OptionsActionName(OptionsSourceTransports); !set[name] {
t.Fatalf("候选来源 %q 映射到 %q,但没有处理器", OptionsSourceTransports, name)
}
}
// TestMCPPanelInstallAndList 锁定 ③ 的前半:安装(表单载荷)→ 看(表格行)。
func TestMCPPanelInstallAndList(t *testing.T) {
t.Parallel()
srv := newFakeMCP(t, "", "add", "mul")
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
ctx := context.Background()
installViaAction(t, hub, ctx, "calc", srv.URL)
assertInstalledConfig(t, mgr, "calc")
row := mustRows(t, hub, ctx)[0]
if row.Name != "calc" || row.State != ServerStateConnected || row.ToolCount == nil || *row.ToolCount != 2 {
t.Fatalf("行 = %+v,期望 calc/connected/2 工具", row)
}
if row.ToolsText != "add、mul" {
t.Fatalf("toolsText = %q,期望 add、mul", row.ToolsText)
}
if row.Target != srv.URL {
t.Fatalf("target = %q,期望地址 %q", row.Target, srv.URL)
}
}
// TestMCPPanelToggleUninstallAndFrames 锁定 ③ 的后半:重连 / 停用 / 启用 / 卸载,
// 以及每一步都推 `panels_changed`(前端据此热更新,不必刷新页面)。
func TestMCPPanelToggleUninstallAndFrames(t *testing.T) {
t.Parallel()
srv := newFakeMCP(t, "", "add", "mul")
mgr, hub, rec := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
ctx := context.Background()
installViaAction(t, hub, ctx, "calc", srv.URL)
if _, err := dispatchRow(t, hub, ActionReconnectMCPServer, "calc"); err != nil {
t.Fatalf("reconnect 动作: %v", err)
}
if _, err := dispatchRow(t, hub, ActionDisableMCPServer, "calc"); err != nil {
t.Fatalf("disable 动作: %v", err)
}
if names := toolNames(mgr.reg); len(names) != 0 {
t.Fatalf("停用后工具仍在注册表 = %v", names)
}
if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateDisabled || row.Enabled {
t.Fatalf("停用后行 = %+v", row)
}
_, err := dispatchRow(t, hub, ActionReconnectMCPServer, "calc")
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeServerDisabled || ae.Status != http.StatusConflict {
t.Fatalf("对停用的 server 重连 = %v,期望 %s/409", err, mcpCodeServerDisabled)
}
if _, err := dispatchRow(t, hub, ActionEnableMCPServer, "calc"); err != nil {
t.Fatalf("enable 动作: %v", err)
}
if names := toolNames(mgr.reg); !names["add"] || !names["mul"] {
t.Fatalf("启用后工具 = %v,期望 add+mul", names)
}
if _, err := dispatchRow(t, hub, ActionUninstallMCPServer, "calc"); err != nil {
t.Fatalf("uninstall 动作: %v", err)
}
if rows := mustRows(t, hub, ctx); len(rows) != 0 {
t.Fatalf("卸载后仍有行:%+v", rows)
}
reasons := changeReasons(rec.all())
for _, want := range []string{
PanelChangeInstalled, PanelChangeDisabled, PanelChangeEnabled, PanelChangeUninstalled,
} {
if !containsString(reasons, want) {
t.Fatalf("变更原因 = %v,缺 %q", reasons, want)
}
}
}
// TestMCPPanelInstallFailureIsClassified 锁定失败分类:装不上 ≠ 找不到 ≠ 已停用。
func TestMCPPanelInstallFailureIsClassified(t *testing.T) {
t.Parallel()
dead := httptest.NewServer(http.NotFoundHandler())
deadURL := dead.URL
dead.Close()
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
ctx := context.Background()
_, err := hub.Dispatch(ctx, ActionRequest{
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
Payload: map[string]any{"values": map[string]any{"name": "down", "url": deadURL}},
})
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeInstallFailed || ae.Status != http.StatusBadGateway {
t.Fatalf("连接失败 = %v,期望 %s/502", err, mcpCodeInstallFailed)
}
// 配置不落盘,但失败要留在快照里(面板要说得出为什么挂了)。
if cfgs, err := runtimeconfig.LoadPlugins(mgr.path); err != nil || len(cfgs) != 0 {
t.Fatalf("失败后落盘 = %+v(err=%v),期望空", cfgs, err)
}
if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateError || row.LastError == "" {
t.Fatalf("失败行 = %+v,期望 error 且带 lastError", row)
}
// 缺字段 ⇒ invalid_config / 400(不是 502:请求本身写错了)。
_, err = hub.Dispatch(ctx, ActionRequest{
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
Payload: map[string]any{"values": map[string]any{"transport": "stdio"}},
})
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeInvalidConfig || ae.Status != http.StatusBadRequest {
t.Fatalf("缺 name/command = %v,期望 %s/400", err, mcpCodeInvalidConfig)
}
// 卸载一个不存在的名字 ⇒ server_not_found / 404。
_, err = dispatchRow(t, hub, ActionUninstallMCPServer, "ghost")
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeServerNotFound || ae.Status != http.StatusNotFound {
t.Fatalf("卸载不存在的 server = %v,期望 %s/404", err, mcpCodeServerNotFound)
}
}
// TestMCPPanelReservedNamespaceIsGuarded 锁定护栏:一台恰好叫 mcp 的 server 的卸载/停用
// 不许把内置「MCP 服务器」页整页摘掉。
func TestMCPPanelReservedNamespaceIsGuarded(t *testing.T) {
t.Parallel()
srv := newFakeMCP(t, "", "add")
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
installFake(t, mgr, MCPPanelPluginID, srv.URL)
if _, err := mgr.Uninstall(context.Background(), MCPPanelPluginID); err != nil {
t.Fatalf("Uninstall: %v", err)
}
entries, err := hub.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(entries) != 1 || entries[0].PluginID != MCPPanelPluginID {
t.Fatalf("内置 MCP 页被热装插件的生命周期改动了:%+v", entries)
}
}
// mustRows 取表格行(`{rows}` 载荷)并断言载荷形状。
func mustRows(t *testing.T, hub *Panels, ctx context.Context) []ServerRow {
t.Helper()
res, err := hub.Dispatch(ctx, ActionRequest{PluginID: MCPPanelPluginID, Action: ActionListMCPServers})
if err != nil {
t.Fatalf("list 动作: %v", err)
}
body, ok := res.(map[string]any)
if !ok {
t.Fatalf("list 结果 = %T,期望对象", res)
}
rows, ok := body["rows"].([]ServerRow)
if !ok {
t.Fatalf("list 结果的 rows 类型 = %T", body["rows"])
}
return rows
}
// dispatchRow 以行内动作的形状(`{row:{name}}`)分发一次动作。
func dispatchRow(t *testing.T, hub *Panels, action, name string) (any, error) {
t.Helper()
return hub.Dispatch(context.Background(), ActionRequest{
PluginID: MCPPanelPluginID, Action: action,
Payload: map[string]any{"row": map[string]any{FieldServerName: name}},
})
}
// installViaAction 以表单载荷(`{values}`)经动作面安装一台 server。
func installViaAction(t *testing.T, hub *Panels, ctx context.Context, name, url string) {
t.Helper()
if _, err := hub.Dispatch(ctx, ActionRequest{
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
Payload: map[string]any{"values": map[string]any{
"name": name, "transport": "http", "url": url,
"args": []any{"--flag", "value"},
"env": map[string]any{"TOKEN": "t0ken"},
}},
}); err != nil {
t.Fatalf("install 动作: %v", err)
}
}
// assertInstalledConfig 断言表单字段确实映射进了 plugins.json(args/env 这类列表/键值
// 最容易在「前端提交了、后端读错键」这一步静默丢失)。
func assertInstalledConfig(t *testing.T, mgr *Manager, name string) {
t.Helper()
cfgs, err := runtimeconfig.LoadPlugins(mgr.path)
if err != nil || len(cfgs) != 1 {
t.Fatalf("落盘 = %+v(err=%v),期望一条", cfgs, err)
}
if cfgs[0].Name != name {
t.Fatalf("落盘名字 = %q", cfgs[0].Name)
}
if len(cfgs[0].Args) != 2 || cfgs[0].Args[1] != "value" {
t.Fatalf("args 没映射进配置:%+v", cfgs[0].Args)
}
if cfgs[0].Env["TOKEN"] != "t0ken" {
t.Fatalf("env 没映射进配置:%+v", cfgs[0].Env)
}
}
// containsString 报告 items 里是否有 want。
func containsString(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
// TestMCPPanelInstallRejectsNonStringEnv 锁定 P2-1:`env` / `args` 里的非字符串值必须
// 变成 400,**不许静默丢键**(`{"PORT":8080}` 是前端 kv 控件最自然的写法:装完提示成功、
// server 连上了、env 却没生效 —— 比直接失败更坏)。
func TestMCPPanelInstallRejectsNonStringEnv(t *testing.T) {
t.Parallel()
srv := newFakeMCP(t, "", "add")
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
ctx := context.Background()
cases := []struct {
name string
mutate func(values map[string]any)
wantKey string
}{
{"env 值是数字", func(v map[string]any) { v["env"] = map[string]any{"PORT": 9182.0} }, "env.PORT"},
{"env 值是布尔", func(v map[string]any) { v["env"] = map[string]any{"DEBUG": true} }, "env.DEBUG"},
{"args 元素是数字", func(v map[string]any) { v["args"] = []any{"-y", 42.0} }, "args"},
}
for _, tc := range cases {
values := map[string]any{
"name": "calc", "transport": "http", "url": srv.URL,
"env": map[string]any{"TOKEN": "abc"},
"args": []any{"--flag"},
}
tc.mutate(values)
_, err := hub.Dispatch(ctx, ActionRequest{
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
Payload: map[string]any{"values": values},
})
ae, ok := AsActionError(err)
if !ok || ae.Code != mcpCodeInvalidConfig || ae.Status != http.StatusBadRequest {
t.Fatalf("%s:err = %v,期望 %s/400", tc.name, err, mcpCodeInvalidConfig)
}
if !strings.Contains(ae.Message, tc.wantKey) {
t.Fatalf("%s:文案没点名出错的键(%q 不含 %q)", tc.name, ae.Message, tc.wantKey)
}
// 文案里不许回显**收到的值**(env 的值可能就是凭据):9182 是本用例特意挑的
// 独特数字,文案里的 "8080" 只是「要加引号」的示例,不是回显。
if strings.Contains(ae.Message, "9182") {
t.Fatalf("%s:文案回显了值:%q", tc.name, ae.Message)
}
}
// 三次都失败 ⇒ 盘上零变更、注册表零工具、列表零行。
if cfgs, err := runtimeconfig.LoadPlugins(mgr.path); err != nil || len(cfgs) != 0 {
t.Fatalf("盘上 = %+v(err=%v),期望空", cfgs, err)
}
if names := toolNames(mgr.reg); len(names) != 0 {
t.Fatalf("注册表里有工具 = %v", names)
}
if rows := mustRows(t, hub, ctx); len(rows) != 0 {
t.Fatalf("快照里有行 = %+v", rows)
}
}
// TestReservedPanelKeysProtectBuiltinProviders 锁定顺手件 1:三台内置提供者的命名空间
// 都不许被一台同名 server 的安装/停用/启用/卸载改写(否则内置页在重启前就消失)。
func TestReservedPanelKeysProtectBuiltinProviders(t *testing.T) {
t.Parallel()
for _, id := range []string{MCPPanelPluginID, "web-search", "browser"} {
id := id
t.Run(id, func(t *testing.T) {
t.Parallel()
srv := newFakeMCP(t, "", "add")
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
ctx := context.Background()
// 模拟该内置提供者在装配期声明的那一页。
if _, err := hub.Declare(id, []sdk.Panel{samplePanel()}); err != nil {
t.Fatalf("Declare(%s): %v", id, err)
}
installFake(t, mgr, id, srv.URL)
assertBuiltinPageAlive(t, hub, id, "安装")
if err := mgr.SetEnabled(ctx, id, false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
assertBuiltinPageAlive(t, hub, id, "停用")
if err := mgr.SetEnabled(ctx, id, true); err != nil {
t.Fatalf("SetEnabled(true): %v", err)
}
if _, err := mgr.Uninstall(ctx, id); err != nil {
t.Fatalf("Uninstall: %v", err)
}
assertBuiltinPageAlive(t, hub, id, "卸载")
})
}
}
// assertBuiltinPageAlive 断言该内置提供者的页仍在目录里。
func assertBuiltinPageAlive(t *testing.T, hub *Panels, id, step string) {
t.Helper()
entries, err := hub.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
for _, e := range entries {
if e.PluginID == id {
return
}
}
t.Fatalf("%s一台同名 server 之后,内置页 %q 不见了:%+v", step, id, entries)
}
// TestMCPPanelEnablePersistFailureReportsAccurately 锁定顺手件 2:server **已连上**但
// 启用态没落盘时,文案必须说的是这件事(「仍处于停用状态」是相反的陈述)。
//
// 只在本平台跑:靠「把 plugins.json 置成只读」让 rename 失败(Unix 下 rename 由目录权限
// 决定,同法不成立)。
func TestMCPPanelEnablePersistFailureReportsAccurately(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("只读位只在 windows 的 rename 上生效")
}
t.Parallel()
srv := newFakeMCP(t, "", "add")
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
ctx := context.Background()
installViaAction(t, hub, ctx, "calc", srv.URL)
if _, err := dispatchRow(t, hub, ActionDisableMCPServer, "calc"); err != nil {
t.Fatalf("disable: %v", err)
}
if err := os.Chmod(mgr.path, 0o444); err != nil {
t.Fatalf("chmod: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(mgr.path, 0o600) })
_, err := dispatchRow(t, hub, ActionEnableMCPServer, "calc")
ae, ok := AsActionError(err)
if !ok || ae.Code != mcpCodeEnableFailed || ae.Status != http.StatusBadGateway {
t.Fatalf("启用(落盘失败)= %v,期望 %s/502", err, mcpCodeEnableFailed)
}
// 断言的是**文案与事实一致**(下面两条),不是某个内部哨兵:这句话是给主人看的。
if !strings.Contains(ae.Message, "未落盘") || strings.Contains(ae.Message, "仍处于停用状态") {
t.Fatalf("文案与事实不符:%q", ae.Message)
}
// 事实是「已经连上、工具可用」,快照也这么说(state 派生口径不变)。
if names := toolNames(mgr.reg); !names["add"] {
t.Fatalf("启用后工具 = %v,期望已连上并注册", names)
}
if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateConnected || row.Enabled {
t.Fatalf("快照 = %+v,期望 state=connected 且 enabled=false(盘上仍是停用)", row)
}
}
+54 -7
View File
@@ -287,15 +287,11 @@ func PanelIDValid(id string) bool {
// markInstalled 记录安装时用过的面板目录键(memo,供卸载/停用复用),并把它报给 // markInstalled 记录安装时用过的面板目录键(memo,供卸载/停用复用),并把它报给
// 面板中枢。未接线时只记 memo —— 计时与归因不依赖中枢在不在。 // 面板中枢。未接线时只记 memo —— 计时与归因不依赖中枢在不在。
func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason string) { func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason string) {
key := PanelPluginID(cfg) key := m.rememberPanelKey(cfg)
if key == "" { if key == "" || isReservedPanelKey(key) {
m.log.Warn("plugin has no panel catalog key", "name", cfg.Name)
return return
} }
m.mu.Lock() hub := m.panelsHub()
m.panelKeys[cfg.Name] = key
hub := m.panels
m.mu.Unlock()
if hub == nil { if hub == nil {
return return
} }
@@ -308,6 +304,51 @@ func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason str
hub.MarkInstalled(key) hub.MarkInstalled(key)
} }
// markDisabled 记录停用时用过的面板目录键,并让中枢隐藏该插件 `when:{enabled:true}` 的
// 入口(声明保留,重新启用即原样回来)。
//
// 「停用」是**有意**没连,因此入口必须消失(§5.6.1「未启用的插件 ⇒ 入口不出现」);
// 而「连不上」不该摘入口 —— 那时用户正需要点「重连」。
func (m *Manager) markDisabled(cfg runtimeconfig.PluginConfig) {
key := m.rememberPanelKey(cfg)
if key == "" || isReservedPanelKey(key) {
return
}
if hub := m.panelsHub(); hub != nil {
hub.SetEnabled(key, false)
}
}
// rememberPanelKey 记下该插件的面板目录键(memo)并回它;键非法时记日志并回空串。
//
// memo 是**缓存而不是第二份真源**:键由 PanelPluginID 从 cfg 推出,这里只把结果留下,
// 好让「活 server 已不在了」的卸载/停用仍能精确找到该清哪一条。
func (m *Manager) rememberPanelKey(cfg runtimeconfig.PluginConfig) string {
key := PanelPluginID(cfg)
if key == "" {
m.log.Warn("plugin has no panel catalog key", "name", cfg.Name)
return ""
}
if isReservedPanelKey(key) {
// 内置提供者的命名空间不归热装插件管:一台恰好叫 mcp 的 server 不许改动
// 「MCP 服务器」页本身(见 isReservedPanelKey)。
m.log.Info("plugin name collides with a built-in panel namespace; panel entry untouched",
"name", cfg.Name, "panelId", key)
return ""
}
m.mu.Lock()
m.panelKeys[cfg.Name] = key
m.mu.Unlock()
return key
}
// panelsHub 返回已接线中枢(未接线时 nil)。
func (m *Manager) panelsHub() *Panels {
m.mu.Lock()
defer m.mu.Unlock()
return m.panels
}
// forgetPanels 在卸载后清掉该插件的面板。键优先取安装时记下的 memo;没有记录 // forgetPanels 在卸载后清掉该插件的面板。键优先取安装时记下的 memo;没有记录
// (例如配置文件是手工放进 plugins.json 的)时回落到「名字恰好合法就用名字」。 // (例如配置文件是手工放进 plugins.json 的)时回落到「名字恰好合法就用名字」。
func (m *Manager) forgetPanels(name string) { func (m *Manager) forgetPanels(name string) {
@@ -325,6 +366,9 @@ func (m *Manager) forgetPanels(name string) {
return return
} }
} }
if isReservedPanelKey(key) {
return
}
hub.MarkUninstalled(key) hub.MarkUninstalled(key)
} }
@@ -343,5 +387,8 @@ func (m *Manager) setPanelsEnabled(name string, on bool) {
return return
} }
} }
if isReservedPanelKey(key) {
return
}
hub.SetEnabled(key, on) hub.SetEnabled(key, on)
} }
+273
View File
@@ -0,0 +1,273 @@
package plugin
// 本文件是 MCP 服务器的**状态读面**(切片 M20):每台 server 的连接态、工具清单、最近一次
// 错误与时间。它是「看不见连没连上 / 暴露了哪些工具 / 为什么挂了」这个洞的补丁。
//
// 三条口径:
//
// 1. **三态,缺席 = 未知**(项目既有纪律:零值不得冒充「没有/未连接」,见
// 「零值 = 不知道」类裁决)。因此 `connected` 是 `*bool`:present true = 连着;
// present false = 确认没连(停用 / 上次连接失败);**absent = 宿主不知道**。同理
// `toolCount` / `tools` / `lastSeenAt` 只在「知道」时才出现 —— 一台停用的 server
// 不是「暴露 0 把工具」,而是「没连,所以不知道它有什么」。
// 2. **凭据绝不出现在快照里**:`apiKey` 不进任何字段(Env 与 Args 也一律不下发 ——
// stdio 的 args 常带 `--token xxx`),自由文本(`lastError`、`endpoint`、`command`)
// 在**记录/构造那一刻**就用 `runtimeconfig.RedactPluginSecrets` 抹过(含凭据的任意
// 片段)。抹在这里而不是在网关:读面不止一条路(面板动作、日志、未来的端点),
// 把安全押在「每个调用方都记得抹」上迟早漏一次。
// 3. **状态是派生的、不是另存一份**:`state` 由「活没活 / 启没启用 / 有没有失败记录」
// 推出来(见 statusOf),只有运行期观察(lastError / lastSeenAt)是记下来的事实。
// 两处各存一份状态必然分叉 —— 那正是「面板说连上了、模型调不到工具」的形状。
import (
"sort"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// serverState 的取值闭集(`state` 列 / `Status.State`)。它们是**机器读**的码,不是文案:
// 前端按码分支,中文名由前端映射(与 web-search 页 `state` 列同一口径)。
const (
// ServerStateConnected 表示 server 已连接、工具已注册。
ServerStateConnected = "connected"
// ServerStateError 表示已启用但最近一次连接/发现失败(`lastError` 有值)。
ServerStateError = "error"
// ServerStateDisabled 表示被显式停用(`enabled=false`):没连,是**有意**没连。
ServerStateDisabled = "disabled"
// ServerStateUnknown 表示宿主说不清:已启用、没连着、也没有失败记录
// (例如 plugins.json 被手工改过、启动期还没轮到它)。
ServerStateUnknown = "unknown"
)
// 快照里的截断上限(都是「表格能扫一眼」的排版倒推出来的,不是随手取的数)。
const (
// toolDescriptionLimit 是单把工具描述的字符上限。
toolDescriptionLimit = 200
// lastErrorLimit 是最近一次错误的字符上限。错误链可能很长,排障要的是病因那一句。
lastErrorLimit = 400
// toolsTextLimit 是表格「工具」列(工具名拼成的文本)的字符上限。
toolsTextLimit = 240
)
// Status 是一台 MCP server 的状态快照(可序列化,供面板行与未来端点复用)。
type Status struct {
// Name 是 server 名(plugins.json 的唯一标签,也是能力的命名空间)。
Name string `json:"name"`
// ID 是 server 的稳定标识(缺省省略)。
ID string `json:"id,omitempty"`
// Kind 是插件种类(本期恒为 mcp)。
Kind string `json:"kind,omitempty"`
// Transport 是连接形态(stdio / http / sse)。
Transport string `json:"transport,omitempty"`
// Endpoint 是 http/sse 的地址(已抹凭据)。
Endpoint string `json:"endpoint,omitempty"`
// Command 是 stdio 的可执行文件(参数**不下发**:args 常带凭据)。
Command string `json:"command,omitempty"`
// Enabled 是启用态(来自 plugins.json;缺省视为 true)。
Enabled bool `json:"enabled"`
// State 是状态闭集码(见 ServerState*)。
State string `json:"state"`
// Connected 是三态连接态:true/false 是事实,**缺席是未知**(见文件头口径①)。
Connected *bool `json:"connected,omitempty"`
// ToolCount 是已注册的工具数。只在**连着**时给出:不连的 server 不是「0 把工具」,
// 而是「不知道它有哪些工具」。
ToolCount *int `json:"toolCount,omitempty"`
// Tools 是工具摘要(名字 + 截断描述)。同样只在连着时给出;连着但零工具时
// `toolCount` 是 0 而本字段缺席(空数组同样意味着「没有工具」,两种表达不必并存)。
Tools []mcp.ToolSummary `json:"tools,omitempty"`
// LastError 是最近一次连接/发现失败的文本(已抹凭据、已截断)。
LastError string `json:"lastError,omitempty"`
// LastErrorAt 是那次失败的时刻(RFC3339)。
LastErrorAt string `json:"lastErrorAt,omitempty"`
// LastSeenAt 是最近一次**成功**连上的时刻(RFC3339)。缺席 = 还没成功连过。
LastSeenAt string `json:"lastSeenAt,omitempty"`
}
// observed 是一台 server 最近一次被观察到的运行期事实(内存态,不落盘)。
//
// 它只记「事件」:成功连上过(lastSeenAt)、失败过(lastError/lastErrorAt)。启用态与
// 连接态都不在这里 —— 前者的真源是 plugins.json,后者的真源是 servers 表。
type observed struct {
cfg runtimeconfig.PluginConfig
lastError string
lastErrorAt time.Time
lastSeenAt time.Time
}
// Statuses 返回全部 MCP server 的状态快照:plugins.json 的顺序在前,其后是「只活在内存里」
// 的(装了但还没落盘、或文件丢了但 server 活着)与「只留下失败记录」的,各自按名字排序
// (map 迭代顺序不稳定,快照顺序必须稳定 —— 否则前端每次刷新都在跳行)。
func (m *Manager) Statuses() []Status {
cfgs, err := runtimeconfig.LoadPlugins(m.path)
if err != nil {
m.log.Warn("plugins.json load skipped", "path", m.path, "error", err)
cfgs = nil
}
live, obs := m.snapshotState()
out := make([]Status, 0, len(cfgs)+len(obs))
seen := make(map[string]bool, len(cfgs)+len(obs))
for _, cfg := range cfgs {
out = append(out, statusOf(cfg, live[cfg.Name], obs[cfg.Name]))
seen[cfg.Name] = true
}
for _, name := range sortedKeys(obs) {
if seen[name] {
continue
}
out = append(out, statusOf(obs[name].cfg, live[name], obs[name]))
seen[name] = true
}
for _, name := range sortedKeys(live) {
if seen[name] {
continue
}
out = append(out, statusOf(live[name].cfg, live[name], obs[name]))
seen[name] = true
}
return out
}
// snapshotState 复制一份活 server 表与观察表(锁内复制、锁外计算:statusOf 会问能力注册表,
// 那是外部代码,持本管理器的锁去碰它等于把两把锁的顺序交给外部)。
func (m *Manager) snapshotState() (map[string]*server, map[string]observed) {
m.mu.Lock()
defer m.mu.Unlock()
live := make(map[string]*server, len(m.servers))
for name, srv := range m.servers {
live[name] = srv
}
obs := make(map[string]observed, len(m.observed))
for name, rec := range m.observed {
obs[name] = rec
}
return live, obs
}
// statusOf 把「配置 + 活 server + 观察记录」推成一条快照。
func statusOf(cfg runtimeconfig.PluginConfig, srv *server, rec observed) Status {
st := Status{
Name: cfg.Name,
ID: cfg.ID,
Kind: cfg.Kind,
Transport: cfg.Transport,
Endpoint: runtimeconfig.RedactPluginSecrets(cfg, cfg.Endpoint),
Command: runtimeconfig.RedactPluginSecrets(cfg, cfg.Command),
Enabled: cfg.IsEnabled(),
LastError: rec.lastError,
LastErrorAt: formatTime(rec.lastErrorAt),
}
switch {
case srv != nil:
st.State = ServerStateConnected
st.Connected = flagBool(true)
count := srv.toolCount
st.ToolCount = &count
st.Tools = srv.reg.ToolSummaries(toolDescriptionLimit)
st.LastSeenAt = formatTime(rec.lastSeenAt)
case !cfg.IsEnabled():
// 停用是**有意**没连:连接态是确认的 false(不是未知),工具则是未知。
//
// 「想启用但失败了」也落在这里(落盘的 enabled 仍是 false,见 enabled.go):此时
// `state` 说「它现在是停用的」,`lastError` 说「刚才那次启用为什么没成」—— 两个
// 事实各归各的列,谁也不冒充谁。若让 error 抢在 disabled 前面,一台**故意**停用
// 过又失败过一次的 server 会永远显示「错误」,用户就没法把它当成「关着的」。
st.State = ServerStateDisabled
st.Connected = flagBool(false)
st.LastSeenAt = formatTime(rec.lastSeenAt)
case rec.lastError != "":
st.State = ServerStateError
st.Connected = flagBool(false)
st.LastSeenAt = formatTime(rec.lastSeenAt)
default:
// 已启用、没连着、也没有失败记录 ⇒ 宿主不知道它到底行不行(不猜)。
st.State = ServerStateUnknown
}
return st
}
// noteSeen 记录一次成功连接(tools 已注册)。
func (m *Manager) noteSeen(cfg runtimeconfig.PluginConfig) {
m.observe(cfg, func(rec *observed) { rec.lastSeenAt = time.Now().UTC() })
}
// noteFailure 记录一次连接/发现失败。错误文本在**这一刻**就抹掉凭据并截断:内存里也不留
// 明文,之后的任何出口(面板、日志、未来端点)都不必再各自记得抹。
func (m *Manager) noteFailure(cfg runtimeconfig.PluginConfig, err error) {
if err == nil {
return
}
text := mcp.TruncateRunes(runtimeconfig.RedactPluginSecrets(cfg, err.Error()), lastErrorLimit)
m.observe(cfg, func(rec *observed) {
rec.lastError = text
rec.lastErrorAt = time.Now().UTC()
})
}
// noteDisabled 记录「这台 server 现在是停用的」(保留历史观察:排障时要看它停用前发生过什么)。
func (m *Manager) noteDisabled(cfg runtimeconfig.PluginConfig) {
m.observe(cfg, func(*observed) {})
}
// observe 在锁内更新一台 server 的观察记录(不存在则新建)。
func (m *Manager) observe(cfg runtimeconfig.PluginConfig, apply func(*observed)) {
name := cfg.Name
if name == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if m.observed == nil {
m.observed = make(map[string]observed, 1)
}
rec := m.observed[name]
rec.cfg = cfg
apply(&rec)
m.observed[name] = rec
}
// forgetObserved 丢掉一台 server 的观察记录(卸载即不再有这台 server 的任何事实)。
func (m *Manager) forgetObserved(name string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.observed, name)
}
// formatTime 把时刻格式化成 RFC3339;零值返回空串(缺席 = 还没发生,不写 1970)。
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
}
// flagBool 是 `&true` / `&false` 的简写(Connected 是三态指针)。
func flagBool(v bool) *bool { return &v }
// sortedKeys 返回 map 键的有序副本(快照顺序必须稳定)。
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// toolsText 把工具摘要拼成表格里「工具」列的一行文本(名字在前、描述省略)。
//
// 表格单元格只认标量(前端 `cellText` 会把数组用「、」拼起来,但长度不可控),因此这里
// 自己拼并截断:排障要看的是**有哪些工具**,不是每把工具的完整描述(描述在 `tools[]` 里)。
func toolsText(tools []mcp.ToolSummary) string {
if len(tools) == 0 {
return ""
}
names := make([]string, 0, len(tools))
for _, t := range tools {
names = append(names, t.Name)
}
return mcp.TruncateRunes(strings.Join(names, "、"), toolsTextLimit)
}
+185
View File
@@ -0,0 +1,185 @@
package plugin
// 本文件锁定 MCP 服务器状态快照的三条硬要求(切片 M20):
// ① 三态:缺席 = 未知,不用零值冒充「未连接」;
// ② 凭据绝不出现(apiKey 及其任意片段),带变异自证;
// ③ 连接失败如实给 lastError + connected=false,且失败的那台也出现在快照里
// (否则「为什么挂了」在面板上根本无处可看)。
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// statusOfOne 返回快照里某台 server 的那一条(没有就 fail)。
func statusOfOne(t *testing.T, mgr *Manager, name string) Status {
t.Helper()
for _, st := range mgr.Statuses() {
if st.Name == name {
return st
}
}
t.Fatalf("快照里没有 %q:%+v", name, mgr.Statuses())
return Status{}
}
// TestStatusesTriState 锁定 ①:三种「知道」程度各自长什么样。
func TestStatusesTriState(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
mgr, _ := newEnabledManager(t, path, srv.URL)
// 连着:connected=true、工具数/明细/最近连接都在。
live := statusOfOne(t, mgr, "calc")
if live.State != ServerStateConnected {
t.Fatalf("state = %q,期望 connected", live.State)
}
if live.Connected == nil || !*live.Connected || live.ToolCount == nil || *live.ToolCount != 1 {
t.Fatalf("连接态快照不对 = %+v", live)
}
if live.LastSeenAt == "" || live.LastError != "" {
t.Fatalf("活着时应当有 lastSeenAt 且没有 lastError:%+v", live)
}
if live.Endpoint != srv.URL {
t.Fatalf("endpoint = %q,期望 %q", live.Endpoint, srv.URL)
}
// 停用:connected 是**确认的 false**(有意没连),工具则是未知。
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
off := statusOfOne(t, mgr, "calc")
if off.State != ServerStateDisabled || off.Connected == nil || *off.Connected {
t.Fatalf("停用快照 = %+v", off)
}
if off.ToolCount != nil || off.Tools != nil {
t.Fatalf("停用时工具应当是**未知**(缺席),得到 %+v / %+v", off.ToolCount, off.Tools)
}
// 未知:文件里有这一条,但本次进程从没连过也没失败过(手工改文件就是这样)。
handEdited := runtimeconfig.PluginConfig{Name: "manual", Kind: runtimeconfig.PluginKindMCP,
Transport: runtimeconfig.TransportHTTP, Endpoint: "https://example.invalid/mcp"}
if err := runtimeconfig.SavePlugins(path, []runtimeconfig.PluginConfig{handEdited}); err != nil {
t.Fatalf("SavePlugins: %v", err)
}
unknown := statusOfOne(t, mgr, "manual")
if unknown.State != ServerStateUnknown {
t.Fatalf("state = %q,期望 unknown", unknown.State)
}
if unknown.Connected != nil {
t.Fatalf("未知的 connected 必须**缺席**(不得用零值冒充未连接),得到 %v", *unknown.Connected)
}
if unknown.ToolCount != nil || unknown.LastSeenAt != "" || unknown.LastError != "" {
t.Fatalf("未知时不该有工具/时间/错误:%+v", unknown)
}
}
// TestStatusesFailureIsVisible 锁定 ③:连不上的**新装**attempt 也要出现在快照里。
func TestStatusesFailureIsVisible(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
dead := httptest.NewServer(http.NotFoundHandler())
deadURL := dead.URL
dead.Close()
mgr := NewManager(registry.New(), path, nil)
defer mgr.Close(context.Background())
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: "down", Transport: runtimeconfig.TransportHTTP, Endpoint: deadURL,
}); err == nil {
t.Fatal("对关掉的 server 安装应当失败")
}
st := statusOfOne(t, mgr, "down")
if st.State != ServerStateError || st.Connected == nil || *st.Connected {
t.Fatalf("失败快照 = %+v,期望 error 且 connected=false", st)
}
if st.LastError == "" {
t.Fatal("失败快照没有 lastError:面板说不出病因")
}
if st.Enabled != true {
t.Fatalf("失败的那台是「想启用但没连上」,enabled 应为 true:%+v", st)
}
}
// TestStatusesNeverLeakAPIKey 锁定 ②:快照 JSON 里不得出现 apiKey 或它的任意片段。
//
// 覆盖两条最容易漏的路:① 正常快照里的配置字段;② 失败文本(MCP 客户端/上游可能把
// Bearer 令牌写进错误里)。片段检查是「变异自证」:把密钥切成若干 8 字符片段再各查一次。
func TestStatusesNeverLeakAPIKey(t *testing.T) {
t.Parallel()
const key = "sk-live-SECRET-9f3c2b1d"
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, key, "add")
mgr := NewManager(registry.New(), path, nil)
defer mgr.Close(context.Background())
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: "calc", Transport: runtimeconfig.TransportHTTP, Endpoint: srv.URL, APIKey: key,
}); err != nil {
t.Fatalf("Install: %v", err)
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil || len(cfgs) != 1 {
t.Fatalf("LoadPlugins = %+v, %v", cfgs, err)
}
cfgWithKey := cfgs[0]
// 失败文本里塞进整把钥匙和它的中段(这正是「上游把凭据回显进 401」的形状)。
mgr.noteFailure(cfgWithKey, errors.New("401 Unauthorized: bearer "+key+" rejected; echo=SECRET-9f3"))
mgr.noteFailure(cfgWithKey, errors.New("dial "+srv.URL+"?api_key="+key+" failed"))
raw, err := json.Marshal(mgr.Statuses())
if err != nil {
t.Fatalf("marshal statuses: %v", err)
}
body := string(raw)
assertNoSecret(t, body, key)
if !strings.Contains(body, "failed") {
t.Fatalf("抹凭据不该把病因整条抹掉:%s", body)
}
}
// TestRedactPluginSecrets 直接钉住抹除函数:整把、前缀、中段、后缀都不许留下。
func TestRedactPluginSecrets(t *testing.T) {
t.Parallel()
const key = "tok_ABCDEF123456"
cfg := runtimeconfig.PluginConfig{APIKey: key,
Env: map[string]string{"GITHUB_TOKEN": "ghp_ZZZZZZ9999", "PLAIN": "keep-me"}}
cases := []string{
"authorization: Bearer " + key,
"prefix " + key[:8] + " suffix",
"middle ABCDEF123456 and more",
"env dump GITHUB_TOKEN=ghp_ZZZZZZ9999",
}
for _, in := range cases {
out := runtimeconfig.RedactPluginSecrets(cfg, in)
assertNoSecret(t, out, key)
assertNoSecret(t, out, "ghp_ZZZZZZ9999")
}
// 非敏感值不该被连带抹掉(否则排障信息会被无谓地毁掉)。
if out := runtimeconfig.RedactPluginSecrets(cfg, "plain keep-me"); !strings.Contains(out, "keep-me") {
t.Fatalf("非敏感值被误抹:%q", out)
}
}
// assertNoSecret 断言 text 不含 secret 本身,也不含它的任意 8 字符片段。
func assertNoSecret(t *testing.T, text, secret string) {
t.Helper()
if strings.Contains(text, secret) {
t.Fatalf("文本里出现了完整凭据 %q:%s", secret, text)
}
const frag = 8
for i := 0; i+frag <= len(secret); i++ {
if part := secret[i : i+frag]; strings.Contains(text, part) {
t.Fatalf("文本里出现了凭据片段 %q:%s", part, text)
}
}
}
+111
View File
@@ -0,0 +1,111 @@
package registry
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住「来源章」(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①):
// 归属只能来自注册时的元数据,因此盖章必须发生在**注册汇点**上,且热替换不能把它抹掉。
//
// 三条判据各自**能变红**:
// - 去掉 RegisterTool 里的盖章 → 第一条红;
// - 去掉 ReplaceTool 里的盖章(它逐字段重建条目)→ 第二条红;
// - 给所有工具都编一个章(比如按名字猜)→ 第三条红。
// originTool 是自报来源的最小工具(宿主内置工具不实现 sdk.OriginAware)。
type originTool struct {
name string
origin sdk.Origin
}
func (t originTool) Name() string { return t.name }
func (t originTool) Description() string { return "来自 MCP" }
func (t originTool) Parameters() (map[string]any, error) {
return map[string]any{"type": "object"}, nil
}
func (t originTool) Execute(_ context.Context, _ any) (any, error) {
return nil, nil
}
func (t originTool) Origin() sdk.Origin { return t.origin }
var _ sdk.OriginAware = originTool{}
// TestRegisterToolStampsOrigin:注册汇点把工具自报的来源盖在能力条目上。
func TestRegisterToolStampsOrigin(t *testing.T) {
r := New()
want := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"}
if err := r.RegisterTool(originTool{name: "read_file_mcp", origin: want}); err != nil {
t.Fatalf("RegisterTool: %v", err)
}
cap, err := r.Get("read_file_mcp")
if err != nil {
t.Fatalf("Get: %v", err)
}
if cap.Origin != want {
t.Fatalf("条目上的来源章 = %+v, want %+v(RegisterTool 是唯一汇点,必须在这里盖章)", cap.Origin, want)
}
}
// TestReplaceToolKeepsOrigin:热替换必须重新盖章 —— 它逐字段重建 Capability,
// 漏一位就等于「面板保存一次,这把工具从它的 server 分节里掉进内置」。
func TestReplaceToolKeepsOrigin(t *testing.T) {
r := New()
origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "http"}
if err := r.RegisterTool(originTool{name: "read_file_mcp", origin: origin}); err != nil {
t.Fatalf("RegisterTool: %v", err)
}
// 热替换成同名的新实现,来源相同(换实现不换 server)。
if err := r.ReplaceTool(originTool{name: "read_file_mcp", origin: origin}); err != nil {
t.Fatalf("ReplaceTool: %v", err)
}
cap, err := r.Get("read_file_mcp")
if err != nil {
t.Fatalf("Get: %v", err)
}
if cap.Origin != origin {
t.Fatalf("热替换后来源章 = %+v, want %+v(ReplaceTool 重建条目,必须重新盖章)", cap.Origin, origin)
}
}
// TestRegisterToolLeavesBuiltinOriginZero:宿主内置工具(没实现 OriginAware)
// 的条目**没有**来源章。它是「键缺席」的根据 —— 读面据此省略 origin,而不是编一个。
func TestRegisterToolLeavesBuiltinOriginZero(t *testing.T) {
r := New()
if err := r.RegisterTool(&stubTool{name: "web_search", desc: "内置"}); err != nil {
t.Fatalf("RegisterTool: %v", err)
}
cap, err := r.Get("web_search")
if err != nil {
t.Fatalf("Get: %v", err)
}
if !cap.Origin.IsZero() {
t.Fatalf("内置工具却带来源章 %+v:没有 OriginAware 就不得编一个来源", cap.Origin)
}
}
// TestSetPolicyKeepsOrigin:策略写路径不受来源章影响,也不许把它覆盖掉。
// 这是「tool_policies / 权限闸零影响」的断言 —— 策略仍按**名字**存,
// 来源章只在条目上,两者互不干涉。
func TestSetPolicyKeepsOrigin(t *testing.T) {
r := New()
origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"}
if err := r.RegisterTool(originTool{name: "read_file_mcp", origin: origin}); err != nil {
t.Fatalf("RegisterTool: %v", err)
}
if err := r.SetAllowInGroup("read_file_mcp", true); err != nil {
t.Fatalf("SetAllowInGroup: %v", err)
}
cap, err := r.Get("read_file_mcp")
if err != nil {
t.Fatalf("Get: %v", err)
}
if !cap.Policy.AllowInGroup || cap.Policy.AllowInStranger {
t.Fatalf("策略 = %+v, want 只有 AllowInGroup=true(按名字写的策略行)", cap.Policy)
}
if cap.Origin != origin {
t.Fatalf("改策略把来源章冲掉了:%+v, want %+v", cap.Origin, origin)
}
}
+28
View File
@@ -58,6 +58,12 @@ type Capability struct {
// Policy is the editable reachability markers for this capability. It // Policy is the editable reachability markers for this capability. It
// defaults to zero, which the policy engine reads as owner-only. // defaults to zero, which the policy engine reads as owner-only.
Policy ToolPolicy Policy ToolPolicy
// Origin 是能力的**上游来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md
// 方案①):由工具自己通过 sdk.OriginAware 声明,注册汇点
// RegisterTool/ReplaceTool 盖在这里。零值 = 宿主内置 / 来源未知 ——
// 这枚章**只用于呈现归属**,不参与权限判定、不进模型可见的工具面
// (restrict 只按名字查策略,ToolSchemas 只取 Name/Description/Parameters)。
Origin sdk.Origin
// Value is the underlying capability object. For KindTool/KindSkill/ // Value is the underlying capability object. For KindTool/KindSkill/
// KindChannel it is the SDK interface from pkg/plugins/sdk; for KindMCP and // KindChannel it is the SDK interface from pkg/plugins/sdk; for KindMCP and
// KindIoT it is owned by the relevant subsystem. // KindIoT it is owned by the relevant subsystem.
@@ -225,6 +231,13 @@ func (r *Registry) setPolicyField(name, field string, apply func(*ToolPolicy)) e
} }
// RegisterTool registers t as a KindTool capability. // RegisterTool registers t as a KindTool capability.
//
// 它是**来源章的唯一汇点**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①):
// MCP 工具的**两条接入路径**都从这里进注册表 —— 运行时热装
// (plugin.Manager.start → mcp.NewRegistry → mcp.Registry.Register)与 `.env` 的
// MCP_SERVERS_JSON 启动期装(cmd/cyrene/mcp.go,不经 Manager)—— 因此在这一处盖章
// 一次就覆盖两条路(以及 tools/list_changed 的增量 refresh)。
// 不从 Manager 现场反推归属:那会漏掉 env 路径,把现役接入方式误报成「无来源」。
func (r *Registry) RegisterTool(t sdk.Tool) error { func (r *Registry) RegisterTool(t sdk.Tool) error {
if t == nil { if t == nil {
return fmt.Errorf("registry: register tool: %w", ErrInvalidArgument) return fmt.Errorf("registry: register tool: %w", ErrInvalidArgument)
@@ -233,10 +246,21 @@ func (r *Registry) RegisterTool(t sdk.Tool) error {
Name: t.Name(), Name: t.Name(),
Kind: KindTool, Kind: KindTool,
Description: t.Description(), Description: t.Description(),
Origin: originOf(t),
Value: t, Value: t,
}) })
} }
// originOf 返回 t 自报的来源章;没实现 sdk.OriginAware(宿主内置工具)或自报零值时
// 返回零值 Origin —— 零值在线上表现为**键缺席**,不是一个空对象。
func originOf(t sdk.Tool) sdk.Origin {
origin, ok := sdk.OriginOf(t)
if !ok {
return sdk.Origin{}
}
return origin
}
// ReplaceTool 用**同名**的 t 就地换掉已登记的工具条目(热替换,design/09 §5.5: // ReplaceTool 用**同名**的 t 就地换掉已登记的工具条目(热替换,design/09 §5.5:
// 面板保存后下一轮即用新接线,不必重启)。 // 面板保存后下一轮即用新接线,不必重启)。
// //
@@ -270,6 +294,10 @@ func (r *Registry) ReplaceTool(t sdk.Tool) error {
Description: t.Description(), Description: t.Description(),
// Policy 沿用旧条目的:它是可编辑的运行期配置,不属于工具的构造产出。 // Policy 沿用旧条目的:它是可编辑的运行期配置,不属于工具的构造产出。
Policy: cur.Policy, Policy: cur.Policy,
// Origin **必须重新盖章**:这里逐字段重建条目,漏掉一位就等于「热替换把来源
// 抹掉」,面板上那把工具会从它的 server 分节里掉进「宿主内置」。来源是工具的
// 构造产出(跟 RegisterTool 同一口径取),不沿用旧条目 —— 换了实现就该换章。
Origin: originOf(t),
Value: t, Value: t,
} }
return nil return nil
+11 -2
View File
@@ -3,6 +3,8 @@ package restrict
import ( import (
"strings" "strings"
"unicode/utf8" "unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
) )
// SensitiveOp 是 design/11 §4「敏感清单」里的操作类别。它只用于审计与前端 // SensitiveOp 是 design/11 §4「敏感清单」里的操作类别。它只用于审计与前端
@@ -45,7 +47,8 @@ var SensitiveOps = []SensitiveOp{
} }
// sensitiveSummaryRunes 截断动作摘要:代审小字与审计只需一眼看出「想干什么」, // sensitiveSummaryRunes 截断动作摘要:代审小字与审计只需一眼看出「想干什么」,
// 同时限制进入前端/日志的文本块大小(也降低把长参数里的秘密整段搬进日志的机会)。 // 同时限制进入前端/日志的文本块大小。它只管**长度** —— 秘密由 summarize 里的
// trace.RedactText 负责打码,不靠截断「顺手甩掉」。
const sensitiveSummaryRunes = 80 const sensitiveSummaryRunes = 80
// SensitiveCall 判定一次工具调用是否属敏感操作(design/11 §4)。 // SensitiveCall 判定一次工具调用是否属敏感操作(design/11 §4)。
@@ -135,8 +138,14 @@ func httpCall(args map[string]any) (SensitiveOp, string, bool) {
// summarize 拼接「动作 + 目标」摘要并按 sensitiveSummaryRunes 截断(不切开 // summarize 拼接「动作 + 目标」摘要并按 sensitiveSummaryRunes 截断(不切开
// 多字节字符);目标为空时只返回动作。 // 多字节字符);目标为空时只返回动作。
//
// 目标先过 trace.RedactText 打码:这个摘要会进审计与前端小字两个读面,而
// terminal 的 command / http 的 url 里常常就带着凭据(`curl -H 'api_key=…'`、
// `?token=…`),截断并不能让它们变得安全。RedactText 自身已按同一档(80 rune)
// 截断,后面的 clipRunes 保留是为了让长度上限继续由本包的
// sensitiveSummaryRunes 掌握 —— 两处同档,叠加不改变结果。
func summarize(action, target string) string { func summarize(action, target string) string {
target = strings.TrimSpace(target) target = trace.RedactText(strings.TrimSpace(target))
if target == "" { if target == "" {
return action return action
} }
@@ -77,3 +77,41 @@ func TestSensitiveCall_SummaryIsClipped(t *testing.T) {
t.Fatalf("被截断的摘要应以省略号结尾: %q", detail) t.Fatalf("被截断的摘要应以省略号结尾: %q", detail)
} }
} }
// TestSensitiveCall_SummaryIsRedacted 锁定「参数摘要进审计与前端小字之前必须
// 打码」:terminal 的 command、http 的 url 里常常就带着凭据,截断不是脱敏。
func TestSensitiveCall_SummaryIsRedacted(t *testing.T) {
t.Parallel()
const secret = "sk-live-9f8e7d6c5b4a3210"
cases := []struct {
name string
args map[string]any
start string // 摘要应保留的开头(打码不能把摘要打没)
}{
{
name: "terminal",
args: map[string]any{"command": "curl -H 'api_key=" + secret + "' https://x"},
start: "terminal ",
},
{
name: "http",
args: map[string]any{"method": "POST", "url": "https://x/hook?token=" + secret},
start: "http POST ",
},
}
for _, tc := range cases {
_, detail, sensitive := SensitiveCall(tc.name, tc.args)
if !sensitive {
t.Fatalf("%s 应判定为敏感", tc.name)
}
if strings.Contains(detail, secret) {
t.Fatalf("%s 摘要泄露了凭据原文: %q", tc.name, detail)
}
if !strings.Contains(detail, "[redacted]") {
t.Fatalf("%s 摘要应带打码标记: %q", tc.name, detail)
}
if !strings.HasPrefix(detail, tc.start) {
t.Fatalf("%s 摘要应以 %q 开头(动作不能被一起打掉): %q", tc.name, tc.start, detail)
}
}
}
+113 -98
View File
@@ -13,21 +13,32 @@ import (
// It is a pure noise/importance filter (design/12 §6 策略四). Implementations must // It is a pure noise/importance filter (design/12 §6 策略四). Implementations must
// never decide identity: who the sender is was already resolved structurally by the // never decide identity: who the sender is was already resolved structurally by the
// channel layer. A nil Reviewer makes the gate fall back to a plain FIFO cap. // channel layer. A nil Reviewer makes the gate fall back to a plain FIFO cap.
//
// The gate calls it **off the admission path** (see FlowGate.Admit), so an
// implementation may take as long as the model takes; it is bounded by
// reviewTimeout and by the per-window budget, never by the caller's patience.
type Reviewer func(ctx context.Context, msgs []UnifiedMessage) (keep []int, err error) type Reviewer func(ctx context.Context, msgs []UnifiedMessage) (keep []int, err error)
// GateVerdict reports how the gate classified one inbound message. // GateVerdict reports how the gate classified one inbound message.
type GateVerdict struct { type GateVerdict struct {
// Proceed reports whether the message should be delivered downstream. // Proceed reports whether the message should be delivered downstream.
Proceed bool Proceed bool
// Reason is a short token describing a drop: "rate_limited", "review_pruned" // Reason is a short token describing a drop. After M24 it is always
// or "capped". It is empty when Proceed is true. // "rate_limited" (over the sliding window — dropped at the door, the message
// never enters the pending set), because nothing else on the admission path can
// refuse a message: the pending cap evicts the **oldest** entries and the review
// model runs in the background, so neither can reject the message that just
// arrived. The audit hook still reports their drops with "capped" /
// "review_pruned"; those are retained-message bookkeeping, not admissions.
// Reason is empty when Proceed is true.
Reason string Reason string
// DroppedMsg is the message that failed admission (nil when Proceed is true). // DroppedMsg is the message that failed admission (nil when Proceed is true).
DroppedMsg *UnifiedMessage DroppedMsg *UnifiedMessage
// Pruned lists every message the gate dropped during this admission. On the // Pruned lists every message the gate dropped during this admission: on the cap
// review path it is the noise the reviewer rejected; on the cap path it is the // path it is the oldest messages evicted to honor the total cap. Each is recorded
// oldest messages evicted to honor the total cap. Each is recorded to the // to the OnPruned audit hook before being dropped, so nothing is lost silently.
// OnPruned audit hook before being dropped, so nothing is lost silently. // Backlog drops made by a background review reach the audit hook with
// "review_pruned" when that review completes, not through this field.
Pruned []UnifiedMessage Pruned []UnifiedMessage
} }
@@ -56,15 +67,23 @@ type FlowGateOptions struct {
MsgPerMin int MsgPerMin int
// MaxMessages is the **per-conversation** pending-message cap (design/12 §6 // MaxMessages is the **per-conversation** pending-message cap (design/12 §6
// 策略四 / 裁决 06.4.3). When a conversation's pending set nears it the Reviewer // 策略四 / 裁决 06.4.3). When a conversation's pending set nears it the Reviewer
// is invoked to prune noise; with no Reviewer the oldest messages are evicted // is invoked — in the background — to prune noise; with no Reviewer the oldest
// FIFO. Zero disables the cap. // messages are evicted FIFO. Zero disables the cap.
MaxMessages int MaxMessages int
// Window is the sliding window over which the per-minute limits are counted. // Window is the sliding window over which the per-minute limits are counted.
// Zero uses defaultRateWindow. // Zero uses defaultRateWindow.
Window time.Duration Window time.Duration
// Review is the optional review model. When nil the gate enforces the cap with // Review is the optional review model. When nil the gate enforces the cap with
// FIFO eviction instead of model judgment. // FIFO eviction instead of model judgment. It is always called from a background
// goroutine, at most once per conversation per Window, with at most one call in
// flight per gate.
Review Reviewer Review Reviewer
// ReviewPerMin is the hard per-account ceiling on Reviewer calls started per
// Window: the budget that bounds review cost no matter how many conversations
// cross their own threshold in the same window. Zero (or negative) uses
// defaultReviewPerWindow. It bounds calls started, not calls finished, so the
// bound holds independently of model latency.
ReviewPerMin int
// OnPruned is called once per pruned/dropped message with a short reason token // OnPruned is called once per pruned/dropped message with a short reason token
// ("review_pruned" or "capped") so the caller can record an audit trail. // ("review_pruned" or "capped") so the caller can record an audit trail.
// It may be nil (then the caller only sees Pruned on the verdict). // It may be nil (then the caller only sees Pruned on the verdict).
@@ -80,6 +99,13 @@ const (
// runs. Design/12 §6 策略四 says "maxMessages 的 80%": this is the cost guard // runs. Design/12 §6 策略四 says "maxMessages 的 80%": this is the cost guard
// that keeps review from firing on every message. // that keeps review from firing on every message.
defaultReviewPct = 80 defaultReviewPct = 80
// defaultReviewPerWindow is the hard per-adapter ceiling on reviewer calls per
// window (6/minute, the same order of magnitude as FlowLimitDefault). Before it
// existed the only bound was "one per conversation per window", which an account
// with many conversations could multiply without limit. At p50 5.3 s per call
// this still allows ~half a minute of review work per minute per account, so it
// bounds the tail without starving normal traffic.
defaultReviewPerWindow = 6
// FlowLimitDefault is the protective default of 6 group messages per minute // FlowLimitDefault is the protective default of 6 group messages per minute
// (design/12 §6 策略三). It is applied when an account pins no group rate limit. // (design/12 §6 策略三). It is applied when an account pins no group rate limit.
FlowLimitDefault = 6 FlowLimitDefault = 6
@@ -128,6 +154,10 @@ type flowSession struct {
groupHits []time.Time groupHits []time.Time
directHits []time.Time directHits []time.Time
buffer []queuedMsg buffer []queuedMsg
// lastReview 是**最近一次审查被派发**的时刻(不是完成时刻,也无论成败):
// 写在这里而不是「成功之后」,失败路径才同样被窗口节流(修复前它只在
// err == nil 分支置位,而 68% 的审查因对端回带 BOM 解析失败 ⇒ 缓冲满之后
// 每条入站帧都付一次 ~6 s 的模型调用)。
lastReview time.Time lastReview time.Time
// inFlight 是该会话**正在跑**的回合数(AcquireTurn/ReleaseTurn 维护)。受限车道 // inFlight 是该会话**正在跑**的回合数(AcquireTurn/ReleaseTurn 维护)。受限车道
// 用它把「同一发送者同时最多 1 个在跑的回合」落到实处。 // 用它把「同一发送者同时最多 1 个在跑的回合」落到实处。
@@ -148,6 +178,14 @@ type flowSession struct {
// //
// 两个策略的计数都按会话独立(裁决 06.4.3),只有 MsgPerMin 总量护栏按账号汇总。 // 两个策略的计数都按会话独立(裁决 06.4.3),只有 MsgPerMin 总量护栏按账号汇总。
// //
// **审查不在准入路径上**:Admit 只读待处理集合里已经存在的结论(限流计数、
// 上一批已完成审查留下的队列),触发条件满足时只把一次审查**交给后台**,因此
// 适配器的读 goroutine 永远不会等模型 —— 修复前它会同步等一次 `noise_review`
// (实测 p50 5.3 s / p95 6.7 s / max 9.2 s),把消息、心跳与探活回执全排在同一条
// 队上(见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。后台审查的三重上界:
// 同一适配器至多一次在飞(reviewInFlight)、同一会话每 Window 至多一次
// (lastReview)、整个适配器每 Window 至多 defaultReviewPerWindow 次(reviewHits)。
//
// Owner-private messages are not routed through the gate (the caller bypasses it), // Owner-private messages are not routed through the gate (the caller bypasses it),
// so the owner is never throttled or pruned. Identity is never examined here. // so the owner is never throttled or pruned. Identity is never examined here.
// The gate also carries the per-conversation running-turn slot (AcquireTurn / // The gate also carries the per-conversation running-turn slot (AcquireTurn /
@@ -160,24 +198,48 @@ type FlowGate struct {
reviewAt int reviewAt int
window time.Duration window time.Duration
review Reviewer review Reviewer
reviewBudget int
onPruned func(UnifiedMessage, string) onPruned func(UnifiedMessage, string)
now func() time.Time now func() time.Time
mu sync.Mutex mu sync.Mutex
totalHits []time.Time totalHits []time.Time
sessions map[string]*flowSession // reviewHits 是本适配器在窗口内**派发**审查的时刻,用于账号级预算
admitted uint64 // (defaultReviewPerWindow)。
seq uint64 reviewHits []time.Time
// reviewInFlight 表示本适配器已有一次审查在后台跑:同一适配器至多一次在飞,
// 使后台审查有界(不会因为多个群同时越阈而并发起 N 个模型调用)。
reviewInFlight bool
sessions map[string]*flowSession
admitted uint64
seq uint64
} }
// queuedMsg wraps a buffer entry with a stable sequence so pruning decisions can // queuedMsg wraps a buffer entry with a stable sequence so pruning decisions can
// be audited and the fate of the just-admitted message be tracked across index // be audited and the fate of the just-admitted message be tracked across index
// shifts (value equality on UnifiedMessage is unsafe because it holds slices). // shifts (value equality on UnifiedMessage is unsafe because it holds slices).
// The sequence also lets a review verdict land on the entries the reviewer actually
// saw, even when the buffer has moved on while the call was in flight.
type queuedMsg struct { type queuedMsg struct {
seq uint64 seq uint64
msg UnifiedMessage msg UnifiedMessage
} }
// ReviewAtFor 返回给定待处理上限对应的审查触发水位(design/12 §6 策略四:
// maxMessages 的 80%,至少 1)。它是 NewFlowGate 内部同一处的口径,导出只为让调用方
// **如实报出生效值**(例如 cmd/cyrene 的逐账号入口流控启动日志),而不是在别处另写
// 一遍 80% 的算法。非正上限表示上限关闭,返回 0(永不触发审查)。
func ReviewAtFor(maxMessages int) int {
if maxMessages <= 0 {
return 0
}
at := maxMessages * defaultReviewPct / 100
if at < 1 {
return 1
}
return at
}
// NewFlowGate builds a gate from opts. Options that are zero disable their limit, // NewFlowGate builds a gate from opts. Options that are zero disable their limit,
// except Window which defaults to one minute when left zero. // except Window which defaults to one minute when left zero.
func NewFlowGate(opts FlowGateOptions) *FlowGate { func NewFlowGate(opts FlowGateOptions) *FlowGate {
@@ -188,6 +250,7 @@ func NewFlowGate(opts FlowGateOptions) *FlowGate {
maxMessages: opts.MaxMessages, maxMessages: opts.MaxMessages,
window: opts.Window, window: opts.Window,
review: opts.Review, review: opts.Review,
reviewBudget: opts.ReviewPerMin,
onPruned: opts.OnPruned, onPruned: opts.OnPruned,
now: opts.Now, now: opts.Now,
sessions: make(map[string]*flowSession), sessions: make(map[string]*flowSession),
@@ -195,22 +258,24 @@ func NewFlowGate(opts FlowGateOptions) *FlowGate {
if g.window <= 0 { if g.window <= 0 {
g.window = defaultRateWindow g.window = defaultRateWindow
} }
if g.maxMessages > 0 { if g.reviewBudget <= 0 {
g.reviewAt = g.maxMessages * defaultReviewPct / 100 g.reviewBudget = defaultReviewPerWindow
if g.reviewAt < 1 {
g.reviewAt = 1
}
} }
g.reviewAt = ReviewAtFor(g.maxMessages)
if g.now == nil { if g.now == nil {
g.now = time.Now g.now = time.Now
} }
return g return g
} }
// Admit applies the rate limits then the per-conversation cap/review gate to one // Admit applies the rate limits then the per-conversation cap gate to one inbound
// inbound message. It returns a verdict telling the caller whether to proceed; // message. It returns a verdict telling the caller whether to proceed; messages
// messages that the gate drops are returned (and audited) so nothing is lost // that the gate drops are returned (and audited) so nothing is lost silently.
// silently. //
// **它从不同步调用审查模型**:限流与上限是 O(1) 的判定,审查只是一次「后台修剪」
// 的派发。修复前这里同步等 `noise_review`(p50 5.3 s),而五路入站里有三路
// (qq-ws、kook-ws、weixin 长轮询)是「单读 goroutine + 同步派发」,于是消息、
// 心跳、探活回执共享同一条 6 s 队列 —— 主人口中「消息一直排队」的机制。
// //
// 计数按会话独立(裁决 06.4.3):群聊滑窗与待处理上限都落在 ConversationKey 对应的 // 计数按会话独立(裁决 06.4.3):群聊滑窗与待处理上限都落在 ConversationKey 对应的
// flowSession 上(两个群并发互不挤占),只有 MsgPerMin 这一账号级总量护栏仍按适配器汇总。 // flowSession 上(两个群并发互不挤占),只有 MsgPerMin 这一账号级总量护栏仍按适配器汇总。
@@ -218,48 +283,55 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
now := g.now() now := g.now()
key := ConversationKey(msg) key := ConversationKey(msg)
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock()
g.maybeGCLocked(now) g.maybeGCLocked(now)
verdict, job := g.admitLocked(msg, key, now)
g.mu.Unlock()
if job != nil {
// 派发在解锁之后:后台审查自己会加锁把结论落回待处理集合,绝不能让它
// 与这次准入抢同一把锁(也绝不能让准入等它)。
go g.runReview(ctx, job)
}
return verdict
}
// admitLocked runs the whole admission decision under g.mu and returns the verdict
// plus, when due, the background review job the caller must launch after unlocking.
// It never calls the Reviewer itself.
func (g *FlowGate) admitLocked(msg UnifiedMessage, key string, now time.Time) (GateVerdict, *reviewJob) {
// 策略三(会话级): sliding-window rate limits. 群聊上限按每个群独立计数;MsgPerMin // 策略三(会话级): sliding-window rate limits. 群聊上限按每个群独立计数;MsgPerMin
// 是账号级的非主人消息总量护栏。Over-limit messages are dropped at the door // 是账号级的非主人消息总量护栏。Over-limit messages are dropped at the door
// (downgraded) instead of blocking. // (downgraded) instead of blocking. 这一段的语义与修复前**一字不改**:
// 被限流的消息根本不进待处理集合,也永远不会因为审查被「捞回来」。
if g.groupPerMin > 0 && msg.ChannelType == ChannelGroup { if g.groupPerMin > 0 && msg.ChannelType == ChannelGroup {
st := g.sessionLocked(key) st := g.sessionLocked(key)
st.groupHits = g.trimHits(st.groupHits, now) st.groupHits = g.trimHits(st.groupHits, now)
if len(st.groupHits) >= g.groupPerMin { if len(st.groupHits) >= g.groupPerMin {
return g.drop("rate_limited", msg) return g.drop("rate_limited", msg), nil
} }
st.groupHits = append(st.groupHits, now) st.groupHits = append(st.groupHits, now)
} }
if g.msgPerMin > 0 { if g.msgPerMin > 0 {
g.totalHits = g.trimHits(g.totalHits, now) g.totalHits = g.trimHits(g.totalHits, now)
if len(g.totalHits) >= g.msgPerMin { if len(g.totalHits) >= g.msgPerMin {
return g.drop("rate_limited", msg) return g.drop("rate_limited", msg), nil
} }
g.totalHits = append(g.totalHits, now) g.totalHits = append(g.totalHits, now)
} }
if g.maxMessages <= 0 { if g.maxMessages <= 0 {
return GateVerdict{Proceed: true} return GateVerdict{Proceed: true}, nil
} }
// 策略四(会话级): add to this conversation's pending set, then prune when it // 策略四(会话级): add to this conversation's pending set, then enforce the cap
// nears the cap. // synchronously. 审查只被**派发**(下面的 scheduleReviewLocked),结论稍后由
// 后台落回 —— 准入读的是「当前已有结论」。
st := g.sessionLocked(key) st := g.sessionLocked(key)
g.seq++ g.seq++
seq := g.seq seq := g.seq
st.buffer = append(st.buffer, queuedMsg{seq: seq, msg: msg}) st.buffer = append(st.buffer, queuedMsg{seq: seq, msg: msg})
var dropped []queuedMsg var dropped []queuedMsg
if g.review != nil && len(st.buffer) >= g.reviewAt && !g.reviewRecently(st, now) {
keep, err := g.review(ctx, queueMessages(st.buffer))
if err == nil {
st.buffer, dropped = g.applyReview(st.buffer, keep)
st.lastReview = now
g.auditDropped(dropped, "review_pruned")
}
}
if len(st.buffer) > g.maxMessages { if len(st.buffer) > g.maxMessages {
n := len(st.buffer) - g.maxMessages n := len(st.buffer) - g.maxMessages
var evicted []queuedMsg var evicted []queuedMsg
@@ -267,18 +339,12 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
dropped = append(dropped, evicted...) dropped = append(dropped, evicted...)
g.auditDropped(evicted, "capped") g.auditDropped(evicted, "capped")
} }
job := g.scheduleReviewLocked(st, key, now)
if !bufferHasSeq(st.buffer, seq) { // 本条一定还在缓冲里:上限按新旧顺序丢最旧,而它是最新的(maxMessages ≥ 1)。
reason := "capped" // 于是修复后准入路径上唯一能让消息 Proceed=false 的判据就是上面的限流 —— 审查
if containsSeq(dropped, seq) { // (已移到后台)与上限(丢最旧)都只影响**已经放行过的**历史条目,只留审计痕迹。
reason = "review_pruned" return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}, job
}
v := GateVerdict{Proceed: false, Reason: reason, Pruned: queueMessages(dropped)}
c := msg
v.DroppedMsg = &c
return v
}
return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}
} }
// AllowDirect enforces the per-sender sliding-window rate limit for a **direct // AllowDirect enforces the per-sender sliding-window rate limit for a **direct
@@ -385,15 +451,6 @@ func (g *FlowGate) maybeGCLocked(now time.Time) {
} }
} }
// reviewRecently throttles review so it does not run on every message: it fires at
// most once per window per conversation, which is the cost guard for the review model.
func (g *FlowGate) reviewRecently(st *flowSession, now time.Time) bool {
if st == nil || st.lastReview.IsZero() {
return false
}
return now.Sub(st.lastReview) < g.window
}
// trimHits drops event timestamps older than the sliding window. // trimHits drops event timestamps older than the sliding window.
func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time { func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time {
cutoff := now.Add(-g.window) cutoff := now.Add(-g.window)
@@ -407,27 +464,6 @@ func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time {
return hits[:keep] return hits[:keep]
} }
// applyReview keeps only the entries at the returned indices (validated, de-duped,
// ascending) and returns the kept slice plus the dropped entries in original order.
func (g *FlowGate) applyReview(buffer []queuedMsg, keep []int) (kept, dropped []queuedMsg) {
seen := make(map[int]bool, len(keep))
for _, i := range keep {
if i >= 0 && i < len(buffer) {
seen[i] = true
}
}
out := make([]queuedMsg, 0, len(buffer))
drop := make([]queuedMsg, 0)
for i, entry := range buffer {
if seen[i] {
out = append(out, entry)
} else {
drop = append(drop, entry)
}
}
return out, drop
}
// dropFront removes n entries from the front (oldest) and returns the rest plus the // dropFront removes n entries from the front (oldest) and returns the rest plus the
// dropped ones. // dropped ones.
func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedMsg) { func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedMsg) {
@@ -437,27 +473,6 @@ func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedM
return buffer[n:], append([]queuedMsg(nil), buffer[:n]...) return buffer[n:], append([]queuedMsg(nil), buffer[:n]...)
} }
// bufferHasSeq reports whether an entry with the given sequence is still buffered
// in the conversation's pending set.
func bufferHasSeq(buffer []queuedMsg, seq uint64) bool {
for _, e := range buffer {
if e.seq == seq {
return true
}
}
return false
}
// containsSeq reports whether seq appears among the dropped entries.
func containsSeq(dropped []queuedMsg, seq uint64) bool {
for _, e := range dropped {
if e.seq == seq {
return true
}
}
return false
}
// auditDropped records every dropped entry to the OnPruned audit hook. // auditDropped records every dropped entry to the OnPruned audit hook.
func (g *FlowGate) auditDropped(dropped []queuedMsg, reason string) { func (g *FlowGate) auditDropped(dropped []queuedMsg, reason string) {
for _, e := range dropped { for _, e := range dropped {
@@ -0,0 +1,411 @@
package adapter
// 本文件覆盖 M24:把策略四的审查**移出准入路径**,并让节流在失败路径同样生效。
// 每条用例都对应一条可变异点(见 M24 回报里的「预期会红的用例名」)。
import (
"context"
"fmt"
"sync"
"testing"
"time"
)
// auditRecorder 收集 OnPruned 的审计回调。审查结论来自后台 goroutine,因此这里
// 必须加锁(-race 会盯)。
type auditRecorder struct {
mu sync.Mutex
events []auditEvent
}
type auditEvent struct {
content string
reason string
}
func (r *auditRecorder) hook() func(UnifiedMessage, string) {
return func(m UnifiedMessage, reason string) {
r.mu.Lock()
r.events = append(r.events, auditEvent{content: m.Content, reason: reason})
r.mu.Unlock()
}
}
func (r *auditRecorder) snapshot() []auditEvent {
r.mu.Lock()
defer r.mu.Unlock()
return append([]auditEvent(nil), r.events...)
}
func (r *auditRecorder) reasons() []string {
out := make([]string, 0)
for _, e := range r.snapshot() {
out = append(out, e.reason)
}
return out
}
// waitReviewIdle 等到闸门没有在飞的审查调用,使后台结论已经落回待处理集合。
// reviewInFlight 在**派发时**(Admit 持锁期间)就置位,因此 Admit 返回后立刻
// 调用它不会漏掉刚派发的调用。
func waitReviewIdle(t *testing.T, g *FlowGate) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
g.mu.Lock()
busy := g.reviewInFlight
g.mu.Unlock()
if !busy {
return
}
if time.Now().After(deadline) {
t.Fatal("reviewer call did not finish within 5s")
}
time.Sleep(time.Millisecond)
}
}
// pendingLen 返回某会话待处理集合的条目数。
func pendingLen(g *FlowGate, key string) int {
g.mu.Lock()
defer g.mu.Unlock()
if st := g.sessions[key]; st != nil {
return len(st.buffer)
}
return 0
}
// keepAll 是「审查者什么都不想丢」的替身:返回全部索引,使待处理集合恒在阈值上,
// 从而让节流/预算成为唯一的抑制因素(否则队列一被修剪就跌破阈值,测不出节流)。
func keepAll(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
keep := make([]int, len(msgs))
for i := range msgs {
keep[i] = i
}
return keep, nil
}
// TestFlowGate_AdmitNeverWaitsForSlowReview 是 M24 的核心证据:一个 3–6 s 量级的
// 慢审查替身压住闸门时,入站帧仍然按节奏被放行 —— 读循环不再被模型调用阻塞。
//
// 变异判据:把审查改回同步调用(在 Admit 里直接 g.review(...))⇒ 帧间隔出现一个
// ≈4 s 的空窗 ⇒ 本用例红。
func TestFlowGate_AdmitNeverWaitsForSlowReview(t *testing.T) {
t.Parallel()
release := make(chan struct{})
started := make(chan struct{}, 1)
reviewer := func(_ context.Context, _ []UnifiedMessage) ([]int, error) {
select {
case started <- struct{}{}:
default:
}
// 4 s 的慢审查替身:实测 noise_review 的 p50 5.3 s / max 9.2 s 同量级。
select {
case <-release:
case <-time.After(4 * time.Second):
}
return nil, fmt.Errorf("slow stand-in returned")
}
g := NewFlowGate(FlowGateOptions{MaxMessages: 10, Window: time.Minute, Review: reviewer})
defer close(release)
const frames = 100
const cadence = 20 * time.Millisecond
var maxGap time.Duration
var slowestAdmit time.Duration
prev := time.Now()
for i := 0; i < frames; i++ {
admitStart := time.Now()
// 消息与心跳/探活回执在适配器侧走的是同一条 Admit 路,这里逐帧模拟。
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("frame %d dropped by the gate: %+v", i, v)
}
if d := time.Since(admitStart); d > slowestAdmit {
slowestAdmit = d
}
if gap := time.Since(prev); gap > maxGap {
maxGap = gap
}
prev = time.Now()
time.Sleep(cadence)
}
// 先断言「帧没有被拖住」——同步实现会在这里给出可读的红。
if maxGap > 500*time.Millisecond {
t.Fatalf("inbound frames stalled behind the reviewer: max gap %v (cadence %v)", maxGap, cadence)
}
if slowestAdmit > 100*time.Millisecond {
t.Fatalf("Admit waited on the reviewer: slowest admission %v", slowestAdmit)
}
// 审查确实被派发并且**仍在飞**:证明替身是慢的,上面测到的不是「审查根本没跑」。
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("reviewer was never called: the gate did not schedule the review")
}
g.mu.Lock()
busy := g.reviewInFlight
g.mu.Unlock()
if !busy {
t.Fatal("slow reviewer already returned; the stand-in was not slow enough to prove anything")
}
}
// TestFlowGate_ReviewThrottleHoldsOnFailure 固定「节流在失败路径也必须生效」:
// 审查者连续失败时,同一窗口内同一会话只允许一次调用 —— 这正是修复前失守的地方
// (lastReview 只在 err == nil 分支置位,而实测 67–85% 的审查因 BOM 解析失败)。
//
// 变异判据:把 scheduleReviewLocked 里的 `st.lastReview = now` 挪回「成功之后」
// (或删掉)⇒ 11 条消息各派发一次 ⇒ 本用例红。
func TestFlowGate_ReviewThrottleHoldsOnFailure(t *testing.T) {
t.Parallel()
now, _ := fixedClock(time.Unix(1000, 0))
var mu sync.Mutex
calls := 0
audit := &auditRecorder{}
g := NewFlowGate(FlowGateOptions{
MaxMessages: 4, // reviewAt = 3
Window: time.Minute,
Now: now,
Review: func(_ context.Context, _ []UnifiedMessage) ([]int, error) {
mu.Lock()
calls++
mu.Unlock()
return nil, fmt.Errorf("invalid character 'ï' looking for beginning of value")
},
OnPruned: audit.hook(),
})
for i := 0; i < 11; i++ {
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("message %d dropped although the reviewer only failed: %+v", i, v)
}
waitReviewIdle(t, g)
}
mu.Lock()
got := calls
mu.Unlock()
if got != 1 {
t.Fatalf("reviewer calls within one window = %d, want 1 (throttle must cover the failure path)", got)
}
// 失败方向:既不额外丢弃,也不给队列任何条目「保留」特权 —— 只有上限该有的
// "capped" 审计,绝不出现 "review_pruned"。
for _, r := range audit.reasons() {
if r == "review_pruned" {
t.Fatalf("a failed review must not prune anything, got audits %v", audit.reasons())
}
}
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 4 {
t.Fatalf("pending set = %d, want 4 (cap only, no review pruning)", n)
}
}
// TestFlowGate_ReviewBudgetBoundsCallsPerWindow 把「每适配器每分钟 LLM 审查次数」的
// 硬上界写成可测断言:40 个群在同一窗口内全部越阈,实际调用不超过
// defaultReviewPerWindow(6)。
//
// 变异判据:删掉 reviewBudgetLocked 的调用(或把 reviewBudget 置 0/极大)⇒ 40 次
// ⇒ 本用例红。
func TestFlowGate_ReviewBudgetBoundsCallsPerWindow(t *testing.T) {
t.Parallel()
now, _ := fixedClock(time.Unix(1000, 0))
var mu sync.Mutex
calls := 0
g := NewFlowGate(FlowGateOptions{
MaxMessages: 4, // reviewAt = 3
Window: time.Minute,
Now: now,
Review: func(ctx context.Context, msgs []UnifiedMessage) ([]int, error) {
mu.Lock()
calls++
mu.Unlock()
return keepAll(ctx, msgs)
},
})
const groups = 40
for gi := 0; gi < groups; gi++ {
group := fmt.Sprintf("-20%d", gi)
for i := 0; i < 4; i++ {
g.Admit(context.Background(), groupMsgIn(group, i))
}
waitReviewIdle(t, g)
}
mu.Lock()
got := calls
mu.Unlock()
if got > defaultReviewPerWindow {
t.Fatalf("reviewer calls in one window = %d, want <= %d (per-adapter budget)", got, defaultReviewPerWindow)
}
if got != defaultReviewPerWindow {
t.Fatalf("reviewer calls = %d, want exactly %d (every conversation crossed, budget is the only cap)", got, defaultReviewPerWindow)
}
}
// TestFlowGate_ReviewSingleInFlightPerAdapter 固定「同一适配器至多一次在飞」:
// 两个群同时越阈,第二个必须让位,不并发起第二次模型调用。
//
// 变异判据:删掉 scheduleReviewLocked 里的 reviewInFlight 判断 ⇒ 第二个群也起一次
// 调用 ⇒ 本用例红。
func TestFlowGate_ReviewSingleInFlightPerAdapter(t *testing.T) {
t.Parallel()
release := make(chan struct{})
started := make(chan string, 4)
reviewer := func(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
started <- msgs[0].Content
<-release
return keepAll(context.Background(), msgs)
}
g := NewFlowGate(FlowGateOptions{MaxMessages: 4, Window: time.Minute, Review: reviewer})
defer close(release)
for i := 0; i < 4; i++ {
g.Admit(context.Background(), groupMsgIn("-100", i))
}
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("first review never started")
}
for i := 0; i < 4; i++ {
g.Admit(context.Background(), groupMsgIn("-200", i))
}
select {
case extra := <-started:
t.Fatalf("second review started while one was in flight (%q)", extra)
case <-time.After(100 * time.Millisecond):
}
}
// TestFlowGate_ReviewIgnoresMessagesArrivedDuringCall 固定后台审查的落点:结论只作用于
// 审查者**当时看到的那批**(用序号对齐)。审查在飞期间新到的消息没被审查过,绝不能被
// 这次结论连带修剪 —— 否则审计会记出「review_pruned」却都是已经放行的消息。
//
// 变异判据:把 applyReviewSeqs 写成「不在 keep 集合里就丢」(不做快照过滤)⇒ 在飞期间
// 到达的两条也被丢 ⇒ 本用例红。
func TestFlowGate_ReviewIgnoresMessagesArrivedDuringCall(t *testing.T) {
t.Parallel()
release := make(chan struct{})
started := make(chan struct{}, 1)
audit := &auditRecorder{}
g := NewFlowGate(FlowGateOptions{
MaxMessages: 10, // reviewAt = 8
Window: time.Minute,
Review: func(_ context.Context, _ []UnifiedMessage) ([]int, error) {
started <- struct{}{}
<-release
return []int{0}, nil // 只保留快照里的第 0 条,其余视为噪声
},
OnPruned: audit.hook(),
})
for i := 0; i < 8; i++ {
g.Admit(context.Background(), groupMsg(i))
}
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("review never started")
}
// 审查在飞期间又到两条(seq 9、10):它们不在这次审查的快照里。
g.Admit(context.Background(), groupMsg(8))
g.Admit(context.Background(), groupMsg(9))
close(release)
waitReviewIdle(t, g)
// 快照第 0 条(msg 0)保留 + 在飞期间到达的两条 = 3。
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 3 {
t.Fatalf("pending set = %d, want 3 (msg0 + the two messages the reviewer never saw)", n)
}
for _, e := range audit.snapshot() {
if e.reason != "review_pruned" {
t.Fatalf("audit reason = %q, want review_pruned", e.reason)
}
if e.content == "msg 8" || e.content == "msg 9" {
t.Fatalf("a message that arrived during the call was pruned: %q", e.content)
}
}
if got := len(audit.snapshot()); got != 7 {
t.Fatalf("review_pruned count = %d, want 7 (snapshot entries 1..7)", got)
}
}
// TestFlowGate_CapStillEvictsOnAdmission 固定上限那一半仍是**同步**的:队列超出
// maxMessages 时最旧的条目在准入当次就被挤掉并审计 "capped",不依赖审查。
//
// 变异判据:把上限修剪也挪进后台 ⇒ v.Pruned 为空 ⇒ 本用例红。
func TestFlowGate_CapStillEvictsOnAdmission(t *testing.T) {
t.Parallel()
audit := &auditRecorder{}
g := NewFlowGate(FlowGateOptions{
MaxMessages: 3,
Review: keepAll,
OnPruned: audit.hook(),
})
for i := 0; i < 3; i++ {
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("message %d should fit under the cap: %+v", i, v)
}
}
v := g.Admit(context.Background(), groupMsg(3))
if !v.Proceed {
t.Fatalf("new message should be admitted after FIFO eviction, got %+v", v)
}
if len(v.Pruned) != 1 || v.Pruned[0].Content != "msg 0" {
t.Fatalf("pruned = %+v, want [msg 0]", v.Pruned)
}
found := false
for _, r := range audit.reasons() {
if r == "capped" {
found = true
}
}
if !found {
t.Fatalf("cap eviction not audited as capped: %v", audit.reasons())
}
}
// TestFlowGate_RateLimitDropsAtTheDoor 固定 rate_limited 的「门口丢」语义一字不改:
// 被滑窗挡下的消息根本不进待处理集合,审查也绝不会把它「捞回来」。
func TestFlowGate_RateLimitDropsAtTheDoor(t *testing.T) {
t.Parallel()
now, _ := fixedClock(time.Unix(1000, 0))
g := NewFlowGate(FlowGateOptions{
GroupMsgPerMin: 2,
MaxMessages: 3,
Window: time.Minute,
Now: now,
Review: keepAll,
})
for i := 0; i < 2; i++ {
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("message %d should pass the group window: %+v", i, v)
}
}
v := g.Admit(context.Background(), groupMsg(2))
if v.Proceed {
t.Fatal("third message in the window must be rate limited")
}
if v.Reason != "rate_limited" {
t.Fatalf("reason = %q, want rate_limited", v.Reason)
}
if v.DroppedMsg == nil || v.DroppedMsg.Content != "msg 2" {
t.Fatalf("dropped msg = %+v, want msg 2", v.DroppedMsg)
}
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 2 {
t.Fatalf("pending set = %d, want 2: a rate-limited message never enters it", n)
}
waitReviewIdle(t, g)
}
+100
View File
@@ -0,0 +1,100 @@
package adapter
import (
"encoding/json"
"fmt"
"sort"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// utf8BOM 是 UTF-8 的字节序标记。对端(审查模型网关)会把它原样回带,而
// encoding/json 见到它报 `invalid character 'ï' looking for beginning of value` ——
// 实测 9 天 1,909 次审查里 1,288 次(67%)因此解析失败,每一次都白烧一次模型调用
// 并把「每窗口一次」的节流打穿(见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。
const utf8BOM = "\ufeff"
// reviewExcerptLen 是解析失败时附带在错误里的模型输出长度(**rune**)。它只含模型
// 返回的编号数组(数字/方括号/逗号/围栏),足以定位「又换了什么壳」,不至于把
// 消息内容带进日志。
//
// 🔴 单位是 rune 不是字节:审查模型回的中文/全角标点每个字 3 字节,按 40 **字节**
// 截会把其中一个字切成半个 —— 错误串变成非法 UTF-8,终端显示 U+FFFD、JSON 客户端
// 各自乱码。带上限出镜是刻意的(失败原因对排障有用),但上限必须以字符计。
const reviewExcerptLen = 40
// ParseReviewIndices decodes the keep-index array a review model returned, rising
// above the shapes real gateways produce instead of requiring a bare JSON array:
//
// - a UTF-8 BOM prefix (`\ufeff`) — the actual cause of the 67% failure rate;
// - leading/trailing whitespace and newlines;
// - a Markdown code fence (` ```json … ``` ` or ` ``` … ``` `);
// - surrounding prose ("好的,保留以下编号:[0, 2]") — the outermost `[ … ]`
// span is used.
//
// The decoded indices are validated the same way the previous strict parser did:
// out-of-range and duplicate values are dropped and the rest sorted ascending, so a
// hallucinated number can never evict a real message. An empty or absent array is
// NOT an error here — the caller decides; a missing/non-array answer is.
func ParseReviewIndices(s string, bound int) ([]int, error) {
body, ok := reviewArraySpan(s)
if !ok {
return nil, fmt.Errorf("no JSON array in reviewer output %q", reviewExcerpt(s))
}
var idx []int
if err := json.Unmarshal([]byte(body), &idx); err != nil {
return nil, fmt.Errorf("decode reviewer indices %q: %w", reviewExcerpt(body), err)
}
seen := make(map[int]bool, len(idx))
out := make([]int, 0, len(idx))
for _, i := range idx {
if i >= 0 && i < bound && !seen[i] {
seen[i] = true
out = append(out, i)
}
}
sort.Ints(out)
return out, nil
}
// reviewArraySpan strips the decorations a model puts around its answer (BOM,
// whitespace, code fence, prose) and returns the outermost JSON array span.
func reviewArraySpan(s string) (string, bool) {
s = strings.TrimSpace(strings.TrimPrefix(s, utf8BOM))
s = strings.TrimSpace(unwrapCodeFence(s))
start := strings.IndexByte(s, '[')
end := strings.LastIndexByte(s, ']')
if start < 0 || end < start {
return "", false
}
return s[start : end+1], true
}
// unwrapCodeFence drops a leading ``` fence line and the closing ``` so the fenced
// body is left for the array scan. Anything without a leading fence is returned
// unchanged; a fence with no body is returned as-is and fails the array scan.
func unwrapCodeFence(s string) string {
if !strings.HasPrefix(s, "```") {
return s
}
nl := strings.IndexByte(s, '\n')
if nl < 0 {
return s
}
s = s[nl+1:]
if close := strings.LastIndex(s, "```"); close >= 0 {
s = s[:close]
}
return s
}
// reviewExcerpt 把模型输出裁成一段可以进错误串的摘要。两步都不是可选的:
//
// - trace.RedactTextLimited 先清洗凭据形状(审查网关偶有把 Authorization 回显进
// 输出的),再按 **rune** 截到 reviewExcerptLen —— 按字节截会把多字节字符切成
// 半个,错误串因此变成非法 UTF-8;
// - 压平空白:一条诊断只占一行,也挡掉换行注入。
func reviewExcerpt(s string) string {
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), reviewExcerptLen)
}
@@ -0,0 +1,171 @@
package adapter
// 本文件用**对端原样输出**(含 UTF-8 BOM 字节)做夹具,固定审查输出的解析韧性。
// 生产症状(`~/cy.log`,9 天):`flow gate review output unparsable` 1,301 次,其中
// 1,288 次的错误串逐字是 `invalid character 'ï' looking for beginning of value` ——
// 对端在 JSON 数组前带了 BOM,而闸门用的 json.Unmarshal 不接受它。
import (
"encoding/json"
"strings"
"testing"
"unicode/utf8"
)
// bomReviewFixture 是审查模型网关的实际回包形状:UTF-8 BOM(EF BB BF)紧接一个
// JSON 数组。写死字节而不是用 \ufeff,是为了让「夹具被谁悄悄去掉 BOM」本身就会红。
const bomReviewFixture = "\xef\xbb\xbf[0, 1, 2, 3, 4, 5]"
// TestReviewBOMFixtureStillReproducesTheFailure 先钉住夹具的真实性:它必须以 BOM
// 开头,且严格的 json.Unmarshal 必须在它上面失败,错误串必须就是生产日志里那条。
//
// 变异判据:把夹具里的 BOM 去掉 ⇒ 本用例红(同时也说明 BOM 相关用例已失去意义)。
func TestReviewBOMFixtureStillReproducesTheFailure(t *testing.T) {
t.Parallel()
if !strings.HasPrefix(bomReviewFixture, "\xef\xbb\xbf") {
t.Fatal("BOM fixture lost its BOM bytes: it no longer reproduces the production failure")
}
var idx []int
err := json.Unmarshal([]byte(bomReviewFixture), &idx)
if err == nil {
t.Fatal("strict json.Unmarshal accepted the BOM fixture: the regression it models is gone")
}
if !strings.Contains(err.Error(), "invalid character 'ï'") {
t.Fatalf("strict parse error = %v, want the production symptom \"invalid character 'ï'\"", err)
}
}
// TestParseReviewIndices_ToleratesBOM 是修复的正面证据:同一份带 BOM 的原样回包
// 现在能解析出编号。
//
// 变异判据:去掉 ParseReviewIndices 里的 reviewArraySpan(回到裸 json.Unmarshal)
// ⇒ 本用例红。
func TestParseReviewIndices_ToleratesBOM(t *testing.T) {
t.Parallel()
keep, err := ParseReviewIndices(bomReviewFixture, 6)
if err != nil {
t.Fatalf("ParseReviewIndices(BOM fixture) error = %v, want nil", err)
}
want := []int{0, 1, 2, 3, 4, 5}
if len(keep) != len(want) {
t.Fatalf("keep = %v, want %v", keep, want)
}
for i := range want {
if keep[i] != want[i] {
t.Fatalf("keep = %v, want %v", keep, want)
}
}
}
// TestParseReviewIndices_ToleratesDecorations 覆盖模型常见的几种外壳:首尾空白与换行、
// Markdown 代码围栏(带或不带语言标签)、以及围在数组外面的解说文字。
func TestParseReviewIndices_ToleratesDecorations(t *testing.T) {
t.Parallel()
cases := map[string]string{
"newlines": "\n\n[0, 2]\n\n",
"fence": "```\n[0, 2]\n```",
"fence with lang": "```json\n[0, 2]\n```",
"prose prefix": "好的,保留以下编号:[0, 2]",
"prose suffix": "[0, 2] 以上是保留的编号。",
"bom plus fence": "\xef\xbb\xbf```json\n[0, 2]\n```",
"spaced values": "[ 0 , 2 ]",
}
for name, raw := range cases {
keep, err := ParseReviewIndices(raw, 3)
if err != nil {
t.Fatalf("%s: error = %v, want nil", name, err)
}
if len(keep) != 2 || keep[0] != 0 || keep[1] != 2 {
t.Fatalf("%s: keep = %v, want [0 2]", name, keep)
}
}
}
// TestParseReviewIndices_FiltersHallucinations 固定过滤口径与修复前一致:越界、负数、
// 重复的编号一律丢弃(模型幻觉出的数字绝不能淘汰真实消息),结果升序。
func TestParseReviewIndices_FiltersHallucinations(t *testing.T) {
t.Parallel()
keep, err := ParseReviewIndices("\xef\xbb\xbf[2, 2, 99, -1, 0]", 3)
if err != nil {
t.Fatalf("error = %v, want nil", err)
}
if len(keep) != 2 || keep[0] != 0 || keep[1] != 2 {
t.Fatalf("keep = %v, want [0 2]", keep)
}
}
// TestParseReviewIndices_EmptyArrayIsNotAnError:模型明确回空数组 = 「一条都不保留」,
// 这是有效结论(全部当噪声),不是解析失败 —— 失败方向必须留给真正的畸形输出。
func TestParseReviewIndices_EmptyArrayIsNotAnError(t *testing.T) {
t.Parallel()
keep, err := ParseReviewIndices("\xef\xbb\xbf[]", 5)
if err != nil {
t.Fatalf("error = %v, want nil", err)
}
if len(keep) != 0 {
t.Fatalf("keep = %v, want empty", keep)
}
}
// TestParseReviewIndices_RejectsGarbage:真正畸形的输出仍然报错(调用方据此走
// 「不额外丢弃、也不提权」的安全方向),错误里带一小段原文便于定位下一次变种。
func TestParseReviewIndices_RejectsGarbage(t *testing.T) {
t.Parallel()
for name, raw := range map[string]string{
"no array": "抱歉,我无法判断。",
"empty": "",
"array of str": `["a", "b"]`,
} {
if _, err := ParseReviewIndices(raw, 5); err == nil {
t.Fatalf("%s: error = nil, want a decode error", name)
}
}
}
// TestReviewExcerpt_TruncatesByRune 钉住「上限按字符计,不按字节计」:审查模型回的中文
// 每个字 3 字节,按 40 **字节**截会把其中一个字切成半个,错误串因此成为非法 UTF-8
// (终端显示 U+FFFD、JSON 客户端各自乱码)。
//
// 变异判据:把 reviewExcerpt 换回 `s[:reviewExcerptLen] + "…"` 的字节截断 ⇒ 本用例红
// (utf8.ValidString 为 false)。
func TestReviewExcerpt_TruncatesByRune(t *testing.T) {
t.Parallel()
got := reviewExcerpt(strings.Repeat("中", reviewExcerptLen*2))
if !utf8.ValidString(got) {
t.Fatalf("截断后不是合法 UTF-8(按字节截断了半个汉字):%q", got)
}
body := strings.TrimSuffix(got, "…")
if n := utf8.RuneCountInString(body); n != reviewExcerptLen {
t.Fatalf("保留 %d 个字符,期望 %d:%q", n, reviewExcerptLen, got)
}
if !strings.HasSuffix(got, "…") {
t.Errorf("超限时应当带省略号:%q", got)
}
}
// TestReviewExcerpt_RedactsCredentialShapes 钉住「带上限出镜 ≠ 原样出镜」:摘要仍要
// 过 trace.RedactText,网关回显的凭据形状(sk-… / token=… 等)必须被打码。
//
// 变异判据:让 reviewExcerpt 只做截断、不过 trace.RedactTextLimited ⇒ 本用例红。
func TestReviewExcerpt_RedactsCredentialShapes(t *testing.T) {
t.Parallel()
const secret = "sk-abcdefghijklmnop"
got := reviewExcerpt("[0, 1] Authorization: Bearer " + secret)
if strings.Contains(got, secret) {
t.Fatalf("凭据形状没被打码:%q", got)
}
if !strings.Contains(got, "[redacted]") {
t.Errorf("打码标记缺失:%q", got)
}
if !strings.Contains(got, "[0, 1]") {
t.Errorf("摘要丢了诊断价值(编号数组):%q", got)
}
}
+171
View File
@@ -0,0 +1,171 @@
package adapter
// 本文件是策略四的「审查」半边:把它从准入路径上摘下来的派发器(reviewJob /
// scheduleReviewLocked / runReview)与三重上界(同适配器在飞、每会话窗口、每适配器
// 窗口预算)。解析韧性在 flowgate_parse.go,准入判定仍在 flowgate.go。
//
// 为什么必须是后台:`noise_review` 实测 p50 5,294 ms / p95 6,651 ms / max 9,204 ms
// (`~/cy.log`,9 天 1,900 次),而五路入站里 qq-ws、kook-ws、weixin 长轮询都是
// 「单读 goroutine + 同步派发」—— 同步跑在读循环上等于把消息、心跳、探活回执全排在
// 一次 6 秒的模型调用后面(DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。
//
// # 与修复前的语义差异(哪一条变了、哪一条没变)
//
// - `rate_limited`(策略三,门口丢):**一字不改**。超窗消息在准入当次就被拒绝、
// 根本不进待处理集合,也不经过审查。
// - 上限修剪(策略四的硬部分):**一字不改**。待处理集合超过 maxMessages 时,最旧的
// 条目仍在**准入当次同步**被挤掉并审计为 "capped";触发水位仍是上限的 80%。
// - 审查修剪(策略四的模型部分):**判据不变,时机变了**。触发条件(≥80% 上限、
// 同一会话每窗口至多一次、返回保留编号、越界/重复编号丢弃)逐条沿用;变的是结论
// 落地时点 —— 它不再决定「触发它的那条消息」是否放行,而是修剪**它当时看到的**
// 那批历史条目(审计为 "review_pruned")。
// 直接后果:`review_pruned` 不再从门口丢消息(实测原本 41–74 条/日),那些消息
// 现在会进入流水线 —— 完整性↑、噪声↑,是否开口仍由 engage_judge / 冷避 / owner
// 闸判(这三处判据一行未动)。
//
// 为什么这个差异可以接受:design/12 §6 策略四给审查的职责是「保护 loop 不被不重要的
// 消息塞满」的历史积压去噪,不是逐条消息的准入判决;把一次 6 秒的模型调用钉在准入
// 路径上,代价(读循环阻塞 → 消息/心跳/探活全排队)远大于收益(少进 41–74 条噪声/日)。
// 真正决定「进不进 loop」的两道判据 —— 限流与上限 —— 都留在准入路径上,且未改口径。
import (
"context"
"time"
)
const (
// reviewTimeout bounds one background reviewer call. The in-flight slot is held
// for the whole call, so an unbounded call would also freeze every other
// conversation's review for that account. 上一版没有上界,最长一次实测 9.2 s;
// 30 s 留足余量而不至于让一次挂死的调用长期占位。
reviewTimeout = 30 * time.Second
)
// reviewJob is one scheduled reviewer call: the snapshot of the pending set the
// reviewer is asked about, plus the sequence numbers that identify those entries
// when the answer comes back (the buffer keeps moving while the call is in flight).
type reviewJob struct {
key string
msgs []UnifiedMessage
seqs []uint64
}
// scheduleReviewLocked reports whether this admission should start a background
// review and, when it should, marks that review as started and returns its job.
//
// 三重上界都在这里(调用方须持有 g.mu):
// 1. 同一适配器至多一次在飞(reviewInFlight)—— 后台审查有界,不会因为多个群
// 同时越阈而并发起 N 个模型调用;
// 2. 同一会话每 window 至多一次(lastReview)—— 修复前它只在成功分支置位,
// 于是 68% 解析失败时「每窗口一次」退化成「每条消息一次」;
// 3. 整个适配器每 window 至多 reviewBudget 次(reviewHits)—— 与模型耗时无关的
// 硬上界,会话再多也乘不上去。
//
// lastReview 在这里(**派发时**)写入,而不是等调用返回:成功与失败两条路径因此
// 同样被节流,这是「每条入站帧都付一次 6 秒」的正面修复。
func (g *FlowGate) scheduleReviewLocked(st *flowSession, key string, now time.Time) *reviewJob {
if g.review == nil || g.maxMessages <= 0 || len(st.buffer) < g.reviewAt {
return nil
}
if g.reviewInFlight || g.reviewRecently(st, now) || !g.reviewBudgetLocked(now) {
return nil
}
st.lastReview = now
g.reviewInFlight = true
g.reviewHits = append(g.reviewHits, now)
return &reviewJob{key: key, msgs: queueMessages(st.buffer), seqs: bufferSeqs(st.buffer)}
}
// runReview executes one scheduled review off the admission path and folds its
// verdict back into the conversation's pending set. It is the only place the
// Reviewer is called.
//
// 失败方向(安全侧,与修复前 `err != nil` 分支同口径):审查失败**既不额外丢弃**
// (失败绝不被解释成「低价值 ⇒ 丢掉」),**也不给任何条目保留特权**(失败绝不被
// 解释成「高价值 ⇒ 提权」);待处理集合原样保留,只由既有的 FIFO 上限兜底。
// 唯一的代价是这一批噪声没被修剪,而窗口节流保证下一次尝试要等到下个窗口。
func (g *FlowGate) runReview(ctx context.Context, job *reviewJob) {
rctx, cancel := reviewContext(ctx)
defer cancel()
keep, err := g.review(rctx, job.msgs)
g.mu.Lock()
g.reviewInFlight = false
var dropped []queuedMsg
if err == nil {
// 会话可能在审查期间被 GC 掉(空闲清理),那时结论无处可落,直接丢弃。
if st := g.sessions[job.key]; st != nil {
st.buffer, dropped = applyReviewSeqs(st.buffer, job.seqs, keep)
}
}
g.mu.Unlock()
// 审计在锁外:钩子由 channelbridge 接(落库/面板),不该占着闸门的锁跑 I/O。
g.auditDropped(dropped, "review_pruned")
}
// reviewContext detaches the reviewer call from the admission caller's cancellation
// — the inbound handler returns the moment the message is admitted, and on several
// adapters its ctx dies with the frame — while still bounding the call so a hung
// model cannot pin the single in-flight slot forever.
func reviewContext(ctx context.Context) (context.Context, context.CancelFunc) {
if ctx == nil {
ctx = context.Background()
}
return context.WithTimeout(context.WithoutCancel(ctx), reviewTimeout)
}
// reviewRecently throttles review so it does not run on every message: it fires at
// most once per window per conversation, which is the cost guard for the review model.
func (g *FlowGate) reviewRecently(st *flowSession, now time.Time) bool {
if st == nil || st.lastReview.IsZero() {
return false
}
return now.Sub(st.lastReview) < g.window
}
// reviewBudgetLocked enforces the per-adapter ceiling on reviewer calls started in
// the current window. 调用方须持有 g.mu。
func (g *FlowGate) reviewBudgetLocked(now time.Time) bool {
g.reviewHits = g.trimHits(g.reviewHits, now)
return len(g.reviewHits) < g.reviewBudget
}
// applyReviewSeqs keeps only the buffered entries the reviewer chose. Entries are
// matched by sequence number, so:
// - entries evicted by the cap while the call was in flight simply do not appear
// in the buffer any more (they were audited as "capped" at eviction time);
// - entries that arrived **after** the snapshot are never judged by this review
// and always survive (the reviewer never saw them).
func applyReviewSeqs(buffer []queuedMsg, seqs []uint64, keep []int) (kept, dropped []queuedMsg) {
reviewed := make(map[uint64]bool, len(seqs))
for _, s := range seqs {
reviewed[s] = true
}
keepSet := make(map[uint64]bool, len(keep))
for _, i := range keep {
if i >= 0 && i < len(seqs) {
keepSet[seqs[i]] = true
}
}
kept = make([]queuedMsg, 0, len(buffer))
dropped = make([]queuedMsg, 0)
for _, e := range buffer {
if !reviewed[e.seq] || keepSet[e.seq] {
kept = append(kept, e)
continue
}
dropped = append(dropped, e)
}
return kept, dropped
}
// bufferSeqs extracts the sequence numbers of buffered entries in order, so a
// review verdict can be applied to exactly the entries it was asked about.
func bufferSeqs(buffer []queuedMsg) []uint64 {
out := make([]uint64, len(buffer))
for i, e := range buffer {
out[i] = e.seq
}
return out
}
+54 -31
View File
@@ -2,6 +2,7 @@ package adapter
import ( import (
"context" "context"
"sync"
"testing" "testing"
"time" "time"
) )
@@ -118,10 +119,16 @@ func TestFlowGate_MsgPerMinCapsAllNonOwner(t *testing.T) {
} }
} }
func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) { // TestFlowGate_ReviewPrunesBacklogAfterAdmission 固定策略四的新口径:审查**不在准入
// 路径上**。越阈那一条消息照常放行(它的到达只负责派发审查),审查完成后只修剪
// **它当时看到的那批**待处理条目,并以 "review_pruned" 入审计。
//
// 变异判据:把审查改回在 Admit 里同步执行并按结论丢「刚到的那条」⇒ 本用例的
// `v.Proceed` 断言红。
func TestFlowGate_ReviewPrunesBacklogAfterAdmission(t *testing.T) {
t.Parallel() t.Parallel()
now, _ := fixedClock(time.Unix(1000, 0)) now, _ := fixedClock(time.Unix(1000, 0))
var audited []string audit := &auditRecorder{}
g := NewFlowGate(FlowGateOptions{ g := NewFlowGate(FlowGateOptions{
MaxMessages: 10, MaxMessages: 10,
Window: time.Minute, Window: time.Minute,
@@ -130,9 +137,7 @@ func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) {
// Keep only the first two messages; drop the rest as noise. // Keep only the first two messages; drop the rest as noise.
return []int{0, 1}, nil return []int{0, 1}, nil
}, },
OnPruned: func(_ UnifiedMessage, reason string) { OnPruned: audit.hook(),
audited = append(audited, reason)
},
}) })
// Seven messages stay below the 80% threshold (reviewAt = 8), no review fires. // Seven messages stay below the 80% threshold (reviewAt = 8), no review fires.
@@ -141,69 +146,87 @@ func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) {
t.Fatalf("message %d should be admitted before threshold: %+v", i, v) t.Fatalf("message %d should be admitted before threshold: %+v", i, v)
} }
} }
if len(audited) != 0 { waitReviewIdle(t, g)
t.Fatalf("review fired below threshold: %v", audited) if n := len(audit.snapshot()); n != 0 {
t.Fatalf("review fired below threshold: %d audits", n)
} }
// The 8th message crosses the 80% threshold → review runs and drops msg2..7, // The 8th message crosses the 80% threshold → the review is dispatched in the
// including the just-admitted message 7. // background; the message itself is still admitted (admission never waits).
v := g.Admit(context.Background(), groupMsg(7)) v := g.Admit(context.Background(), groupMsg(7))
if v.Proceed { if !v.Proceed {
t.Fatal("just-admitted message 7 must be pruned by the reviewer") t.Fatalf("the message that crosses the threshold must still be admitted, got %+v", v)
} }
if v.Reason != "review_pruned" { if v.Reason != "" {
t.Fatalf("reason = %q, want review_pruned", v.Reason) t.Fatalf("reason = %q, want empty", v.Reason)
} }
if len(v.Pruned) != 6 { waitReviewIdle(t, g)
t.Fatalf("pruned = %d, want 6 (msg2..msg7)", len(v.Pruned))
// The review keeps 0 and 1, so msg2..7 are pruned from the pending set and audited.
if n := len(audit.snapshot()); n != 6 {
t.Fatalf("audit count = %d, want 6 (msg2..msg7)", n)
} }
if len(audited) != 6 { for _, e := range audit.snapshot() {
t.Fatalf("audit count = %d, want 6", len(audited)) if e.reason != "review_pruned" {
} t.Fatalf("audit reason = %q, want review_pruned", e.reason)
for _, r := range audited {
if r != "review_pruned" {
t.Fatalf("audit reason = %q, want review_pruned", r)
} }
} }
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 2 {
t.Fatalf("pending set = %d, want 2 (only the kept entries)", n)
}
} }
// TestFlowGate_ReviewThrottledWithinWindow 固定「每会话每窗口至多一次审查」。
// 审查者保留全部条目,使待处理集合恒在阈值上 —— 于是节流(而不是队列长度)是唯一的
// 抑制因素;这正是修复前失守的那条判据(lastReview 只在成功分支置位)。
func TestFlowGate_ReviewThrottledWithinWindow(t *testing.T) { func TestFlowGate_ReviewThrottledWithinWindow(t *testing.T) {
t.Parallel() t.Parallel()
now, clock := fixedClock(time.Unix(1000, 0)) now, clock := fixedClock(time.Unix(1000, 0))
var mu sync.Mutex
reviews := 0 reviews := 0
g := NewFlowGate(FlowGateOptions{ g := NewFlowGate(FlowGateOptions{
MaxMessages: 3, MaxMessages: 3,
Window: time.Minute, Window: time.Minute,
Now: now, Now: now,
Review: func(_ context.Context, msgs []UnifiedMessage) ([]int, error) { Review: func(ctx context.Context, msgs []UnifiedMessage) ([]int, error) {
mu.Lock()
reviews++ reviews++
return []int{0}, nil mu.Unlock()
return keepAll(ctx, msgs)
}, },
}) })
count := func() int {
mu.Lock()
defer mu.Unlock()
return reviews
}
// First threshold crossing triggers a review. // First threshold crossing triggers a review.
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
g.Admit(context.Background(), groupMsg(i)) g.Admit(context.Background(), groupMsg(i))
} }
if reviews != 1 { waitReviewIdle(t, g)
t.Fatalf("reviews after crossing threshold = %d, want 1", reviews) if got := count(); got != 1 {
t.Fatalf("reviews after crossing threshold = %d, want 1", got)
} }
// Within the same window the gate must not re-review even when it refills. // Within the same window the gate must not re-review even when it refills.
for i := 3; i < 6; i++ { for i := 3; i < 9; i++ {
g.Admit(context.Background(), groupMsg(i)) g.Admit(context.Background(), groupMsg(i))
} }
if reviews != 1 { waitReviewIdle(t, g)
t.Fatalf("reviews within window = %d, want 1 (throttled)", reviews) if got := count(); got != 1 {
t.Fatalf("reviews within window = %d, want 1 (throttled)", got)
} }
// A fresh window allows a review again. // A fresh window allows a review again.
*clock = clock.Add(2 * time.Minute) *clock = clock.Add(2 * time.Minute)
for i := 6; i < 9; i++ { for i := 9; i < 12; i++ {
g.Admit(context.Background(), groupMsg(i)) g.Admit(context.Background(), groupMsg(i))
} }
if reviews != 2 { waitReviewIdle(t, g)
t.Fatalf("reviews after window roll = %d, want 2", reviews) if got := count(); got != 2 {
t.Fatalf("reviews after window roll = %d, want 2", got)
} }
} }
+81 -13
View File
@@ -5,9 +5,16 @@
// future platforms; existing adapters (Telegram, OneBot) that already carry // future platforms; existing adapters (Telegram, OneBot) that already carry
// their own transport are left untouched. // their own transport are left untouched.
// //
// It depends only on the standard library and internal/logger. The auth token is // It depends only on the standard library, internal/logger and internal/runtime/trace.
// never logged or surfaced in errors. The Authorization scheme is configurable // The auth token is never logged or surfaced in errors. The Authorization scheme is
// (default "Bearer" for OAuth2-style tokens; KOOK robots use "Bot"). // configurable (default "Bearer" for OAuth2-style tokens; KOOK robots use "Bot").
//
// The envelope's message is an upstream free-text channel: upstreams echo the rejected
// request body into it, and that body holds what she said. It is therefore **not**
// carried in errors by default (see WithEnvelopeMessage); code and HTTP status are. The
// same rule — code/status stay, free text goes — applies to every channel adapter's own
// envelope handling (Telegram description, OneBot msg, iLink CDN x-error-message); see
// upstream_text.go.
package httpclient package httpclient
import ( import (
@@ -23,6 +30,7 @@ import (
"strings" "strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
) )
// ErrAPI is the sentinel returned when the remote API reports a non-zero code or // ErrAPI is the sentinel returned when the remote API reports a non-zero code or
@@ -39,6 +47,9 @@ type Client struct {
token string token string
authScheme string authScheme string
log *slog.Logger log *slog.Logger
// keepEnvelopeMessage 是**默认关闭**的开关:信封 message 只有显式 opt-in
// (WithEnvelopeMessage)才会进错误文本。零值 = 安全,见该函数的注释。
keepEnvelopeMessage bool
} }
// Option customizes a Client at construction time. // Option customizes a Client at construction time.
@@ -80,6 +91,23 @@ func WithLogger(l *slog.Logger) Option {
} }
} }
// WithEnvelopeMessage 显式要求把信封的 message 保留在错误文本里。**默认不带**。
//
// 🔴 默认不带是刻意的,理由与 trace.UpstreamError 的 message 同源:上游会把被拒的输入
// 回显在 message 里(KOOK 实测 `invalid content: <她原话>`),于是那句话会随错误进
// 调用方的日志。排障真正用得到的分类信息是 `code` 与 `status`,两者**恒在**;message
// 只是人话提示。宁可少一条提示,也不让她的原话进日志。
//
// 只有在你**确认该上游不回显用户内容**时才打开。打开后 message 仍会压平空白、清洗
// 凭据形状并截到 maxUpstreamTextRunes(40 rune)—— 上限不像 trace 的 128,是这层没有
// 「一屏摘要」的诉求,短到装不下一句正文。
//
// 函数式选项的形状让最常用的 `New(...)` 天然就是最安全的那个:想放宽必须在这里显式
// 写出来,代码评审时一眼可见(与 trace.WithUpstreamMessage 同一个范式)。
func WithEnvelopeMessage() Option {
return func(c *Client) { c.keepEnvelopeMessage = true }
}
// New builds a Client. With no base URL the caller must pass absolute URLs (via // New builds a Client. With no base URL the caller must pass absolute URLs (via
// the Do/Get helpers' path being an absolute URL). // the Do/Get helpers' path being an absolute URL).
func New(opts ...Option) *Client { func New(opts ...Option) *Client {
@@ -129,7 +157,13 @@ func (c *Client) resolve(path string) string {
} }
// do performs the request, injecting auth and decoding the envelope. // do performs the request, injecting auth and decoding the envelope.
//
// 地址在这一层只以来源形态出镜:target 由调用方给(GetJSON 的 query 可能带凭据、
// 绝对 path 可能带 user:pass@),完整串既不进错误也不进日志。响应体原文同理 ——
// 上游可能把被拒的输入回显在体里,因此 4xx/5xx 只收敛成状态码 + 信封的 code
// (message 默认不带,见 WithEnvelopeMessage),其余字节一概不留。
func (c *Client) do(ctx context.Context, method, target string, body any) (json.RawMessage, error) { func (c *Client) do(ctx context.Context, method, target string, body any) (json.RawMessage, error) {
host := trace.HostOf(target)
var buf bytes.Buffer var buf bytes.Buffer
if body != nil { if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil { if err := json.NewEncoder(&buf).Encode(body); err != nil {
@@ -138,7 +172,7 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
} }
req, err := http.NewRequestWithContext(ctx, method, target, &buf) req, err := http.NewRequestWithContext(ctx, method, target, &buf)
if err != nil { if err != nil {
return nil, fmt.Errorf("httpclient: build %s %s request: %w", method, target, err) return nil, fmt.Errorf("httpclient: build %s %s request: %w", method, host, trace.URLCause(err))
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
@@ -152,16 +186,16 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("httpclient: %s %s: %w", method, target, err) return nil, fmt.Errorf("httpclient: %s %s: %w", method, host, trace.URLCause(err))
} }
defer resp.Body.Close() defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil { if err != nil {
return nil, fmt.Errorf("httpclient: read %s %s response: %w", method, target, err) return nil, fmt.Errorf("httpclient: read %s %s response: %w", method, host, trace.URLCause(err))
} }
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("httpclient: %s %s: status %d: %s: %w", method, target, resp.StatusCode, truncate(raw), ErrAPI) return nil, c.apiFailure(method, host, resp, raw)
} }
var envelope APIEnvelope var envelope APIEnvelope
@@ -169,15 +203,49 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
return raw, nil return raw, nil
} }
if envelope.Code != 0 { if envelope.Code != 0 {
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w", method, target, envelope.Code, envelope.Message, ErrAPI) if detail := c.envelopeDetail(envelope.Message); detail != "" {
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w",
method, host, envelope.Code, detail, ErrAPI)
}
return nil, fmt.Errorf("httpclient: %s %s: code %d: %w", method, host, envelope.Code, ErrAPI)
} }
return envelope.Data, nil return envelope.Data, nil
} }
// truncate caps a byte slice to 512 bytes for inclusion in an error message. // apiFailure 把一次非 2xx 响应收敛成一条不携带响应体原文的错误:来源主机 + 状态码,
func truncate(b []byte) string { // 外加(当体确实是 `{code,message}` 信封时)code —— message 同样只在显式 opt-in 时
if len(b) > 512 { // 才出现。其余字节只经 trace.UpstreamError 留下体积与 sha256 前缀,供「是不是同一份
return strings.TrimSpace(string(b[:512])) + "…" // 报文」这类相关性判断。
func (c *Client) apiFailure(method, host string, resp *http.Response, raw []byte) error {
upstream := trace.UpstreamError(host, resp.StatusCode, resp.Header.Get("Content-Type"), raw)
var envelope APIEnvelope
if err := json.Unmarshal(raw, &envelope); err == nil {
detail := c.envelopeDetail(envelope.Message)
switch {
case envelope.Code != 0 && detail != "":
return fmt.Errorf("httpclient: %s %s: code %d: %s: %w: %w",
method, host, envelope.Code, detail, upstream, ErrAPI)
case envelope.Code != 0:
return fmt.Errorf("httpclient: %s %s: code %d: %w: %w", method, host, envelope.Code, upstream, ErrAPI)
case detail != "":
return fmt.Errorf("httpclient: %s %s: %s: %w: %w", method, host, detail, upstream, ErrAPI)
}
} }
return strings.TrimSpace(string(b)) return fmt.Errorf("httpclient: %s: %w: %w", method, upstream, ErrAPI)
}
// envelopeDetail 返回**允许进错误文本**的信封 message:默认空串(不带)。
//
// 为什么默认不带(这条取舍是实测得出的):上游会把被拒的请求正文回显在 message 里,
// 而那条正文就是她的话。M49 复审的探针在隔离副本里拿到的实际文本是
// `... code 1: invalid content: 她刚才说的那句话: ...: status=400` —— 她的原话既进了
// 错误也进了调用方的日志。排障需要的是 code 与 status(两者恒在),不是那句话。
//
// 只有 WithEnvelopeMessage() 显式打开后,这里才返回 upstreamText(压平空白 + 凭据打码
// + 截到 40 rune)。
func (c *Client) envelopeDetail(message string) string {
if !c.keepEnvelopeMessage {
return ""
}
return upstreamText(message)
} }
@@ -0,0 +1,234 @@
package httpclient
// 本文件钉住本客户端的出镜边界(M41 #10/#11 那一族,M50 补信封 message):
//
// - **非 2xx 的响应体原文不进错误**:旧实现把体截到 512B 拼进 error,而调用方
// (KOOK 适配器)会把这条 error 直接落日志 —— 上游在体里回显什么,日志里就有
// 什么。现在只留状态码、来源主机,以及信封的 code(message 默认不带)。
// - **信封 message 默认不带**:上游会用它在被拒时回显请求正文(也就是她的话)。
// 要带必须显式 WithEnvelopeMessage(),那时仍过 upstreamText(凭据打码 + 40 rune)。
// - **请求地址的凭据不进错误**:target 可能带 `?access_token=…` 或 `user:pass@`,
// 而 net/http 的 *url.Error 会把整条地址拼进错误串。
//
// 每条用例都是「把它改回去就变红」的形状:断言的是**错误文本**,也就是调用方实际
// 打进日志的那段字符串。
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// failingTransport 让 http.Client.Do 以传输层错误收场。选定它而不是拨一个真端口,
// 是因为只有传输层失败才会让 net/http 包出 **带完整地址** 的 *url.Error ——
// 那正是要钉住的泄漏形态。
type failingTransport struct{}
func (failingTransport) RoundTrip(*http.Request) (*http.Response, error) {
return nil, errors.New("dial tcp: connection refused")
}
// TestDoJSON_StatusErrorDoesNotEchoBodySentinel 4xx 体里放哨兵 ⇒ 错误里搜不到。
//
// 用非信封体(代理返回的 HTML 错误页就是这样):信封类的体按设计会保留 code(message
// 默认不带),非信封体则一个字节都不留。
func TestDoJSON_StatusErrorDoesNotEchoBodySentinel(t *testing.T) {
t.Parallel()
const sentinel = "SENTINEL-4XX-BODY"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("<html><body>bad gateway " + sentinel + "</body></html>"))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"a": 1})
if err == nil {
t.Fatal("502 却报成功")
}
if !errors.Is(err, ErrAPI) {
t.Fatalf("err = %v, want ErrAPI", err)
}
if strings.Contains(err.Error(), sentinel) {
t.Fatalf("响应体原文进了错误(会随调用方落日志):%v", err)
}
// 可诊断性不能一起丢:来源主机与状态码必须还在,且带得出「体有多大」。
host, _, ok := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
if !ok || !strings.Contains(err.Error(), host) {
t.Fatalf("错误里没有来源主机 %q:%v", host, err)
}
if !strings.Contains(err.Error(), "status=502") {
t.Fatalf("错误里没有状态码:%v", err)
}
if !strings.Contains(err.Error(), "body_bytes=") {
t.Fatalf("错误里没有响应体体积(相关性判断要用):%v", err)
}
}
// TestDoJSON_StatusErrorKeepsEnvelopeCode 非 2xx 的信封体仍要给出 code:这是本客户端
// 唯一认识的诊断面,替换掉体原文之后不能连它一起丢。而 message 是上游自由文本面,
// **默认不带**(M50 口径)—— 上游会用它在被拒时回显她的话。
func TestDoJSON_StatusErrorKeepsEnvelopeCode(t *testing.T) {
t.Parallel()
const sentinel = "invalid content: 她刚才说的那句话"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":40101,"message":"` + sentinel + `","data":null}`))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
if err == nil {
t.Fatal("400 却报成功")
}
if !strings.Contains(err.Error(), "code 40101") || !strings.Contains(err.Error(), "status=400") {
t.Fatalf("信封的 code / 上游 status 丢了:%v", err)
}
// 哨兵出现在 message 里 ⇒ 错误与日志都不得带它(变异:把 envelopeDetail 改回
// 无条件 upstreamText(message) ⇒ 本断言立刻红)。
var logBuf bytes.Buffer
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("send failed", "err", err)
for _, surface := range []string{err.Error(), logBuf.String()} {
if strings.Contains(surface, "她刚才说的那句话") {
t.Fatalf("信封 message(上游回显的她的原话)进了错误或日志:%s", surface)
}
}
// 哨兵链不变:分类靠 errors.Is,不靠文本。
if !errors.Is(err, ErrAPI) || !errors.Is(err, trace.ErrUpstream) {
t.Fatalf("err = %v, want both ErrAPI and trace.ErrUpstream", err)
}
}
// TestDoJSON_NonZeroCodeDropsEnvelopeMessage 是 2xx + `code != 0` 这**另一个分支**:
// 它此前把 message 直接拼进错误(M49 探针复现的原文里 `code 1: invalid content: …`
// 走的就是跨过 apiFailure 的这一支)。code 必须留下,message 必须不带。
func TestDoJSON_NonZeroCodeDropsEnvelopeMessage(t *testing.T) {
t.Parallel()
const sentinel = "invalid content: 她刚才说的那句话"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 200 + 非零 code:KOOK 一类平台的「业务失败也 200」形态。
_, _ = w.Write([]byte(`{"code":1,"message":"` + sentinel + `","data":null}`))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"content": "她刚才说的那句话"})
if err == nil {
t.Fatal("非零 code 却报成功")
}
if strings.Contains(err.Error(), "她刚才说的那句话") {
t.Fatalf("200 + code!=0 分支仍把 message 拼进错误:%v", err)
}
if !strings.Contains(err.Error(), "code 1") {
t.Fatalf("2xx 分支的 code 丢了(那一条没别的分类信息):%v", err)
}
if !errors.Is(err, ErrAPI) {
t.Fatalf("err = %v, want ErrAPI", err)
}
}
// TestDoJSON_EnvelopeMessageOptInRedactsMessageSecrets opt-in(WithEnvelopeMessage)是
// 唯一能带 message 的通道:带上的那一刻仍必须清洗凭据形状。
func TestDoJSON_EnvelopeMessageOptInRedactsMessageSecrets(t *testing.T) {
t.Parallel()
const secret = "big-secret-value-123456"
body := `{"code":40101,"message":"access_token=` + secret + `","data":null}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()),
WithLogger(discardLogger()), WithToken(secret), WithEnvelopeMessage())
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
if err == nil {
t.Fatal("非零 code 却报成功")
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("opt-in 的信封 message 没有清洗凭据形状:%v", err)
}
if !strings.Contains(err.Error(), "[redacted]") {
t.Fatalf("opt-in 生效后错误里没有 message(无法证明这条通道还活着):%v", err)
}
}
// TestDoJSON_EnvelopeMessageOptInIsBounded 钉住 opt-in 下 message 的长度口径:40 rune
// (maxUpstreamTextRunes)+ 省略号。这条让「到底哪一层在截、截到多少」变成可断言的事实 ——
// 上界刻意比 trace.RedactText 的 80 更短(错误里那句话是提示,不是摘要)。
func TestDoJSON_EnvelopeMessageOptInIsBounded(t *testing.T) {
t.Parallel()
long := strings.Repeat("a", 300)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":40101,"message":"` + long + `","data":null}`))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()),
WithLogger(discardLogger()), WithEnvelopeMessage())
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
if err == nil {
t.Fatal("401 却报成功")
}
if !strings.Contains(err.Error(), strings.Repeat("a", maxUpstreamTextRunes)+"…") {
t.Fatalf("信封 message 没有按 %d rune 截断:%v", maxUpstreamTextRunes, err)
}
if strings.Contains(err.Error(), strings.Repeat("a", maxUpstreamTextRunes+1)) {
t.Fatalf("信封 message 超过了 %d rune 上限:%v", maxUpstreamTextRunes, err)
}
}
// TestGetJSON_TransportErrorOmitsTargetCredentials 传输层失败时,错误里不得出现目标
// 地址(含 query 里的凭据)—— net/http 会把整条 URL 放在 *url.Error 里。
func TestGetJSON_TransportErrorOmitsTargetCredentials(t *testing.T) {
t.Parallel()
const secret = "QUERY-SECRET-VALUE"
c := New(
WithBaseURL("http://api.example.com"),
WithHTTPClient(&http.Client{Transport: failingTransport{}}),
WithLogger(discardLogger()),
)
_, err := c.GetJSON(context.Background(), "/gateway/index", url.Values{"access_token": {secret}})
if err == nil {
t.Fatal("传输层失败却报成功")
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("目标地址的凭据进了错误:%v", err)
}
if !strings.Contains(err.Error(), "api.example.com") || !strings.Contains(err.Error(), "connection refused") {
t.Fatalf("剥掉地址时把来源或原因一起丢了:%v", err)
}
}
// TestGetJSON_UserinfoNeverEntersError 绝对地址带 user:pass@ 时同样不得出镜。
//
// 断言用户名而不只是密码:net/http 自己在 *url.Error 里就把密码打成 xxxxx,因此
// 「密码不出现」在剥壳与不剥壳两种实现下都成立(这样的用例是空跑);用户名才是
// 剥壳与不剥壳的分水岭。
func TestGetJSON_UserinfoNeverEntersError(t *testing.T) {
t.Parallel()
c := New(
WithHTTPClient(&http.Client{Transport: failingTransport{}}),
WithLogger(discardLogger()),
)
_, err := c.GetJSON(context.Background(), "https://leakuser:leakpass@api.example.com/x", nil)
if err == nil {
t.Fatal("传输层失败却报成功")
}
msg := err.Error()
if strings.Contains(msg, "leakuser") || strings.Contains(msg, "leakpass") {
t.Fatalf("user:pass@ 进了错误:%v", msg)
}
if !strings.Contains(msg, "api.example.com") {
t.Fatalf("剥掉地址时把来源一起丢了:%v", msg)
}
}
@@ -0,0 +1,48 @@
package httpclient
// 本文件是**渠道侧上游自由文本的唯一清洗入口**(M50)。它管的是同一件事在四个出口上
// 的形态:KOOK 一类 `{code,message}` 信封的 `message`、Telegram Bot API 的
// `description`、OneBot v11 信封的 `msg` / `wording`、iLink CDN 的 `x-error-message`
// 头。四者都是上游给的**人话**,都可能把被拒的请求内容回显出来 —— 而请求内容里就有
// 她的原话。
//
// 🔴 契约(改本文件前先读):
// - 上层口径是 **默认不带**:这些字段默认不进 error、不进日志(见 client.go 的
// WithEnvelopeMessage 与各渠道调用点的注释)。本文件的函数**只在调用方已经显式
// 选择保留**时才被调用 —— 它是「保留时的上限」,不是「可以带」的许可。
// - 清洗只有一份:`trace.RedactTextLimited`(凭据形状打码 + rune 截断)。本包**不**
// 另写一套正则或截断,另写一套必然漂移,而漂移的方向总是「其中一条出口开始原样
// 吐凭据」。
// - 压平空白也是必需的一步:一条诊断只许占一行,否则上游可以用换行伪造日志行
// (log injection)。
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maxUpstreamTextRunes 是上游自由文本允许保留的 rune 上限:**40**。
//
// 为什么比 `trace.RedactText` 的 80 更短:那一档服务的是**摘要素材**(出图、工具结果
// 摘要),读者只看这一条;本文件的产物进的是**错误链与日志**,同一条信息还会被调用方
// 再落一次,长度预算没有理由按摘要给。而 40 rune 已足够装下渠道侧真正有用的那类说明
// (`invalid token`、`参数错误`、`缺少 code`、`FLOOD_WAIT_30`),又短到装不下一句她
// 说的话 —— 上游回显 `invalid content: <正文>` 时,前半句就吃掉大半预算。
const maxUpstreamTextRunes = 40
// upstreamText 把一段上游自由文本收敛成可以出镜的短诊断:压平空白,再交给
// `trace.RedactTextLimited` 打码凭据形状并截到 maxUpstreamTextRunes(超出的带省略号)。
//
// 空串进 ⇒ 空串出;调用方据此判断「这一段有没有东西可显示」。
//
// 🔴 它**不**决定该不该带(默认不带)。把它的结果无条件拼进 error 仍然是把她的原话
// 送上日志 —— 调用点必须自己持有那个显式的开关(本包只有 WithEnvelopeMessage 一处)。
// 渠道插件当前**不带**任何自由文本,因此没有第二个调用点;将来若真要开,也要回到
// 这一条(`trace` 的唯一清洗 + 40 rune),不要在插件里另写一套截断。
func upstreamText(s string) string {
if s == "" {
return ""
}
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), maxUpstreamTextRunes)
}
@@ -0,0 +1,56 @@
package httpclient
// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后,
// 本客户端一行没改也应当自动安全 —— 上游把散文塞进 error.code / error.type 时,
// 错误文本与日志里都搜不到它,而 status / body_bytes 这些诊断面照旧。
//
// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里**没有任何本地守卫**,
// 也就没有任何本地补丁能掩盖缺口。M46 的教训正是「守卫加在调用方 ⇒ 新调用点默认
// 不安全」,所以这一条故意不去调用点补。
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestDoJSON_StatusErrorDropsFreeTextUpstreamCode 4xx 报文的 error.code / error.type
// 里带散文(带空格与标点,不像协议令牌)⇒ 两个字段整体消失,而状态码与体积摘要仍在。
func TestDoJSON_StatusErrorDropsFreeTextUpstreamCode(t *testing.T) {
t.Parallel()
const prose = "Invalid value: '她刚才说的那句话'"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"a": 1})
if err == nil {
t.Fatal("502 却报成功")
}
if !errors.Is(err, ErrAPI) {
t.Fatalf("err = %v, want ErrAPI", err)
}
var logBuf bytes.Buffer
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("upstream call failed", "err", err)
for _, surface := range []string{err.Error(), logBuf.String()} {
if strings.Contains(surface, "她刚才说的那句话") {
t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface)
}
}
// 可诊断性不能一起丢:状态码与体积摘要仍是分类与相关性比对的依据。
for _, want := range []string{"status=502", "body_bytes="} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("错误里缺少 %q:%v", want, err)
}
}
}
+53 -7
View File
@@ -116,17 +116,30 @@ type UnifiedMessage struct {
SenderID string SenderID string
SenderName string SenderName string
Sender Actor Sender Actor
// SelfID 是**她自己在该平台的账号 id**(OneBot 的 self_id、KOOK 的 bot_id…),
// 只从平台元数据取,绝不从正文推断。空串 = 平台没给 / 适配器尚未接线:此时
// 「是否指向她」的判定必须 fail-closed(见 AddressedToMe),判据缺失不等于命中。
SelfID string
Content string Content string
ContentType ContentType ContentType ContentType
Media []Media Media []Media
Mentions []Mention Mentions []Mention
ReplyToID string // MentionAll 报告本条是否 **@全体**(OneBot 的 at 段 qq="all";design/26 §2.4 要求
ReplyToText string // 以结构化标志留痕的 mention_all)。它是 AddressedToMe 的子集信号:true ⇒ 该字段也 true。
GroupID string // 单独留一列是为了让「@全体」与「直接点名她」在数据上可分辨(审计 / 降噪各取所需)。
GroupName string MentionAll bool
ThreadID string // AddressedToMe 报告本条消息在结构上**是否指向她本人**(design/18 §2 触发① 的判据,
MessageID string // 主人 2026-10-13 裁决「甲 = 要」)。语义与推导见 AddressedToSelf:
Timestamp time.Time // 群聊 = 点名她或 @全体;私聊 = 恒 true;平台未给 SelfID = false(fail-closed)。
// 判据**全部来自平台元数据**,绝不匹配正文里的 "@<数字>"(项目红线)。
AddressedToMe bool
ReplyToID string
ReplyToText string
GroupID string
GroupName string
ThreadID string
MessageID string
Timestamp time.Time
// RawData is the opaque native payload the message was converted from. // RawData is the opaque native payload the message was converted from.
RawData []byte RawData []byte
} }
@@ -159,3 +172,36 @@ func (m UnifiedMessage) Validate() error {
// IsGroup reports whether the message belongs to a group conversation. // IsGroup reports whether the message belongs to a group conversation.
func (m UnifiedMessage) IsGroup() bool { return m.ChannelType == ChannelGroup } func (m UnifiedMessage) IsGroup() bool { return m.ChannelType == ChannelGroup }
// MentionedSelf 报告 mentions 里是否**点名**了 selfID:结构化 id 的精确比对,
// 绝不做正文匹配。selfID 为空(平台没给她的账号 id,或适配器未接线)或 mentions
// 为空时一律 false —— 判据缺失就 fail-closed,绝不猜。它是各适配器接线的唯一出处:
// 适配器只需把平台给出的 SelfID 与 Mentions 交给它。
func MentionedSelf(selfID string, mentions []Mention) bool {
if selfID == "" {
return false
}
for _, m := range mentions {
if m.UserID == selfID {
return true
}
}
return false
}
// AddressedToSelf 依据本条消息的**结构化平台元数据**推导 AddressedToMe
// (design/18 §2 触发①;主人 2026-10-13 裁决「甲 = 要」):
//
// - 私聊:恒 true —— 对端就是发送者,本条天然是对她说的;
// - 群聊:点名了她(Mentions 含 SelfID)**或** @全体(MentionAll);
// - SelfID 为空:一律 false —— 判据缺失 ⇒ fail-closed,绝不退回正文文本匹配。
//
// 适配器在填好 SelfID / ChannelType / Mentions / MentionAll 之后调用它回写
// AddressedToMe;推导的**唯一出处**在这里,避免每个适配器各写一份判据而互相漂移。
// 它只用于**真消息**(走 toUnified 的那条路径),系统事件包装不适用。
func (m UnifiedMessage) AddressedToSelf() bool {
if m.ChannelType == ChannelDirect {
return true
}
return m.MentionAll || MentionedSelf(m.SelfID, m.Mentions)
}
+102
View File
@@ -149,3 +149,105 @@ func TestContentTypeValid(t *testing.T) {
t.Error("bogus ContentType.Valid() = true, want false") t.Error("bogus ContentType.Valid() = true, want false")
} }
} }
func TestMentionedSelf(t *testing.T) {
t.Parallel()
mentions := []Mention{{UserID: "all"}, {UserID: "u-2"}}
tests := []struct {
name string
selfID string
mentions []Mention
want bool
}{
{name: "platform has not provided self id", selfID: "", mentions: mentions, want: false},
{name: "no mentions", selfID: "u-2", mentions: nil, want: false},
{name: "points at someone else", selfID: "u-9", mentions: mentions, want: false},
{name: "points at her", selfID: "u-2", mentions: mentions, want: true},
{name: "at-all marker is not a user id", selfID: "all", mentions: nil, want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := MentionedSelf(tt.selfID, tt.mentions); got != tt.want {
t.Errorf("MentionedSelf(%q, %+v) = %v, want %v", tt.selfID, tt.mentions, got, tt.want)
}
})
}
}
func TestAddressedToSelf(t *testing.T) {
t.Parallel()
tests := []struct {
name string
msg UnifiedMessage
want bool
}{
{
name: "private chat is always addressed to her",
msg: UnifiedMessage{ChannelType: ChannelDirect, SelfID: "u-1"},
want: true,
},
{
name: "private chat without platform self id stays addressed",
msg: UnifiedMessage{ChannelType: ChannelDirect},
want: true,
},
{
name: "group mentions her",
msg: UnifiedMessage{
ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1",
Mentions: []Mention{{UserID: "u-1"}},
},
want: true,
},
{
name: "group mentions someone else",
msg: UnifiedMessage{
ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1",
Mentions: []Mention{{UserID: "u-2"}},
},
want: false,
},
{
name: "group mentions all",
msg: UnifiedMessage{
ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1", MentionAll: true,
Mentions: []Mention{{UserID: "all"}},
},
want: true,
},
{
name: "group without mention",
msg: UnifiedMessage{ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1"},
want: false,
},
{
name: "group without platform self id is fail-closed",
msg: UnifiedMessage{
ChannelType: ChannelGroup, GroupID: "g-1",
Mentions: []Mention{{UserID: "u-1"}},
},
want: false,
},
{
name: "zero value stays fail-closed for adapters that do not wire it",
msg: UnifiedMessage{},
want: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.msg.AddressedToSelf(); got != tt.want {
t.Errorf("AddressedToSelf() = %v, want %v (msg=%+v)", got, tt.want, tt.msg)
}
})
}
}
+137
View File
@@ -0,0 +1,137 @@
package adapter
// 本文件承载**出站消息 id 的出口**(切片 OUTBOUNDID / M12 的 A 层)。
//
// 背景(M11 查明的缺口):`PlatformAdapter.SendMessage(ctx, resp) error` 只回成败,
// **没有任何位置**放下「平台给这条消息分配的 id」。于是她自己刚发出的那条消息在系统里
// 没有身份 —— 平台侧明明把 id 回给了我们,却在解析的那一刻就被丢掉。任何「接她的话」
// (对方回复的是**她自己**那条消息 ⇒ `UnifiedMessage.ReplyToID` 落在她自己的出站 id 上)
// 的判定都无从谈起。
//
// ## 形态选择:**可选接口 + 调用方探测**(不采用返回 id 的直接改签名,也不用回调)
//
// 范式与 `SystemNoticeNormalizer` / `DeliveryFailure` 逐字同族 —— 都在本包,都是
// 「适配器按需实现、调用方探测」,理由也一样:契约层是唯一不必让任何一方反向依赖
// 具体插件的落点。
//
// - **不把它加进 `PlatformAdapter`**:加方法等于契约破坏 —— 四个未接线适配器
// (kook / telegram / weixin-ilink / webhook)与全部桩实现会**立刻编译失败**,
// 而它们拿不到 id 的原因是**平台不给**,不是「没接线」。向后兼容是硬指标。
// - **也不采用回调 / sink 注册**:那会把 id 变成异步、无关联的推送 —— 调用方拿到一条
// id 时已经无法确定它属于哪一次发送、哪一个会话;重连与重试下还会把上一轮的旧 id
// 混进环形表。出站 id 是**某一次投递的属性**,就该随那次投递一起返回。
//
// ## 三条铁律(任何实现都必须遵守)
//
// 1. **取不到就空**:`SendReceipt.MessageID` 为空一律表示「不知道」,绝不用占位值 /
// 序号 / 时间戳兜底。一个编出来的 id 会让「接她的话」凭空命中(把别人的话认成
// 回给她的)。宁可判不出来,不许判错。
// 2. **三态可分辨**:`SendIDStatus` 把「回执带回了 id」「回执到了但没带 id」
// 「压根没等到回执」分开 —— 排障时才分得清「平台没上报」与「我们没等到」。
// 3. **不改变既有投递语义**:这是**增量** seam。`SendMessage` 的成败判据、限流、
// 目标解析一律照旧;没实现本接口的适配器行为一个字节都不变。
import "context"
// SendOutcome 是一次投递的完整结果:**逐条**的投递凭据,顺序与
// `UnifiedResponse.Messages` 一一对应。
//
// 用结构体而不是裸切片:日后要给投递结果补别的属性(逐条耗时可观测、失败分类)时,
// 签名不必再动一次。
type SendOutcome struct {
// Receipts 是每条已投递消息的回执。调用方必须按**下标**与 Messages 对齐 ——
// 长度不一致说明适配器没遵守契约(实现应保证等长)。
Receipts []SendReceipt
}
// SendReceipt 是一条出站消息的投递凭据。`MessageID` 是出站 id 的**唯一**出口。
type SendReceipt struct {
// MessageID 是平台回执给出的消息 id(OneBot 的 `data.message_id`)。
//
// **空串一律表示「不知道」**:没等到回执、回执里没有这个字段、或平台根本不给。
// 绝不编造 —— 下游判「对方是不是在接她的话」时,空 id 必须落成「判不出来」,
// 而不是拿一个假值去撞环形表。
MessageID string
// IDStatus 说明上面那个空 / 非空是怎么来的(见 SendIDStatus 的三态)。
IDStatus SendIDStatus
}
// SendIDStatus 是出站 id 的**到达状态**(三态,绝不合并)。
//
// 零值即 `SendIDUnreported`(不知道):把「不知道」设为零值是刻意的 fail-closed
// 设计 —— 一个只写了 `SendReceipt{}` 的实现天然表示「我取不到 id」,绝不会因为
// 忘了填某个字段而被读成「取到了」。
type SendIDStatus string
const (
// SendIDUnreported 表示**没等到回执**:等待超时 / 连接在投递中途断了 / 适配器
// 压根没有取 id 的能力(此时它不该实现 OutboundReceiptAdapter,但调用方探测失败
// 时也走这一态)。帧可能已经写出去了 ——「发没发出去」由 error 回答,这一态只回
// 答「id 拿没拿到」。
SendIDUnreported SendIDStatus = ""
// SendIDReported 表示**回执把 id 带回来了**(此时 MessageID 必非空)。
SendIDReported SendIDStatus = "reported"
// SendIDAbsent 表示**回执到了,但它没带 id**(字段缺失 / 为 null / 形态不可用)。
// 与 SendIDUnreported 分开:前者是「平台明确给了个空」,后者是「我们没等到」。
SendIDAbsent SendIDStatus = "absent"
)
// NewSendReceipt 由平台回执里的 id 构造凭据:非空 ⇒ Reported,空 ⇒ Absent。
//
// 它是**唯一的构造出处**,因此 `MessageID` 与 `IDStatus` 不可能自相矛盾
// (不会出现「状态说取到了、id 却是空的」)。只用于「回执已经到了」的场景 ——
// 没等到回执时用零值 `SendReceipt{}`(SendIDUnreported)。
func NewSendReceipt(platformID string) SendReceipt {
if platformID == "" {
return SendReceipt{IDStatus: SendIDAbsent}
}
return SendReceipt{MessageID: platformID, IDStatus: SendIDReported}
}
// Reported 报告这条凭据里是否真的有一个可用的平台 id。
func (r SendReceipt) Reported() bool {
return r.IDStatus == SendIDReported && r.MessageID != ""
}
// OutboundReceiptAdapter 是**可选**的出站凭据 seam:能在投递时给出平台消息 id 的适配器
// 实现它。调用方用 `SendWithReceipt` 探测,绝不直接类型断言。
type OutboundReceiptAdapter interface {
// SendMessageWithReceipt 的投递语义与 `SendMessage` **完全一致**(同一套目标解析、
// 限流、错误与重试口径),额外逐条回一条投递凭据。
//
// 返回的 error 只描述**投递**:拿到了回执但回执报错、或回执里没有 id,都**不是**
// 这里的 error(那是 `SendIDStatus` 的答案)。error 非 nil 时 `SendOutcome` 可能
// 只带了前几条的凭据 —— 调用方按 `len(Receipts)` 取**已投递**的那几条。
SendMessageWithReceipt(ctx context.Context, resp UnifiedResponse) (SendOutcome, error)
}
// SendWithReceipt 投递一次回复并**尽力**取回平台消息 id —— 调用方(B 层的环形表)
// 的唯一入口。
//
// - 适配器实现了 `OutboundReceiptAdapter`:走它的凭据路径(HTTP / WS 各按自己的
// 平台协议取)。
// - 没实现(kook / telegram / weixin-ilink / webhook 等):退回既有的 `SendMessage`,
// 并**如实**为每条消息报一条 `SendIDUnreported` 的空凭据 —— 不是失败,是「这个
// 渠道给不出 id」。调用方据此就知道该渠道的环形表永远是空的,而不是以为取到了。
//
// 两种情况的 Receipts 长度都等于 len(resp.Messages),调用方不必分叉处理。
func SendWithReceipt(ctx context.Context, a PlatformAdapter, resp UnifiedResponse) (SendOutcome, error) {
if a == nil {
return SendOutcome{}, ErrNotConnected
}
if ra, ok := a.(OutboundReceiptAdapter); ok {
return ra.SendMessageWithReceipt(ctx, resp)
}
if err := a.SendMessage(ctx, resp); err != nil {
return SendOutcome{}, err
}
return SendOutcome{Receipts: makeUnknownReceipts(len(resp.Messages))}, nil
}
// makeUnknownReceipts 造 n 条「不知道」的空凭据(零值即不知道,见 SendIDStatus)。
func makeUnknownReceipts(n int) []SendReceipt {
if n <= 0 {
return nil
}
return make([]SendReceipt, n)
}
+150
View File
@@ -0,0 +1,150 @@
package adapter
// 本文件证明出站 id 出口(outbound.go)的三条硬性质:**向后兼容**(老适配器一行不改、
// 探测失败即如实报「不知道」)、**三态可分辨**、**取不到就是空 / 绝不编造**。
import (
"context"
"errors"
"testing"
)
// receiptAdapter 是同时实现了 PlatformAdapter 与 OutboundReceiptAdapter 的桩:
// 它记录两条路径各被调用了几次,用来证明「实现了 seam 的适配器**只**走凭据路径」
// (绝不出现一条消息发两遍)。
type receiptAdapter struct {
mockAdapter
receiptCalls int
receipts []SendReceipt
}
func (m *receiptAdapter) SendMessageWithReceipt(_ context.Context, resp UnifiedResponse) (SendOutcome, error) {
m.receiptCalls++
m.sent = resp.Messages
return SendOutcome{Receipts: m.receipts}, nil
}
var (
_ PlatformAdapter = (*receiptAdapter)(nil)
_ OutboundReceiptAdapter = (*receiptAdapter)(nil)
)
// failAdapter 的投递恒失败,用来验证错误原样穿过 SendWithReceipt。
type failAdapter struct {
mockAdapter
err error
}
func (f *failAdapter) SendMessage(context.Context, UnifiedResponse) error { return f.err }
func TestNewSendReceiptNeverReportsEmptyID(t *testing.T) {
t.Parallel()
absent := NewSendReceipt("")
if absent.MessageID != "" || absent.IDStatus != SendIDAbsent || absent.Reported() {
t.Errorf("NewSendReceipt(\"\") = %+v, want absent with empty id", absent)
}
reported := NewSendReceipt("1700000012345")
if reported.MessageID != "1700000012345" || !reported.Reported() {
t.Errorf("NewSendReceipt(id) = %+v, want reported id", reported)
}
}
// TestSendReceiptZeroValueIsUnknown 钉住 fail-closed 的零值口径:一个只写了
// `SendReceipt{}` 的实现表示「不知道」,绝不能被读成「平台给了 id」。
func TestSendReceiptZeroValueIsUnknown(t *testing.T) {
t.Parallel()
var zero SendReceipt
if zero.IDStatus != SendIDUnreported {
t.Errorf("zero IDStatus = %q, want %q", zero.IDStatus, SendIDUnreported)
}
if zero.MessageID != "" || zero.Reported() {
t.Errorf("zero receipt = %+v, want empty and not reported", zero)
}
}
// TestPlainAdapterIsNotAReceiptAdapter 是**向后兼容**的运行时证据:一个只实现
// PlatformAdapter 的适配器(等价于 kook / telegram / weixin-ilink / webhook)**不是**
// OutboundReceiptAdapter —— 契约没被破坏,它们一行都不用改。
func TestPlainAdapterIsNotAReceiptAdapter(t *testing.T) {
t.Parallel()
var a PlatformAdapter = &mockAdapter{name: "telegram"}
if _, ok := a.(OutboundReceiptAdapter); ok {
t.Fatal("plain adapter must not satisfy OutboundReceiptAdapter")
}
}
// TestSendWithReceiptFallsBackToSendMessage 覆盖未接线渠道:投递照旧走 SendMessage,
// 凭据如实报「未取到」——**逐条**与 Messages 等长,且 id 全空(绝不编造)。
func TestSendWithReceiptFallsBackToSendMessage(t *testing.T) {
t.Parallel()
ad := &mockAdapter{name: "telegram"}
resp := UnifiedResponse{Messages: []UnifiedMessage{
{ChannelType: ChannelDirect, ContentType: ContentText, Content: "一"},
{ChannelType: ChannelDirect, ContentType: ContentText, Content: "二"},
}}
out, err := SendWithReceipt(context.Background(), ad, resp)
if err != nil {
t.Fatalf("SendWithReceipt() error = %v", err)
}
if len(ad.sent) != 2 {
t.Fatalf("fallback did not deliver through SendMessage: sent = %+v", ad.sent)
}
if len(out.Receipts) != 2 {
t.Fatalf("Receipts len = %d, want 2 (one per message)", len(out.Receipts))
}
for i, r := range out.Receipts {
if r.MessageID != "" || r.IDStatus != SendIDUnreported || r.Reported() {
t.Errorf("receipt[%d] = %+v, want empty id with unreported status", i, r)
}
}
}
// TestSendWithReceiptUsesSeamWhenImplemented 证明实现了 seam 的适配器只走凭据路径
// (不会两条路径各发一遍)。
func TestSendWithReceiptUsesSeamWhenImplemented(t *testing.T) {
t.Parallel()
ad := &receiptAdapter{receipts: []SendReceipt{NewSendReceipt("9001")}}
ad.name = "qq-obv11"
out, err := SendWithReceipt(context.Background(), ad, UnifiedResponse{Messages: []UnifiedMessage{
{ChannelType: ChannelGroup, GroupID: "123", ContentType: ContentText, Content: "pong"},
}})
if err != nil {
t.Fatalf("SendWithReceipt() error = %v", err)
}
if ad.receiptCalls != 1 {
t.Errorf("receipt path calls = %d, want 1", ad.receiptCalls)
}
if len(out.Receipts) != 1 || out.Receipts[0].MessageID != "9001" {
t.Errorf("Receipts = %+v, want one reported 9001", out.Receipts)
}
}
func TestSendWithReceiptPropagatesError(t *testing.T) {
t.Parallel()
want := errors.New("boom")
ad := &failAdapter{err: want}
_, err := SendWithReceipt(context.Background(), ad, UnifiedResponse{Messages: []UnifiedMessage{
{ChannelType: ChannelDirect, ContentType: ContentText, Content: "x"},
}})
if !errors.Is(err, want) {
t.Fatalf("SendWithReceipt() error = %v, want %v", err, want)
}
}
func TestSendWithReceiptNilAdapter(t *testing.T) {
t.Parallel()
if _, err := SendWithReceipt(context.Background(), nil, UnifiedResponse{}); !errors.Is(err, ErrNotConnected) {
t.Fatalf("SendWithReceipt(nil) error = %v, want ErrNotConnected", err)
}
}
+3 -14
View File
@@ -39,6 +39,7 @@ import (
"time" "time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
) )
const ( const (
@@ -179,7 +180,7 @@ func Fetch(ctx context.Context, req Request) ([]byte, error) {
resp, err := client.Do(httpReq) resp, err := client.Do(httpReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("mediafetch: 取回 %s 失败: %w", origin, sanitizeErr(err)) return nil, fmt.Errorf("mediafetch: 取回 %s 失败: %w", origin, trace.URLCause(err))
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
@@ -189,7 +190,7 @@ func Fetch(ctx context.Context, req Request) ([]byte, error) {
// 边读边限:多读一个字节用来判定「超限」,因此绝不会把一份超大文件读满内存。 // 边读边限:多读一个字节用来判定「超限」,因此绝不会把一份超大文件读满内存。
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil { if err != nil {
return nil, fmt.Errorf("mediafetch: 读取 %s 失败: %w", origin, sanitizeErr(err)) return nil, fmt.Errorf("mediafetch: 读取 %s 失败: %w", origin, trace.URLCause(err))
} }
if int64(len(data)) > maxBytes { if int64(len(data)) > maxBytes {
return nil, fmt.Errorf("%w(上限 %d 字节,来源 %s)", ErrTooLarge, maxBytes, origin) return nil, fmt.Errorf("%w(上限 %d 字节,来源 %s)", ErrTooLarge, maxBytes, origin)
@@ -239,18 +240,6 @@ func applyHeader(dst, src http.Header) {
} }
} }
// sanitizeErr 剥掉 *url.Error 的外壳:它会把**完整 URL**(含查询串里的签名凭据)
// 拼进错误串。剥壳同时保留内层哨兵链(`context.DeadlineExceeded` 等仍可用
// errors.Is 判定)。内层已是最终错误时**原样透传**(这里只做剥壳,不新增包装 ——
// 再包一层只会把同一个原因说两遍)。
func sanitizeErr(err error) error {
var uerr *url.Error
if errors.As(err, &uerr) && uerr.Err != nil {
return uerr.Err
}
return err
}
// originOfURL 把一个已解析的 URL 归一成来源字符串。 // originOfURL 把一个已解析的 URL 归一成来源字符串。
func originOfURL(u *url.URL) (string, error) { func originOfURL(u *url.URL) (string, error) {
if u == nil { if u == nil {
+54
View File
@@ -0,0 +1,54 @@
package session
// 本文件把「消息 → 会话」的键派生**导出**给别的层复用(M22 复审 P1-1)。
//
// 为什么必须导出:会话的键一直是会话层自己算的(`channelID` + `platform \x00 channelID`),
// 而渠道桥接器的**每会话回合车道**需要「什么算同一个会话」这个判据与它**同源**。
// 各算各的会漂移,而且漂移的后果不是排序不好看,是**同一个 session 两条车道、两个回合
// 并发**——两条 run 共享 sessionID,checkpoint 行 id `{sessionID}:{step}` 互相覆盖、
// `loadCheckpoint` 可能续到另一条在途 run 的快照而重放工具调用,flow 桶
// `p.graphs[sessionID]` 被先收尾者删掉(loop 没有会话级互斥,IM 侧也没有网关那层
// `session_turn_fence` 兜底)。
//
// 两处真实反例(都来自复审实测):
// - kook:同一 guild 的两个 text channel —— `ChannelID` 不同(c1/c2)但 `GroupID` 相同,
// 会话层按 `group:<GroupID>` 判成**同一个** session;任何把 `ChannelID` 当会话判据的
// 派生都会把它们拆成两条车道。
// - 多账号:同一平台两个账号对同一对端 —— 账号名不同(`telegram:conf0`/`:conf1`)、
// `platform` 常量相同、`ChannelID` 相同 ⇒ 也是**同一个** session;任何把「账号」当
// 会话判据的派生都会把它们拆开。
//
// 因此本文件只做一件事:把既有的派生**原样**导出,语义一个字不改(`channelID` 仍是唯一
// 的实现,见 manager.go)。
import "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
// sessionKeySep 分隔 (platform, channel_id) 两个维度。用 NUL 是因为平台名与渠道 id 都
// 不可能包含它,拼接因此不会产生歧义(`a` + "b" 与 `ab` + "" 不会撞键)。
const sessionKeySep = "\x00"
// ChannelKey 返回一条入站消息所代表的**渠道**键(会话派生的第二个维度):
// 群消息按 `group:<GroupID>`(加前缀使群不会与同 id 的私聊渠道撞键),私聊按
// `ChannelID`,没有显式渠道 id 时退回 `SenderID`。
//
// 它就是 `SessionManager.GetOrCreate` 内部用的那一个函数(manager.go 的 `channelID`),
// 导出只为让别的层(渠道桥接器的回合车道)能与会话键同源,**不得**在别处另写一份。
// 返回空串表示这条消息没有可用的渠道(`GetOrCreate` 会以 `ErrInvalidChannel` 拒掉它)。
func ChannelKey(msg adapter.UnifiedMessage) string { return channelID(msg) }
// Key 返回一条入站消息的**会话键** —— 与 `SessionManager` 的缓存键和
// `channel_sessions` 表的唯一键**逐字相同**(`platform \x00 channelID`)。
//
// 它是本包对外唯一的「什么算同一个会话」判据:任何需要按会话串行/分桶的调用方都用它,
// 不要自己拼 `platform|channel` 之类的近似键(那正是 M22 复审里「车道键与会话键不同源」
// 的成因)。返回的键在渠道不可用时仍然非空(`platform\x00`),调用方若需要区分,
// 用 ChannelKey 判空。
func Key(msg adapter.UnifiedMessage) string {
return sessionKey(msg.Platform, channelID(msg))
}
// sessionKey 拼接 (platform, channel_id) 两维度。本包内所有「会话键」的**唯一**出处
// (GetOrCreate / List / 导出的 Key 都经这里),因此不可能各自漂移。
func sessionKey(platform, cid string) string {
return platform + sessionKeySep + cid
}
+89
View File
@@ -0,0 +1,89 @@
package session
// 本文件钉住 keys.go 导出的两个派生:`ChannelKey` 与会话层内部 `channelID` 是**同一个**
// 实现,`Key` 与会话缓存键**逐字相同**。它们是「什么算同一个会话」的唯一定义 —— 车道
// (internal/services/channelbridge/lane.go)按会话串行就靠它,两处一旦分叉,同一个
// session 就会出现两条车道、两个回合并发(M22 复审 P1-1)。
import (
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
func TestChannelKeyDerivations(t *testing.T) {
t.Parallel()
cases := []struct {
name string
msg adapter.UnifiedMessage
want string
}{
{
name: "群:按 group:<GroupID>,与频道无关",
msg: adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g1"},
want: "group:g1",
},
{
name: "群:缺 GroupID 时退回 ChannelID",
msg: adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1"},
want: "c1",
},
{
name: "私聊:按 ChannelID",
msg: adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect, ChannelID: "200", SenderID: "200"},
want: "200",
},
{
name: "私聊:缺 ChannelID 时退回 SenderID",
msg: adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect, SenderID: "200"},
want: "200",
},
{
name: "私聊:两者都缺 ⇒ 空(调用方据此以 ErrInvalidChannel 拒掉)",
msg: adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect},
want: "",
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := ChannelKey(c.msg); got != c.want {
t.Fatalf("ChannelKey = %q, want %q", got, c.want)
}
})
}
}
// TestKeyIsTheSessionManagerCacheKey 钉住「导出的 Key 就是会话缓存键」:
// 形状 `platform \x00 channelID`(NUL 分隔,杜绝拼接歧义),且两维度都参与 ——
// 同平台不同渠道、不同平台同渠道都必须是**不同**的键。
func TestKeyIsTheSessionManagerCacheKey(t *testing.T) {
t.Parallel()
group1 := adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g1"}
group1OtherChannel := adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c2", GroupID: "g1"}
group2 := adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g2"}
otherPlatform := adapter.UnifiedMessage{Platform: "qq-obv11", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g1"}
if got, want := Key(group1), "kook\x00group:g1"; got != want {
t.Fatalf("Key = %q, want %q", got, want)
}
// 同一 guild 的两个 text channel 在会话层是**同一个**会话。
if Key(group1) != Key(group1OtherChannel) {
t.Fatalf("same guild channels must share one session key: %q vs %q", Key(group1), Key(group1OtherChannel))
}
// 不同群、不同平台必须是不同的会话。
if Key(group1) == Key(group2) {
t.Fatalf("different groups must not share a session key: %q", Key(group1))
}
if Key(group1) == Key(otherPlatform) {
t.Fatalf("different platforms must not share a session key: %q", Key(group1))
}
// 渠道缺失时键仍非空,但 ChannelKey 能为空(调用方据此区分不可用渠道)。
noChannel := adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect}
if Key(noChannel) == "" || ChannelKey(noChannel) != "" {
t.Fatalf("missing-channel case: Key=%q ChannelKey=%q", Key(noChannel), ChannelKey(noChannel))
}
}
+2 -2
View File
@@ -110,7 +110,7 @@ func (m *SessionManager) GetOrCreate(ctx context.Context, msg adapter.UnifiedMes
if cid == "" { if cid == "" {
return Session{}, fmt.Errorf("session: get-or-create %s: %w", msg.Platform, ErrInvalidChannel) return Session{}, fmt.Errorf("session: get-or-create %s: %w", msg.Platform, ErrInvalidChannel)
} }
key := msg.Platform + "\x00" + cid key := sessionKey(msg.Platform, cid)
// 快路径:已绑定且车道与本次解析一致——只读锁即可返回。 // 快路径:已绑定且车道与本次解析一致——只读锁即可返回。
if s, ok := m.lookupCached(key); ok && s.ActorRole == actor.Role { if s, ok := m.lookupCached(key); ok && s.ActorRole == actor.Role {
@@ -155,7 +155,7 @@ func (m *SessionManager) List(ctx context.Context) ([]Session, error) {
if err := rows.Scan(&platform, &cid, &sessionID, &roleStr); err != nil { if err := rows.Scan(&platform, &cid, &sessionID, &roleStr); err != nil {
return nil, fmt.Errorf("session: scan list: %w", err) return nil, fmt.Errorf("session: scan list: %w", err)
} }
key := platform + "\x00" + cid key := sessionKey(platform, cid)
if cached, ok := m.lookupCached(key); ok { if cached, ok := m.lookupCached(key); ok {
sessions = append(sessions, cached) sessions = append(sessions, cached)
continue continue
+27 -2
View File
@@ -12,7 +12,8 @@ import (
// 为什么用独立加载器而不是挂进 Config 根结构:Config 由 Load 统一解码,而 // 为什么用独立加载器而不是挂进 Config 根结构:Config 由 Load 统一解码,而
// 本切片刻意**不碰** Config / 记忆整理那组键(并行切片正在改它们)。这里复用 // 本切片刻意**不碰** Config / 记忆整理那组键(并行切片正在改它们)。这里复用
// 与 LoadLLMDefaults 同一手法(parseDotEnv + 同一份 `env:"…"` 解码器), // 与 LoadLLMDefaults 同一手法(parseDotEnv + 同一份 `env:"…"` 解码器),
// 因此优先级与 Load 逐字一致:进程环境变量 > `.env` > 标签默认值。 // 因此优先级与 Load 逐字一致:进程环境变量 > `.env` > 标签默认值(TimeoutSec
// 没有标签默认值,见该字段的注释 —— 未配置时它读出 0,由 memory 包定其生效值)。
// envPath 为空表示不看 `.env`(与 Load 的约定一致)。 // envPath 为空表示不看 `.env`(与 Load 的约定一致)。
type MemoryReviewConfig struct { type MemoryReviewConfig struct {
// Enabled 是入库前审查总开关,默认 true。依据:主人 2026-10-05 的裁定 // Enabled 是入库前审查总开关,默认 true。依据:主人 2026-10-05 的裁定
@@ -28,12 +29,36 @@ type MemoryReviewConfig struct {
ScanLimit int `env:"MEMORY_REVIEW_SCAN_LIMIT" default:"200"` ScanLimit int `env:"MEMORY_REVIEW_SCAN_LIMIT" default:"200"`
// TimeoutSec 是单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**, // TimeoutSec 是单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**,
// 记忆照旧保留 + WARN(design/23 §15 D3-5 的 fail-safe)。 // 记忆照旧保留 + WARN(design/23 §15 D3-5 的 fail-safe)。
TimeoutSec int `env:"MEMORY_REVIEW_TIMEOUT_SEC" default:"20"` //
// 🔴 **本字段刻意没有 `default:` 标签**(2026-10-13,切片 M16)。单条审查预算的
// **唯一真源**是 memory 包的 `defaultReviewTimeout`(90s,推导见
// internal/runtime/memory/preinsert_config.go 的文件头注释)。这里原写
// `default:"20"`,后果不是「默认值是 20」而是**默认值根本不由 memory 侧决定**:
// env 层永远产出一个非零值 ⇒ memory 侧那道「≤0 才回落默认」的归一化成了**死代码**
// (90s 从未生效,生产启动行 `memory ingest review started … timeout=20s` 即铁证,
// 而实测召回链就要 21~23s ⇒ 每条候选满额的审查都超时、连留痕都不写)。
//
// 去掉字面默认后的**生效值**(路径:本字段 → cmd/cyrene/wire_memory_review.go 的
// `ReviewConfig.Timeout` → `normalizeReviewConfig`):
//
// `.env` / 进程环境没写这个键 ⇒ 这里 0 ⇒ memory 侧回落 90s
// 写了正数 ⇒ 原样生效(归一化只动 ≤0)
// 写了 0 / 负数 ⇒ 回落 90s(与项目其它旋钮同一口径:≤0 = 用默认)
//
// ⚠️ 别在这里重新加回字面默认值 —— 那会让 memory 侧的 90s 再次变成死代码,
// 而 memory 包的 TestReviewTimeoutEffectiveValueIsTheOnlyDefault 会立刻变红
// (它走完整组装路径取生效值,不比对常量)。
TimeoutSec int `env:"MEMORY_REVIEW_TIMEOUT_SEC"`
} }
// LoadMemoryReviewConfig 读取入库前审查的旋钮。非法(负数)值不在这里报错, // LoadMemoryReviewConfig 读取入库前审查的旋钮。非法(负数)值不在这里报错,
// 而是与项目其它旋钮同一口径 —— **≤0 视为「用默认」**,由 memory 包 normalize // 而是与项目其它旋钮同一口径 —— **≤0 视为「用默认」**,由 memory 包 normalize
// 收敛,避免一个笔误让进程起不来。 // 收敛,避免一个笔误让进程起不来。
//
// 🔴 因此本函数**对 TimeoutSec 不做任何回落**(切片 M16):返回 0 就是「没配」,
// 调用方必须原样交给 memory 包的 ReviewConfig(组合根今天就是这么做的),
// 「0 ⇒ 多少秒」由 `normalizeReviewConfig` 在那**唯一一处**决定。调用方若在这里
// 又补一个自己的默认值,全链就会重新长出第二个真源 —— 那正是本切片修掉的形态。
func LoadMemoryReviewConfig(envPath string) (MemoryReviewConfig, error) { func LoadMemoryReviewConfig(envPath string) (MemoryReviewConfig, error) {
fromEnv := map[string]string{} fromEnv := map[string]string{}
if envPath != "" { if envPath != "" {
+60 -2
View File
@@ -1,13 +1,47 @@
package config package config
import ( import (
"os"
"testing" "testing"
) )
// reviewTimeoutEnvUnset 让 MEMORY_REVIEW_TIMEOUT_SEC 在本次用例中**真的未设置**。
//
// 为什么不用 t.Setenv(key, ""):空串在 decode.go 的 lookupRaw 里**照样命中 env 分支**
// (`os.LookupEnv` 对空值返回 ok=true)⇒ 配置层读出 0,于是**看不见 tag 上的字面
// 默认值** —— 而那正是本切片的回归面(tag 上再加回 `default:"20"` 必须变红)。
// 未设置才会走到 tag 默认那一档。
func reviewTimeoutEnvUnset(t *testing.T) {
t.Helper()
old, had := os.LookupEnv("MEMORY_REVIEW_TIMEOUT_SEC")
t.Cleanup(func() {
if had {
_ = os.Setenv("MEMORY_REVIEW_TIMEOUT_SEC", old)
return
}
_ = os.Unsetenv("MEMORY_REVIEW_TIMEOUT_SEC")
})
if err := os.Unsetenv("MEMORY_REVIEW_TIMEOUT_SEC"); err != nil {
t.Fatalf("unset MEMORY_REVIEW_TIMEOUT_SEC: %v", err)
}
}
// TestLoadMemoryReviewConfigDefaults 锁定 D3「入库前审查」(design/23 §15.1 D3) // TestLoadMemoryReviewConfigDefaults 锁定 D3「入库前审查」(design/23 §15.1 D3)
// 的 env 契约默认值。它们必须与 memory 包的兜底默认值一致 —— 两处不一致会让 // 的 env 契约默认值。它们必须与 memory 包的兜底默认值一致 —— 两处不一致会让
// 「没配这个键」与「配了但解析失败」走向不同的行为。 // 「没配这个键」与「配了但解析失败」走向不同的行为。
//
// 🔴 TimeoutSec 是**例外的那一个,而且是刻意的**(切片 M16,2026-10-13):
// 它**没有**字面默认值,未配置时就是 0,含义是「没配」而不是「0 秒」。
// 单条审查预算的唯一真源在 memory 包的 `defaultReviewTimeout`(90s),
// 全链的生效值 = `ReviewConfig.Timeout` 经 `normalizeReviewConfig` 归一化之后的值。
//
// 为什么不在这里断言 90:那会让本仓重新长出**两个**真源(env tag 一处、
// memory 常量一处),而 `.env` 冻结口径(AGENTS §10)本来就把这类「上限/调优」
// 参数指向**代码默认值**。断言生效值的那条测试在 memory 包
// (TestReviewTimeoutEffectiveValueIsTheOnlyDefault,走完整组装路径)——
// 本包不能引 memory 包(memory → config 已成立,反向会成环)。
func TestLoadMemoryReviewConfigDefaults(t *testing.T) { func TestLoadMemoryReviewConfigDefaults(t *testing.T) {
reviewTimeoutEnvUnset(t)
cfg, err := LoadMemoryReviewConfig("") cfg, err := LoadMemoryReviewConfig("")
if err != nil { if err != nil {
t.Fatalf("LoadMemoryReviewConfig() error: %v", err) t.Fatalf("LoadMemoryReviewConfig() error: %v", err)
@@ -21,13 +55,19 @@ func TestLoadMemoryReviewConfigDefaults(t *testing.T) {
if cfg.ScanLimit != 200 { if cfg.ScanLimit != 200 {
t.Errorf("MEMORY_REVIEW_SCAN_LIMIT = %d, want 200(§15 D3-4 每条审查有界)", cfg.ScanLimit) t.Errorf("MEMORY_REVIEW_SCAN_LIMIT = %d, want 200(§15 D3-4 每条审查有界)", cfg.ScanLimit)
} }
if cfg.TimeoutSec != 20 { if cfg.TimeoutSec != 0 {
t.Errorf("MEMORY_REVIEW_TIMEOUT_SEC = %d, want 20(§15 D3-5 超时即不改写)", cfg.TimeoutSec) t.Errorf("MEMORY_REVIEW_TIMEOUT_SEC = %d, want 0(未配置)—— "+
"这里出现非零值说明 tag 上又被加回了字面默认值,memory 侧的 "+
"defaultReviewTimeout 会再次变成死代码(M16 修的正是这个形态)", cfg.TimeoutSec)
} }
} }
// TestLoadMemoryReviewConfigEnvOverrides 锁定键名与类型:写进 .env 的旋钮真的 // TestLoadMemoryReviewConfigEnvOverrides 锁定键名与类型:写进 .env 的旋钮真的
// 生效(键名写错会静默沿用默认值,这类错误只能靠这条用例抓住)。 // 生效(键名写错会静默沿用默认值,这类错误只能靠这条用例抓住)。
//
// TimeoutSec 另钉一条口径(M16):**正数原样透传、≤0 原样透传**。配置层不做
// 「坏值回落」——「0 或负数 ⇒ 用默认 90s」是 memory 包 normalizeReviewConfig
// 的语义,全链只有那一处决定它(组合根不许在这里补默认值)。
func TestLoadMemoryReviewConfigEnvOverrides(t *testing.T) { func TestLoadMemoryReviewConfigEnvOverrides(t *testing.T) {
t.Setenv("MEMORY_REVIEW_ENABLED", "false") t.Setenv("MEMORY_REVIEW_ENABLED", "false")
t.Setenv("MEMORY_REVIEW_QUEUE_SIZE", "8") t.Setenv("MEMORY_REVIEW_QUEUE_SIZE", "8")
@@ -41,4 +81,22 @@ func TestLoadMemoryReviewConfigEnvOverrides(t *testing.T) {
if cfg.Enabled || cfg.QueueSize != 8 || cfg.ScanLimit != 32 || cfg.TimeoutSec != 3 { if cfg.Enabled || cfg.QueueSize != 8 || cfg.ScanLimit != 32 || cfg.TimeoutSec != 3 {
t.Fatalf("MEMORY_REVIEW_* 未生效:%+v", cfg) t.Fatalf("MEMORY_REVIEW_* 未生效:%+v", cfg)
} }
for _, tc := range []struct {
raw string
want int
}{
{"0", 0}, // 显式 0 = 用默认(下游归一化),不许在这里被改成 90
{"-5", -5}, // 负数同样是「用默认」,也不在这里报错(AGENTS:不让笔误把进程弄起不来)
} {
t.Setenv("MEMORY_REVIEW_TIMEOUT_SEC", tc.raw)
got, err := LoadMemoryReviewConfig("")
if err != nil {
t.Fatalf("LoadMemoryReviewConfig(%q) error: %v", tc.raw, err)
}
if got.TimeoutSec != tc.want {
t.Fatalf("MEMORY_REVIEW_TIMEOUT_SEC=%q 读出 %d, want %d(配置层不回落,回落只在 memory 包)",
tc.raw, got.TimeoutSec, tc.want)
}
}
} }
@@ -0,0 +1,475 @@
// channels_coalesce.go 承载**渠道静默窗口合并**(M23)的落盘面(M23 蓝图 §7)。
//
// 它回答的是主人 2026-10-23/24 的原话「要让群聊聊天很自然,很流畅的做法」:群里
// 「一鼓作气打三句」不该让她回三次。窗口/上限这些旋钮属于**调优**,因此按
// design/13 §2.1「`.env` 冻结 ⇒ 新配置走 `$DATA_DIR/config/**` + 代码默认值」落在
// $DATA_DIR/config/coalesce.json,**零新 env 键**。
//
// 本文件是纯存储层:它只负责读写、默认值归一与 clamp,**不做任何行为判定** ——
// 「哪些消息进窗口」「主人私聊为什么没有窗口」的判据在
// internal/services/channelbridge/coalesce*.go(车道类型由 actor 元数据决定,
// **配置无权翻转**:这里既没有「对主人私聊生效」的键,也没有能改车道类型的开关)。
package runtimeconfig
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// coalesceConfigSubdir 与 owner_lane.json / backthink.json 共用 $DATA_DIR 下的
// config 子目录口径。
coalesceConfigSubdir = "config"
// coalesceFileName 是静默窗口合并旋钮的落盘文件名。
coalesceFileName = "coalesce.json"
// coalesceLogSource 是本模块日志的来源标记(AGENTS §10:禁止无来源裸日志)。
coalesceLogSource = "config/runtimeconfig/coalesce"
// clamp 上下限(M23 蓝图 §4 + 塔 2026-10-24 裁定):窗口 300–15000ms、
// 绝对上限 ≥ 窗口且 ≤ 30000ms、单批 1–50 条。越界值**clamp + WARN**,
// 绝不静默采信一个不可能的值,也绝不因此拦住入站。
CoalesceWindowMinMs = 300
CoalesceWindowMaxMs = 15000
CoalesceMaxWaitMinMs = CoalesceWindowMinMs
CoalesceMaxWaitMaxMs = 30000
CoalesceBatchMin = 1
CoalesceBatchMax = 50
coalesceDefaultWindow = 2000
coalesceDefaultWait = 6000
coalesceDefaultBatch = 24
)
// DefaultCoalescePath 返回静默窗口合并配置文件在 dataDir 下的路径。
func DefaultCoalescePath(dataDir string) string {
return filepath.Join(dataDir, coalesceConfigSubdir, coalesceFileName)
}
// CoalesceConversationKey 把**车道键**(`platform \x00 channelID`,与会话键同源)
// 渲染成落盘/面板用的可读键:NUL 换成 `|`。
//
// 为什么需要这一层:车道键里的分隔符是 NUL(`session.Key` 的唯一实现点决定),
// 直接写进 JSON 会变成 `\u0000` —— 面板的输入框里看不见、复制不出来,主人根本
// 没法给某个群配覆盖。反向不歧义:平台名与渠道 id 都不含 `|`,所以这个渲染是
// 单射的;桥只按「同一个函数」把车道键映射成配置键,绝不在别处另拼一份。
func CoalesceConversationKey(laneKey string) string {
return strings.ReplaceAll(laneKey, "\x00", "|")
}
// CoalescePolicy 是一组**生效**的静默窗口参数(已归一、已 clamp)。
//
// 零值**不是**「关闭」的等价物(Enabled=false 才是):读盘缺失时走
// DefaultCoalescePolicy,绝不因为「没写这个键」就把特性当成关闭。
type CoalescePolicy struct {
// Enabled 是群聊窗口的总开关;false ⇒ 行为逐字回到 M22(到达即投递、一条一轮)。
Enabled bool `json:"enabled"`
// WindowMs 是**静默窗口**:滑动语义,新消息重置计时(「等她把话说完」)。
WindowMs int `json:"windowMs"`
// MaxWaitMs 是**绝对上限**,锚定在批内第一条的到达时刻:一个不停说话的群
// 因此不会让计时永不归零(宁可晚回,不许不回)。
MaxWaitMs int `json:"maxWaitMs"`
// MaxBatchMessages 是单批消息条数上限;超出的**不是丢弃**,是由车道回插队首
// 成为下一批(见 channelbridge/coalesce.go 的 split)。
MaxBatchMessages int `json:"maxBatchMessages"`
// ApplyToStrangers 决定陌生人私聊是否也走窗口;false ⇒ 陌生人私聊逐条跑。
ApplyToStrangers bool `json:"applyToStrangers"`
// StrangerWindowMs 是陌生人私聊的独立窗口(v1 与 WindowMs 同值,纯预留:
// 蓝图 §3 明确「现状没有证据支撑两个值」,先留字段避免日后改结构)。
StrangerWindowMs int `json:"strangerWindowMs"`
}
// DefaultCoalescePolicy 返回代码默认值(也是面板「留空 ⇒ 跟随」的那一档)。
func DefaultCoalescePolicy() CoalescePolicy {
return CoalescePolicy{
Enabled: true,
WindowMs: coalesceDefaultWindow,
MaxWaitMs: coalesceDefaultWait,
MaxBatchMessages: coalesceDefaultBatch,
ApplyToStrangers: true,
StrangerWindowMs: coalesceDefaultWindow,
}
}
// CoalescePolicyPatch 是**三态补丁**:键缺位(nil)= 不动;有值 = 采用。
// `null` 在文件层没有清除语义(每个键都有代码默认值兜底),因此无需 Clear 集合。
type CoalescePolicyPatch struct {
Enabled *bool `json:"enabled,omitempty"`
WindowMs *int `json:"windowMs,omitempty"`
MaxWaitMs *int `json:"maxWaitMs,omitempty"`
MaxBatchMessages *int `json:"maxBatchMessages,omitempty"`
ApplyToStrangers *bool `json:"applyToStrangers,omitempty"`
StrangerWindowMs *int `json:"strangerWindowMs,omitempty"`
}
// apply 把补丁里**出现过的**键叠到 p 上(缺位键保持 p 的原值)。
func (patch CoalescePolicyPatch) apply(p CoalescePolicy) CoalescePolicy {
if patch.Enabled != nil {
p.Enabled = *patch.Enabled
}
if patch.WindowMs != nil {
p.WindowMs = *patch.WindowMs
}
if patch.MaxWaitMs != nil {
p.MaxWaitMs = *patch.MaxWaitMs
}
if patch.MaxBatchMessages != nil {
p.MaxBatchMessages = *patch.MaxBatchMessages
}
if patch.ApplyToStrangers != nil {
p.ApplyToStrangers = *patch.ApplyToStrangers
}
if patch.StrangerWindowMs != nil {
p.StrangerWindowMs = *patch.StrangerWindowMs
}
return p
}
// merge 返回自身与后到补丁的合并(后者出现过的键优先)。写盘时用它保留
// 「这次没提到的键」的旧值 —— 三态里的「不动」在文件层就落成这个合并。
func (patch CoalescePolicyPatch) merge(next CoalescePolicyPatch) CoalescePolicyPatch {
out := patch
if next.Enabled != nil {
out.Enabled = next.Enabled
}
if next.WindowMs != nil {
out.WindowMs = next.WindowMs
}
if next.MaxWaitMs != nil {
out.MaxWaitMs = next.MaxWaitMs
}
if next.MaxBatchMessages != nil {
out.MaxBatchMessages = next.MaxBatchMessages
}
if next.ApplyToStrangers != nil {
out.ApplyToStrangers = next.ApplyToStrangers
}
if next.StrangerWindowMs != nil {
out.StrangerWindowMs = next.StrangerWindowMs
}
return out
}
// Empty 报告补丁是否一个键都没有(写盘时据此跳过空改写)。
func (patch CoalescePolicyPatch) Empty() bool {
return patch.Enabled == nil && patch.WindowMs == nil && patch.MaxWaitMs == nil &&
patch.MaxBatchMessages == nil && patch.ApplyToStrangers == nil &&
patch.StrangerWindowMs == nil
}
// CoalesceFile 是 coalesce.json 的形状:一个全局 default 加一份每会话覆盖表
// (键 = CoalesceConversationKey(车道键))。
type CoalesceFile struct {
Default CoalescePolicyPatch `json:"default"`
Conversations map[string]CoalescePolicyPatch `json:"conversations,omitempty"`
}
// EffectiveDefault 返回「代码默认 + 盘上 default 段」归一后的**生效值**(已 clamp)。
// 面板据此显示「当前值」,而 Values() 显示「你写进去的键」—— 两者刻意分开:
// 一个是运行期事实,一个是盘上原文。
func (f CoalesceFile) EffectiveDefault() CoalescePolicy {
p, _ := ClampCoalescePolicy(f.Default.apply(DefaultCoalescePolicy()))
return p
}
// EffectiveConversation 返回某条会话覆盖的生效值:全局生效值 → 该会话覆盖 → clamp。
func (f CoalesceFile) EffectiveConversation(key string) (CoalescePolicy, bool) {
conv, ok := f.Conversations[key]
if !ok {
return CoalescePolicy{}, false
}
p, _ := ClampCoalescePolicy(conv.apply(f.EffectiveDefault()))
return p, true
}
// Values 返回该补丁里**出现过的**键(键缺位 ⇒ 不出现),供面板「留空 ⇒ 跟随默认」
// 如实回显。值是该键在盘上的原值(未经 clamp)。
func (patch CoalescePolicyPatch) Values() map[string]any {
out := map[string]any{}
if patch.Enabled != nil {
out["enabled"] = *patch.Enabled
}
if patch.WindowMs != nil {
out["windowMs"] = *patch.WindowMs
}
if patch.MaxWaitMs != nil {
out["maxWaitMs"] = *patch.MaxWaitMs
}
if patch.MaxBatchMessages != nil {
out["maxBatchMessages"] = *patch.MaxBatchMessages
}
if patch.ApplyToStrangers != nil {
out["applyToStrangers"] = *patch.ApplyToStrangers
}
if patch.StrangerWindowMs != nil {
out["strangerWindowMs"] = *patch.StrangerWindowMs
}
return out
}
// ClampCoalescePolicy 把一组参数夹进可运行区间,并返回**被改动过**的说明
// (调用方按 WARN 落地:越界不是错误,但必须留痕,否则「我配了 100ms」会变成
// 一个没人知道的 300ms)。
func ClampCoalescePolicy(p CoalescePolicy) (CoalescePolicy, []string) {
var notes []string
if p.WindowMs != 0 && p.WindowMs < CoalesceWindowMinMs {
notes = append(notes, fmt.Sprintf("windowMs=%d 太小,取 %d", p.WindowMs, CoalesceWindowMinMs))
p.WindowMs = CoalesceWindowMinMs
}
if p.WindowMs > CoalesceWindowMaxMs {
notes = append(notes, fmt.Sprintf("windowMs=%d 太大,取 %d", p.WindowMs, CoalesceWindowMaxMs))
p.WindowMs = CoalesceWindowMaxMs
}
if p.WindowMs == 0 {
// 蓝图 §4:`0` 与 enabled:false 同义(「窗口 0 毫秒」不可能是有意的调优)。
notes = append(notes, "windowMs=0 视为关闭静默窗口")
p.Enabled = false
p.WindowMs = coalesceDefaultWindow
}
if p.StrangerWindowMs <= 0 {
p.StrangerWindowMs = p.WindowMs
}
if p.StrangerWindowMs < CoalesceWindowMinMs || p.StrangerWindowMs > CoalesceWindowMaxMs {
notes = append(notes, fmt.Sprintf("strangerWindowMs=%d 越界,取 windowMs=%d", p.StrangerWindowMs, p.WindowMs))
p.StrangerWindowMs = p.WindowMs
}
if p.MaxWaitMs < p.WindowMs {
notes = append(notes, fmt.Sprintf("maxWaitMs=%d 小于窗口,取 %d", p.MaxWaitMs, p.WindowMs))
p.MaxWaitMs = p.WindowMs
}
if p.MaxWaitMs > CoalesceMaxWaitMaxMs {
notes = append(notes, fmt.Sprintf("maxWaitMs=%d 太大,取 %d", p.MaxWaitMs, CoalesceMaxWaitMaxMs))
p.MaxWaitMs = CoalesceMaxWaitMaxMs
}
if p.MaxBatchMessages < CoalesceBatchMin {
notes = append(notes, fmt.Sprintf("maxBatchMessages=%d 太小,取 %d", p.MaxBatchMessages, CoalesceBatchMin))
p.MaxBatchMessages = CoalesceBatchMin
}
if p.MaxBatchMessages > CoalesceBatchMax {
notes = append(notes, fmt.Sprintf("maxBatchMessages=%d 太大,取 %d", p.MaxBatchMessages, CoalesceBatchMax))
p.MaxBatchMessages = CoalesceBatchMax
}
return p, notes
}
// ValidateCoalescePatch 拒绝那些**不是调优、而是敲错**的补丁(负数;类型错误已由
// JSON 解码挡掉)。返回错误 ⇒ 调用方回 400 且**盘上一个字节都不动**。
func ValidateCoalescePatch(p CoalescePolicyPatch) error {
fields := []struct {
name string
v *int
}{
{"windowMs", p.WindowMs},
{"maxWaitMs", p.MaxWaitMs},
{"maxBatchMessages", p.MaxBatchMessages},
{"strangerWindowMs", p.StrangerWindowMs},
}
for _, f := range fields {
if f.v != nil && *f.v < 0 {
return fmt.Errorf("%s 不能为负数(%d)", f.name, *f.v)
}
}
return nil
}
// CoalescePatch 是 PUT 请求体的形状(三态):`default` 缺位 ⇒ 全局不动、
// 出现过的键采用;`conversations` 里出现过的键逐键采用,值为 `null` ⇒
// **删掉**该会话覆盖(回到全局 default),没提到的会话一概不动。
type CoalescePatch struct {
Default *CoalescePolicyPatch `json:"default,omitempty"`
Conversations map[string]*CoalescePolicyPatch `json:"conversations,omitempty"`
}
// Empty 报告补丁是否什么都没提(面板「没改任何键就点保存」不该在盘上留一次改写)。
func (p CoalescePatch) Empty() bool {
if p.Default != nil && !p.Default.Empty() {
return false
}
return len(p.Conversations) == 0
}
// CoalesceStore 是静默窗口旋钮的读写面:内存缓存 + 原子落盘。它可安全并发使用。
// 读缺失文件、坏文件都退回默认值(绝不因为配置坏了而阻断入站),但留带来源的 WARN。
type CoalesceStore struct {
path string
log *slog.Logger
mu sync.RWMutex
cfg CoalesceFile
}
// NewCoalesceStore 构造 store 并尽力加载已有文件。path 为空或文件缺失时走默认值,
// 不返回错误:配置坏了只影响窗口取值,绝不该拦住渠道启动。
func NewCoalesceStore(path string, log *slog.Logger) *CoalesceStore {
if log == nil {
log = slog.Default()
}
s := &CoalesceStore{
path: path,
log: logger.WithSource(log, coalesceLogSource),
cfg: CoalesceFile{Default: CoalescePolicyPatch{}, Conversations: map[string]CoalescePolicyPatch{}},
}
s.load()
return s
}
// Path 返回这份配置的落点(面板如实回显「改的是哪个文件」用)。
func (s *CoalesceStore) Path() string { return s.path }
// Present 报告配置文件是否已存在(false = 还没写过,运行期正走代码默认)。
func (s *CoalesceStore) Present() bool {
if s.path == "" {
return false
}
_, err := os.Stat(s.path)
return err == nil
}
// load 尽力从磁盘读入配置。文件不存在是正常情况(首次运行),静默走默认值;
// 其它读/解析错误留 WARN 后仍走默认值。
func (s *CoalesceStore) load() {
if s.path == "" {
return
}
raw, err := os.ReadFile(s.path)
if err != nil {
if !os.IsNotExist(err) {
s.log.Warn("coalesce config unreadable, using defaults", "path", s.path, "error", err)
}
return
}
var f CoalesceFile
if err := json.Unmarshal(raw, &f); err != nil {
s.log.Warn("coalesce config invalid, using defaults", "path", s.path, "error", err)
return
}
if f.Conversations == nil {
f.Conversations = map[string]CoalescePolicyPatch{}
}
s.mu.Lock()
s.cfg = f
s.mu.Unlock()
s.logClamp(f)
}
// logClamp 把 disk 上那份的越界值一次性记进日志(每次 load/Set 一次,不是每次发车)。
func (s *CoalesceStore) logClamp(f CoalesceFile) {
if _, notes := ClampCoalescePolicy(f.Default.apply(DefaultCoalescePolicy())); len(notes) > 0 {
s.log.Warn("coalesce default clamped", "path", s.path, "notes", strings.Join(notes, "; "))
}
for key, patch := range f.Conversations {
if _, notes := ClampCoalescePolicy(patch.apply(DefaultCoalescePolicy())); len(notes) > 0 {
s.log.Warn("coalesce conversation override clamped",
"path", s.path, "conversation", key, "notes", strings.Join(notes, "; "))
}
}
}
// PolicyFor 返回某条车道键**生效**的窗口参数:代码默认 → 全局 default → 该会话覆盖,
// 逐键叠完后 clamp。它永不返回「未设置」:调用方永远拿到一个可运行的值。
//
// 它读的是内存(不碰磁盘),因此面板保存后**下一次发车**就生效(热生效),
// 也与入站路径一样不会被一次落盘抖动卡住。
func (s *CoalesceStore) PolicyFor(laneKey string) CoalescePolicy {
s.mu.RLock()
def := s.cfg.Default
conv, ok := s.cfg.Conversations[CoalesceConversationKey(laneKey)]
s.mu.RUnlock()
p, _ := ClampCoalescePolicy(def.apply(DefaultCoalescePolicy()))
if ok {
p, _ = ClampCoalescePolicy(conv.apply(p))
}
return p
}
// Snapshot 返回配置的一份拷贝(**盘上原值**,未经 clamp),供面板/测试读取。
// 与 PolicyFor 的区别是刻意的:面板要如实回显「你写进去的是什么」,
// 而不是「运行期把它夹成了什么」。
func (s *CoalesceStore) Snapshot() CoalesceFile {
s.mu.RLock()
defer s.mu.RUnlock()
out := CoalesceFile{Default: s.cfg.Default, Conversations: make(map[string]CoalescePolicyPatch, len(s.cfg.Conversations))}
for k, v := range s.cfg.Conversations {
out.Conversations[k] = v
}
return out
}
// Apply 应用一份三态补丁:出现过的键采用,缺位的键保持原值;conversations 里
// 值为 nil 的键 = **删掉**该会话覆盖(回到全局 default)。内存与落盘在同一把锁内,
// 因此并发写入不会互相覆盖,也不会留下半份文件。返回 (是否真的改了盘上的字节)。
//
// 校验(ValidateCoalescePatch)由调用方在**进本函数之前**做完:走到这里意味着
// 「这份补丁合法」,落盘因此不会出现「写了一半才发现非法」。
func (s *CoalesceStore) Apply(patch CoalescePatch) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
before, _ := json.Marshal(s.cfg)
if patch.Default != nil {
s.cfg.Default = s.cfg.Default.merge(*patch.Default)
}
if s.cfg.Conversations == nil {
s.cfg.Conversations = map[string]CoalescePolicyPatch{}
}
for key, conv := range patch.Conversations {
if conv == nil {
delete(s.cfg.Conversations, key)
continue
}
s.cfg.Conversations[key] = s.cfg.Conversations[key].merge(*conv)
}
after, _ := json.Marshal(s.cfg)
if string(before) == string(after) {
return false, nil
}
if err := s.saveLocked(); err != nil {
return false, fmt.Errorf("runtimeconfig: save coalesce config %s: %w", s.path, err)
}
return true, nil
}
// saveLocked 原子写出当前配置(临时文件 + rename)。调用方**必须持有 s.mu**。
// 0600 与 owner_lane.json 同口径:这份文件只被后端自己读写。
func (s *CoalesceStore) saveLocked() error {
if s.path == "" {
return nil
}
dir := filepath.Dir(s.path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
}
raw, err := json.MarshalIndent(s.cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temp in %s: %w", dir, err)
}
tmpName := tmp.Name()
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return fmt.Errorf("chmod %s: %w", tmpName, err)
}
if _, err := tmp.Write(raw); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return fmt.Errorf("write %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return fmt.Errorf("close %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, s.path); err != nil {
_ = os.Remove(tmpName)
return fmt.Errorf("rename: %w", err)
}
return nil
}
@@ -0,0 +1,211 @@
// channels_ownerlane.go 承载 owner 车道结构化旋钮的**落盘面**(design/18 §11
// 「乙 = C」,主人 2026-10-13 裁决):主人在群里说「少说点」这类诉求不再靠她自己记,
// 而是被写成一份可读可改的每群配置(默认值 + 每群覆盖),面板后续照这份形状写入。
//
// 它落在 $DATA_DIR/config/owner_lane.json(与 backthink.json 同一目录口径、同一
// 「.env 冻结 ⇒ 新配置走 $DATA_DIR/config/**」纪律,design/13 §2.1),**不进 .env**。
// 本文件是纯存储层:它只负责读写与默认值归一,**不做任何行为判定** ——
// 「说不说 / 多频繁」的判据在 channelbridge/ownerlane.go,身份权限仍只由 actor 元数据决定。
package runtimeconfig
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// ownerLaneConfigSubdir 与 backthink.json 共用 $DATA_DIR 下的 config 子目录口径。
ownerLaneConfigSubdir = "config"
// ownerLaneFileName 是 owner 车道旋钮的落盘文件名。
ownerLaneFileName = "owner_lane.json"
// ownerLaneLogSource 是本模块日志的来源标记,使「旋钮被谁改成什么」跨模块可定位
// (项目规范 §五:禁止无来源裸日志)。
ownerLaneLogSource = "config/runtimeconfig/ownerlane"
)
// DefaultOwnerLanePath 返回 owner 车道旋钮文件在 dataDir 下的路径。
func DefaultOwnerLanePath(dataDir string) string {
return filepath.Join(dataDir, ownerLaneConfigSubdir, ownerLaneFileName)
}
// OwnerLanePolicy 是 owner 车道的一档参与策略(design/18 §11 的可调旋钮):
// 它**只**影响「说不说 / 多频繁」,绝不动权限、工具面或 actor 元数据(AGENTS §9)。
//
// 零值 = 最保守的一档(只回指向她的消息),也就是裁决「甲」的默认口径。
type OwnerLanePolicy struct {
// ReplyToUnaddressed 是「说不说」开关:true 时主人**非指向她**的群消息也参与回复;
// false(默认)时只回指向她的(@她 / @全体 / 未接线渠道的旧判据)。
ReplyToUnaddressed bool `json:"replyToUnaddressed"`
// MinReplyGapSec 是「多频繁」:owner 车道两条**非指向她**的回复之间的最小间隔(秒)。
// 0 = 不额外限制。指向她的消息一律必回,**不受本字段约束**(硬保留)。
MinReplyGapSec int `json:"minReplyGapSec"`
}
// DefaultOwnerLanePolicy 返回裁决「甲」的默认口径:不对她说就不开口。
func DefaultOwnerLanePolicy() OwnerLanePolicy {
return OwnerLanePolicy{}
}
// OwnerLaneFile 是 owner_lane.json 的形状:一个全局 default(可被面板改)
// 加一份每群覆盖表。键见 OwnerLaneGroupKey。
type OwnerLaneFile struct {
Default OwnerLanePolicy `json:"default"`
Groups map[string]OwnerLanePolicy `json:"groups,omitempty"`
}
// OwnerLaneGroupKey 是每群覆盖表的键:平台 + 群 id。群 id 为空表示**全局默认**那一档
// (面板把「所有群」的旋钮写进 default 时用它)。
func OwnerLaneGroupKey(platform, groupID string) string {
return platform + "|" + groupID
}
// OwnerLaneStore 是 owner 车道旋钮的读写面:内存缓存 + 落盘。它可安全并发使用。
// 读缺失文件、坏文件都退回默认值(绝不因为配置坏了而阻断入站),但会留一条带来源的 WARN。
type OwnerLaneStore struct {
path string
log *slog.Logger
mu sync.RWMutex
cfg OwnerLaneFile
}
// NewOwnerLaneStore 构造 store 并尽力加载已有文件。path 为空或文件缺失时走默认值,
// 不返回错误:配置坏了只影响旋钮取值,绝不该拦住渠道启动。
func NewOwnerLaneStore(path string, log *slog.Logger) *OwnerLaneStore {
if log == nil {
log = slog.Default()
}
s := &OwnerLaneStore{
path: path,
log: logger.WithSource(log, ownerLaneLogSource),
cfg: OwnerLaneFile{Default: DefaultOwnerLanePolicy(), Groups: map[string]OwnerLanePolicy{}},
}
s.load()
return s
}
// load 尽力从磁盘读入配置。文件不存在是正常情况(首次运行),静默走默认值;
// 其它读/解析错误留一条带来源的 WARN 后仍走默认值。
func (s *OwnerLaneStore) load() {
if s.path == "" {
return
}
raw, err := os.ReadFile(s.path)
if err != nil {
if !os.IsNotExist(err) {
s.log.Warn("owner lane config unreadable, using defaults", "path", s.path, "error", err)
}
return
}
var f OwnerLaneFile
if err := json.Unmarshal(raw, &f); err != nil {
s.log.Warn("owner lane config invalid, using defaults", "path", s.path, "error", err)
return
}
if f.Groups == nil {
f.Groups = map[string]OwnerLanePolicy{}
}
s.mu.Lock()
s.cfg = f
s.mu.Unlock()
}
// PolicyFor 返回某个 (platform, groupID) 生效的策略:有每群覆盖就用它,否则用全局
// default(default 缺省即「甲」)。它不返回「未设置」,因此调用方永远拿到一个可用的档位。
func (s *OwnerLaneStore) PolicyFor(platform, groupID string) OwnerLanePolicy {
s.mu.RLock()
defer s.mu.RUnlock()
if p, ok := s.cfg.Groups[OwnerLaneGroupKey(platform, groupID)]; ok {
return p
}
return s.cfg.Default
}
// Snapshot 返回配置的一份拷贝,供面板/测试读取(改动它不影响 store)。
func (s *OwnerLaneStore) Snapshot() OwnerLaneFile {
s.mu.RLock()
defer s.mu.RUnlock()
out := OwnerLaneFile{Default: s.cfg.Default, Groups: make(map[string]OwnerLanePolicy, len(s.cfg.Groups))}
for k, v := range s.cfg.Groups {
out.Groups[k] = v
}
return out
}
// Set 写入某个 (platform, groupID) 的策略并落盘。groupID 为空表示写全局 default 档。
//
// 内存改动与**落盘都在同一把锁内**(review p1):并发 Set 因此完全串行 —— 既不会两个
// 写者去写同一个临时文件把 JSON 写坏,也不会因为原子 rename 的完成先后与各快照的新旧
// 不一致而丢掉「后发生的那次更新」。落盘量是一个小 JSON,持锁期间的 I/O 代价可忽略;
// 读侧(PolicyFor)读的是内存、不碰文件,因此不会被这次落盘抖动。
//
// 内存先改、失败返回错误(调用方留可见 WARN),但内存里的新档位仍然生效 ——
// 「改不动磁盘」不该让已经做出的行为决定回退。
func (s *OwnerLaneStore) Set(platform, groupID string, p OwnerLanePolicy) error {
s.mu.Lock()
defer s.mu.Unlock()
if groupID == "" {
s.cfg.Default = p
} else {
if s.cfg.Groups == nil {
s.cfg.Groups = map[string]OwnerLanePolicy{}
}
s.cfg.Groups[OwnerLaneGroupKey(platform, groupID)] = p
}
if err := s.saveLocked(); err != nil {
return fmt.Errorf("runtimeconfig: save owner lane config %s: %w", s.path, err)
}
return nil
}
// saveLocked 原子写出当前配置。调用方**必须持有 s.mu**。
//
// 它每次都用**新建的临时文件**(`os.CreateTemp`,仓库同款先例见 internal/user/save.go)
// 再原子 rename:即便有别的进程/goroutine 也在写同一个目录,也不会撞同一个临时文件名,
// 而半份文件永远不会以 s.path 的名字出现。
func (s *OwnerLaneStore) saveLocked() error {
if s.path == "" {
return nil
}
dir := filepath.Dir(s.path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
}
raw, err := json.MarshalIndent(s.cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temp in %s: %w", dir, err)
}
tmpName := tmp.Name()
// CreateTemp 以 0600 打开;显式再 chmod 一次,使落盘文件不因 umask 变化而更宽。
// 任一步失败都删掉临时文件,绝不留垃圾。
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return fmt.Errorf("chmod %s: %w", tmpName, err)
}
if _, err := tmp.Write(raw); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return fmt.Errorf("write %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return fmt.Errorf("close %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, s.path); err != nil {
_ = os.Remove(tmpName)
return fmt.Errorf("rename: %w", err)
}
return nil
}
@@ -0,0 +1,134 @@
package runtimeconfig
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
)
// TestOwnerLaneStoreDefaultsWhenFileMissing 钉住默认档(裁决「甲」):
// 文件不存在时读到的就是最保守的一档 —— 不对她说就不开口,且没有额外频率闸。
func TestOwnerLaneStoreDefaultsWhenFileMissing(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config", "owner_lane.json")
s := NewOwnerLaneStore(path, nil)
got := s.PolicyFor("qq-obv11", "672432767")
if got.ReplyToUnaddressed || got.MinReplyGapSec != 0 {
t.Fatalf("默认档 = %+v, want 零值(不对她说就不开口)", got)
}
}
// TestOwnerLaneStoreSetPersistsAndReloads 钉住「可读可改」:Set 落盘后,新建的
// store 能读回同一个每群覆盖;未覆盖的群仍走全局 default。
func TestOwnerLaneStoreSetPersistsAndReloads(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config", "owner_lane.json")
s := NewOwnerLaneStore(path, nil)
want := OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 120}
if err := s.Set("qq-obv11", "672432767", want); err != nil {
t.Fatalf("Set: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("owner_lane.json 未落盘:%v", err)
}
reloaded := NewOwnerLaneStore(path, nil)
if got := reloaded.PolicyFor("qq-obv11", "672432767"); got != want {
t.Fatalf("重载后该群策略 = %+v, want %+v", got, want)
}
// 未被覆盖的群仍走全局 default(零值)。
if got := reloaded.PolicyFor("qq-obv11", "another-group"); got != DefaultOwnerLanePolicy() {
t.Fatalf("未覆盖群策略 = %+v, want 默认档", got)
}
}
// TestOwnerLaneStoreGlobalDefault 钉住面板可改的「全局 default」那一档:
// groupID 为空即写默认,所有未单独覆盖的群都跟着变。
func TestOwnerLaneStoreGlobalDefault(t *testing.T) {
t.Parallel()
s := NewOwnerLaneStore(filepath.Join(t.TempDir(), "config", "owner_lane.json"), nil)
global := OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 60}
if err := s.Set("", "", global); err != nil {
t.Fatalf("Set default: %v", err)
}
if got := s.PolicyFor("telegram", "-20"); got != global {
t.Fatalf("全局默认生效值 = %+v, want %+v", got, global)
}
}
// TestOwnerLaneStoreCorruptFileFallsBackToDefaults 钉住「配置坏了也不阻断」:
// 畸形文件只退回默认档并留 WARN,绝不 panic、绝不返回错误阻断渠道启动。
func TestOwnerLaneStoreCorruptFileFallsBackToDefaults(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "config", "owner_lane.json")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte("{ this is not json "), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
s := NewOwnerLaneStore(path, nil)
if got := s.PolicyFor("qq-obv11", "g"); got != DefaultOwnerLanePolicy() {
t.Fatalf("坏配置下策略 = %+v, want 默认档", got)
}
}
// TestOwnerLaneStoreConcurrentSetKeepsAllUpdates 钉住并发 Set 的安全性(review p1):
// 落盘必须与内存改动**同一把锁**、且用**新建的临时文件**,否则两个写者会撞同一个
// tmp 名把 JSON 写坏(⇒ load 退回默认「甲」、主人刚说的「少说点」白说且无提示),
// 或者 rename 的完成顺序与新快照的先后不一致,丢掉后发生的更新。
//
// 修前必红(原实现:固定 `path+".tmp"` + mu.Unlock() 之后落盘),断言:
// ① 每一轮结束后文件都是**合法 JSON**;
// ② 本轮写过的**每一个**键都能在最终文件里读到(不丢更新)。
func TestOwnerLaneStoreConcurrentSetKeepsAllUpdates(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config", "owner_lane.json")
s := NewOwnerLaneStore(path, nil)
const (
rounds = 24
writers = 24
)
for round := 0; round < rounds; round++ {
var wg sync.WaitGroup
for i := 0; i < writers; i++ {
key := fmt.Sprintf("g-%d-%d", round, i)
wg.Add(1)
go func(i int) {
defer wg.Done()
// 写者的返回值故意不看:修前它会因 tmp 被别的写者 rename 走而报错,
// 但那正是本用例要靠**文件内容**抓住的失败。
_ = s.Set("qq-obv11", key, OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: i})
}(i)
}
wg.Wait()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("round %d: 读回 owner_lane.json 失败:%v", round, err)
}
var f OwnerLaneFile
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatalf("round %d: 并发落盘把配置写坏了(文件不是合法 JSON):%v\n内容=%q", round, err, raw)
}
for i := 0; i < writers; i++ {
key := OwnerLaneGroupKey("qq-obv11", fmt.Sprintf("g-%d-%d", round, i))
if _, ok := f.Groups[key]; !ok {
t.Fatalf("round %d: 丢更新 —— 键 %s 不在最终文件里(并发的某次 Set 没落盘)", round, key)
}
}
}
}
@@ -0,0 +1,150 @@
// memorytidy_save.go 是 `$DATA_DIR/config/memory-tidy.json` 的**写路径**(面板调优)。
//
// 为什么需要它(主人 2026-10-07 的两条口径,逐字):「`.env` 只做兜底用 在除了第1次
// 部署之后 后端自动创建 独立的面板配置文件之后就走面板配置文件。这些键优先从面板
// 配置上取」+「频率必须调 可以调到 1 小时或者三小时 或者动态分配,比如白天是间隔短
// 一点 晚上到了静默期稍微长一点」。在这条写路径出现之前,那个文件**只有读路径**
// (LoadMemoryTidyTuning + 启动播种 SeedMemoryTidy):主人要改频率只能改文件 + 重启。
//
// 它与读取侧(memorytidy.go)是**同一份文件形状的唯一两个门**:decodeMemoryTidy
// 仍是判「这份文件能不能被读走」的唯一那一行代码,写路径不另立一套判定。
//
// 四条口径(与既有的 SaveChannels / SaveWebSearch 同族同手法):
//
// 1. **补丁语义**:调用方给的是「要设的键(原样 JSON 值)+ 要删的键」,不是整份
// 文档。盘上其它键(`t1Judge` / `autoApplyPerRound` …以及将来新增的键)**原样
// 保留** —— 一次面板保存绝不把主人手写进文件的其它旋钮抹掉。
// 2. **值不变就不落盘**:合并后的文档与盘上那份**语义相同**(同一个 MarshalIndent
// 口径比较,而不是比原始字节)时**一个字节都不写**、连 mtime 都不动 —— 主人的
// 手写排版不该因为一次空操作被重排。
// 3. **读不走的文件拒绝覆盖**:文件存在但不是 JSON 对象 / 结构解不开 ⇒ 返回
// `ErrInvalidMemoryTidy`,调用方应**原样报错并拒绝写**(盘上此刻运行期正整体
// 回落 `.env`/代码默认,盖掉它只会掩盖问题)。
// 4. **原子写**:temp + rename(文件 0600 / 目录 0755,与 SeedMemoryTidy、
// SaveWebSearch 一致):并发读者要么看到旧文件、要么看到新文件,不会读到半个
// JSON;且**先写好再换名**,任何一步失败都不会留下半份配置。
//
// 格式上的代价(如实登记):补丁经 `map[string]json.RawMessage` 落盘 ⇒ 键序变成
// 字典序、缩进统一为 2 空格。每个键的值一个不差,但人工排过版的文件在**真正发生
// 改动时**会被重排一次(口径 2 保证「没改动就不重排」)。
package runtimeconfig
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
// memoryTidySaveFileMode 是落盘文件的权限(只给跑服务的账号读)。
memoryTidySaveFileMode = 0o600
// memoryTidySaveDirMode 是 `$DATA_DIR/config/` 的建目录权限。
memoryTidySaveDirMode = 0o755
)
// PatchMemoryTidyTuning 把一组**已经过调用方校验**的键值并进
// `memory-tidy.json` 并原子写回,返回落盘后**重新读回**的调优结构。
//
// set 的键是「要写的键 → 原样 JSON 值」(调用方负责值的类型/取值域校验:那是 API
// 契约的一部分,属于网关侧;本函数只管**文件形状与落盘**)。clear 是要从文件里删掉
// 的键(= 回到下一档兜底)。两者都为空时**不落盘**。
//
// 返回 (tuning, wrote, err):
// - tuning 是**重新读盘**的结果(真值回显,不是「我以为写成了什么」);
// - wrote 报告这次是否真的写了字节(内容语义未变时为 false);
// - err 非 nil 时**盘上一个字节都没动**;错误链里带 ErrInvalidMemoryTidy 表示
// 「盘上那份读不走」⇒ 调用方应拒绝覆盖并如实报错。
func PatchMemoryTidyTuning(path string, set map[string]json.RawMessage, clear []string) (MemoryTidyTuning, bool, error) {
doc, err := readMemoryTidyDocForPatch(path)
if err != nil {
return MemoryTidyTuning{}, false, err
}
before := canonicalMemoryTidyJSON(doc)
merged := make(map[string]json.RawMessage, len(doc)+len(set))
for key, value := range doc {
merged[key] = value
}
for key, value := range set {
merged[key] = value
}
for _, key := range clear {
delete(merged, key)
}
raw, err := json.MarshalIndent(merged, "", " ")
if err != nil {
return MemoryTidyTuning{}, false, fmt.Errorf("runtimeconfig: marshal memory tidy: %w", err)
}
wrote := false
if !bytes.Equal(raw, before) {
if err := writeMemoryTidyTuningFile(path, raw); err != nil {
return MemoryTidyTuning{}, false, err
}
wrote = true
}
tuning, err := LoadMemoryTidyTuning(path)
if err != nil {
// 刚落盘就读不回来:这是本函数自己的错误(写出去的内容必须能被自己的读取
// 路径读走),如实报错,绝不回一份「成功」。
return MemoryTidyTuning{}, wrote, fmt.Errorf("runtimeconfig: reread %s after patch: %w", path, err)
}
return tuning, wrote, nil
}
// readMemoryTidyDocForPatch 读盘上的文档(键 → 原样 JSON 值)并确认**运行期读得走**。
//
// 缺失/全空白 ⇒ 空文档(= 还没配过,与 LoadMemoryTidyTuning 同义,不是错误);
// 存在但不是 JSON 对象、或整体结构解不开(类型写错)⇒ ErrInvalidMemoryTidy
// (口径 3:拒绝覆盖)。
func readMemoryTidyDocForPatch(path string) (map[string]json.RawMessage, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]json.RawMessage{}, nil
}
return nil, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
}
if strings.TrimSpace(string(raw)) == "" {
return map[string]json.RawMessage{}, nil
}
// 与读取路径**同一行代码**判「这份文件能不能被读走」:一处说坏了、另一处说没事
// 就是自相矛盾(写入方会据此把一份坏配置盖掉)。
if _, err := decodeMemoryTidy(raw, path); err != nil {
return nil, err
}
doc := map[string]json.RawMessage{}
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("%w: decode %s as object: %w", ErrInvalidMemoryTidy, path, err)
}
return doc, nil
}
// canonicalMemoryTidyJSON 返回文档的规范化字节(与落盘同一个 MarshalIndent 口径),
// 供「值有没有变」的语义比较用。序列化失败时返回 nil —— 那只可能让比较不相等
// (= 走正常落盘路径),不会误判成「没变」。
func canonicalMemoryTidyJSON(doc map[string]json.RawMessage) []byte {
raw, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return nil
}
return raw
}
// writeMemoryTidyTuningFile 原子写出文件(口径 4):建目录 → 写临时文件 → rename。
func writeMemoryTidyTuningFile(path string, raw []byte) error {
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, memoryTidySaveDirMode); err != nil {
return fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err)
}
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, memoryTidySaveFileMode); err != nil {
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("runtimeconfig: rename %s: %w", path, err)
}
return nil
}
@@ -0,0 +1,151 @@
package runtimeconfig
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"testing"
)
// 本文件锁定 `memory-tidy.json` **写路径**(PatchMemoryTidyTuning)的四条口径:
// ① 补丁语义:只改提交的键,盘上其它键(t1Judge 等)**原样保留**;
// ② 值没变 ⇒ 一个字节都不写(连 mtime 都不动);
// ③ 盘上那份**读不走**时拒绝覆盖(ErrInvalidMemoryTidy),且**盘上不动**;
// ④ 原子写:temp + rename,成功之后不留 `.tmp`,文件权限 0600(Windows 不校验)。
// patchTuning 是测试用的补丁构造:键 → 原样 JSON 值。
func patchTuning(t *testing.T, pairs map[string]any) map[string]json.RawMessage {
t.Helper()
out := make(map[string]json.RawMessage, len(pairs))
for k, v := range pairs {
raw, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal %s: %v", k, err)
}
out[k] = raw
}
return out
}
// TestPatchMemoryTidyTuning_MergesAndKeepsOtherKeys 补丁只动提交的键,其余键原样保留;
// 返回值是**重新读盘**的结果(真值),不是「我以为写成了什么」。
func TestPatchMemoryTidyTuning_MergesAndKeepsOtherKeys(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"t1Judge": false, "t1PerWindow": 120, "intervalSec": 3600}`)
tuning, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"intervalSec": 7200}), nil)
if err != nil {
t.Fatalf("patch: %v", err)
}
if !wrote {
t.Fatal("值变了却没写盘")
}
if tuning.IntervalSec == nil || *tuning.IntervalSec != 7200 {
t.Fatalf("返回值不是读回的真值:%+v", tuning.IntervalSec)
}
if tuning.T1Judge == nil || *tuning.T1Judge {
t.Fatalf("没提交的 t1Judge 被动了:%+v", tuning.T1Judge)
}
doc := map[string]json.RawMessage{}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("decode: %v", err)
}
if string(doc["intervalSec"]) != "7200" || string(doc["t1PerWindow"]) != "120" {
t.Fatalf("盘上内容 = %s", raw)
}
if string(doc["t1Judge"]) != "false" {
t.Fatalf("不受管的 t1Judge 被抹掉了:%s", raw)
}
// 清除哨兵:clear 里的键从盘上消失(= 回到下一档兜底)。
if _, _, err := PatchMemoryTidyTuning(path, nil, []string{"intervalSec"}); err != nil {
t.Fatalf("clear: %v", err)
}
doc = map[string]json.RawMessage{}
raw, _ = os.ReadFile(path)
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("decode: %v", err)
}
if _, ok := doc["intervalSec"]; ok {
t.Fatalf("clear 之后盘上仍有 intervalSec:%s", raw)
}
if _, ok := doc["t1Judge"]; !ok {
t.Fatalf("clear 抹掉了别的键:%s", raw)
}
}
// TestPatchMemoryTidyTuning_NoChangeWritesNothing 值没变 ⇒ 不写盘、不重排格式。
func TestPatchMemoryTidyTuning_NoChangeWritesNothing(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"intervalSec": 3600}`)
before, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
tuning, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"intervalSec": 3600}), nil)
if err != nil {
t.Fatalf("patch: %v", err)
}
if wrote {
t.Fatal("值没变却写了盘")
}
after, _ := os.ReadFile(path)
if string(after) != string(before) {
t.Fatalf("值没变却改了盘上内容:\nbefore=%s\nafter=%s", before, after)
}
if tuning.IntervalSec == nil || *tuning.IntervalSec != 3600 {
t.Fatalf("没写盘也要回读真值:%+v", tuning.IntervalSec)
}
}
// TestPatchMemoryTidyTuning_RefusesUnreadableFile 盘上那份运行期读不走时拒绝覆盖:
// 返回 ErrInvalidMemoryTidy(调用方据此回 409),且**盘上一个字节都不动**。
func TestPatchMemoryTidyTuning_RefusesUnreadableFile(t *testing.T) {
t.Parallel()
for _, body := range []string{`{"intervalSec": "3600"}`, `not json at all`, `[1, 2]`} {
body := body
t.Run(body, func(t *testing.T) {
path := writeMemoryTidyFile(t, body)
_, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"intervalSec": 3600}), nil)
if !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("err = %v, want ErrInvalidMemoryTidy", err)
}
if wrote {
t.Fatal("读不走的文件被写入了")
}
raw, _ := os.ReadFile(path)
if string(raw) != body {
t.Fatalf("坏文件被改动了:%s", raw)
}
})
}
}
// TestPatchMemoryTidyTuning_CreatesMissingFileAtomically 文件缺失 ⇒ 建目录并原子写出
// (temp + rename):成功之后不留 `.tmp`,权限 0600(Windows 不校验)。
func TestPatchMemoryTidyTuning_CreatesMissingFileAtomically(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config", memoryTidyFileName)
tuning, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"tempTtlSec": 604800}), nil)
if err != nil {
t.Fatalf("patch: %v", err)
}
if !wrote {
t.Fatal("缺失文件时应当写出")
}
if tuning.TempTtlSec == nil || *tuning.TempTtlSec != 604800 {
t.Fatalf("回读真值 = %+v", tuning.TempTtlSec)
}
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Fatalf("原子写留下了临时文件:err=%v", err)
}
if info, err := os.Stat(path); err != nil {
t.Fatalf("stat: %v", err)
} else if info.Mode().Perm() != memoryTidySaveFileMode && runtime.GOOS != "windows" {
t.Fatalf("文件权限 = %v, want %v", info.Mode().Perm(), memoryTidySaveFileMode)
}
}
@@ -22,7 +22,7 @@
// 残缺);调用方记一条 WARN 即可 —— 播种失败时运行期仍按 `.env` + 代码默认跑, // 残缺);调用方记一条 WARN 即可 —— 播种失败时运行期仍按 `.env` + 代码默认跑,
// 行为与播种前逐字相同。 // 行为与播种前逐字相同。
// //
// 日志**只打路径、原因与键名,不打值**:这十键今天都不是凭据,但「配置日志不打值」 // 日志**只打路径、原因与键名,不打值**:这十一键今天都不是凭据,但「配置日志不打值」
// 是本仓既有口径(materialize.go 的「绝不打 apiKey」),这里从一开始就照做,免得 // 是本仓既有口径(materialize.go 的「绝不打 apiKey」),这里从一开始就照做,免得
// 日后加一个敏感键时要回头改日志。 // 日后加一个敏感键时要回头改日志。
package runtimeconfig package runtimeconfig
@@ -157,7 +157,7 @@ func writeMemoryTidySeed(log *slog.Logger, path string, seed MemoryTidyTuning, r
return MemoryTidySeed{Written: true}, nil return MemoryTidySeed{Written: true}, nil
} }
// memoryTidySeedComplete 报告种子是否「十键齐全且值可用」。 // memoryTidySeedComplete 报告种子是否「十一键齐全且值可用」。
// //
// 为什么要「齐全」而不是「非 nil 就放行」:MemoryTidyTuning 的指针语义是「nil = 没配」, // 为什么要「齐全」而不是「非 nil 就放行」:MemoryTidyTuning 的指针语义是「nil = 没配」,
// 一份缺键的种子写下去虽然与「不写」在读取端等价,但它会让文件里出现 null —— 这份文件 // 一份缺键的种子写下去虽然与「不写」在读取端等价,但它会让文件里出现 null —— 这份文件
@@ -16,7 +16,7 @@ import (
// 本文件锁定**启动播种**(memorytidy_seed.go;主人 2026-10-07:「**`.env` 只做兜底 // 本文件锁定**启动播种**(memorytidy_seed.go;主人 2026-10-07:「**`.env` 只做兜底
// 用** …… 后端**自动创建**独立的面板配置文件」)的四条纪律: // 用** …… 后端**自动创建**独立的面板配置文件」)的四条纪律:
// //
// ① 文件不存在 ⇒ 按调用方给的「当前生效值」播种(**十键齐全**,不是只播老六键); // ① 文件不存在 ⇒ 按调用方给的「当前生效值」播种(**十一键齐全**,不是只播老六键);
// ② 文件已存在 ⇒ **一个字节都不动**(也不留 `.tmp`); // ② 文件已存在 ⇒ **一个字节都不动**(也不留 `.tmp`);
// ③ 文件损坏 ⇒ **不写盘**(WARN + 按 `.env` 跑),绝不「修好它」; // ③ 文件损坏 ⇒ **不写盘**(WARN + 按 `.env` 跑),绝不「修好它」;
// ④ 幂等:连跑两次只在第一次写盘。 // ④ 幂等:连跑两次只在第一次写盘。
@@ -59,7 +59,7 @@ func seedPath(t *testing.T) string {
return filepath.Join(t.TempDir(), "config", memoryTidyFileName) return filepath.Join(t.TempDir(), "config", memoryTidyFileName)
} }
// decodeSeed 把盘上的文件解成键集合,供「十键齐全」的断言(比结构体更贴近主人看到 // decodeSeed 把盘上的文件解成键集合,供「十一键齐全」的断言(比结构体更贴近主人看到
// 的那份文件)。 // 的那份文件)。
func decodeSeed(t *testing.T, path string) map[string]json.RawMessage { func decodeSeed(t *testing.T, path string) map[string]json.RawMessage {
t.Helper() t.Helper()
@@ -79,7 +79,7 @@ func assertNoTempLeft(t *testing.T, path string) {
} }
// TestSeedMemoryTidy_SeedsMissingFile 是本任务的主用例:文件不存在 ⇒ 播种,且**内容 // TestSeedMemoryTidy_SeedsMissingFile 是本任务的主用例:文件不存在 ⇒ 播种,且**内容
// 就是调用方给的快照**(十键齐全、含四项频率键),目录也一并建出来。 // 就是调用方给的快照**(十一键齐全、含四项频率键),目录也一并建出来。
func TestSeedMemoryTidy_SeedsMissingFile(t *testing.T) { func TestSeedMemoryTidy_SeedsMissingFile(t *testing.T) {
t.Parallel() t.Parallel()
path := seedPath(t) // 连 config/ 目录都不存在 path := seedPath(t) // 连 config/ 目录都不存在
@@ -94,7 +94,7 @@ func TestSeedMemoryTidy_SeedsMissingFile(t *testing.T) {
} }
assertNoTempLeft(t, path) assertNoTempLeft(t, path)
// 十键齐全(含新增的四项频率键)——「别只播老六键」的断言就在这里。 // 十一键齐全(含新增的四项频率键)——「别只播老六键」的断言就在这里。
keys := decodeSeed(t, path) keys := decodeSeed(t, path)
for _, key := range memoryTidySeedKeys { for _, key := range memoryTidySeedKeys {
if _, ok := keys[key]; !ok { if _, ok := keys[key]; !ok {
@@ -211,7 +211,7 @@ func TestSeedMemoryTidy_SeedsBlankFile(t *testing.T) {
t.Fatalf("结果 = %+v, want 按「还没配过」播种", res) t.Fatalf("结果 = %+v, want 按「还没配过」播种", res)
} }
if len(decodeSeed(t, path)) != len(memoryTidySeedKeys) { if len(decodeSeed(t, path)) != len(memoryTidySeedKeys) {
t.Fatalf("空白文件被播种后应当十键齐全:%s", readRaw(t, path)) t.Fatalf("空白文件被播种后应当十一键齐全:%s", readRaw(t, path))
} }
} }
@@ -248,6 +248,25 @@ func TestSeedMemoryTidy_RefusesIncompleteSeed(t *testing.T) {
} }
} }
// tidySeedLeakedValues 是「不许出现在日志里的配置值」的哨兵,**刻意带 `=` 前缀**
// (断言的形态是「字段值」而不是裸数字)。
//
// 为什么必须带 `=`:这条用例的日志里**合法地**带 `path=<临时目录>`,而
// `t.TempDir()` 的目录名带随机数十进制 —— 裸数字扫整行会**偶发假红**(实测命中过
// `…WithoutValues4091800659\001\config\…` 里的 `1800`,约个位数/10000 的窗口)。
// 带 `=` 的形态只可能来自 slog 的 `key=value` 字段;路径里的随机数前面是路径分隔
// 片段而不是 `=`,因此不受影响(值的**位置**语义也没变松)。
//
// 为什么不用另一个备选「只扫 `keys=` 之后的内容」:那会把断言缩成「keys 列表里
// 没有值」,于是任何以**别的**字段名打出来的值(`interval_sec=4242`、将来新加的
// 字段)都会溜过去 —— 那等于把这条防护改成空壳,而不是修掉它的假红。
//
// 259200 是 tempTtlSec(completeSeedTuning 里显式给的值),与其余三个同为「一眼可
// 辨、且与代码默认值不同」的哨兵。
func tidySeedLeakedValues() []string {
return []string{"=4242", "=10800", "=1800", "=259200"}
}
// TestSeedMemoryTidy_LogsPathAndReasonWithoutValues 钉住「可观测」:写盘与跳过都有 // TestSeedMemoryTidy_LogsPathAndReasonWithoutValues 钉住「可观测」:写盘与跳过都有
// 日志(带路径与原因、带 source),且**不打任何配置值**(本仓配置日志的既有口径)。 // 日志(带路径与原因、带 source),且**不打任何配置值**(本仓配置日志的既有口径)。
func TestSeedMemoryTidy_LogsPathAndReasonWithoutValues(t *testing.T) { func TestSeedMemoryTidy_LogsPathAndReasonWithoutValues(t *testing.T) {
@@ -277,9 +296,9 @@ func TestSeedMemoryTidy_LogsPathAndReasonWithoutValues(t *testing.T) {
if !strings.Contains(out, "已按当时生效值播种落盘") { if !strings.Contains(out, "已按当时生效值播种落盘") {
t.Fatalf("播种日志没说明做了什么:%s", out) t.Fatalf("播种日志没说明做了什么:%s", out)
} }
for _, value := range []string{"4242", "10800", "1800"} { for _, value := range tidySeedLeakedValues() {
if strings.Contains(out, value) { if strings.Contains(out, value) {
t.Fatalf("日志里出现了配置值 %s(配置日志只许打键名):%s", value, out) t.Fatalf("日志里出现了配置值 %s(配置日志只许打键名):%s", strings.TrimPrefix(value, "="), out)
} }
} }
@@ -299,9 +318,9 @@ func TestSeedMemoryTidy_LogsPathAndReasonWithoutValues(t *testing.T) {
if !strings.Contains(skipped, memoryTidySeedReasonExists) { if !strings.Contains(skipped, memoryTidySeedReasonExists) {
t.Fatalf("跳过播种的日志没带 reason 字段:%s", skipped) t.Fatalf("跳过播种的日志没带 reason 字段:%s", skipped)
} }
for _, value := range []string{"4242", "10800", "1800"} { for _, value := range tidySeedLeakedValues() {
if strings.Contains(skipped, value) { if strings.Contains(skipped, value) {
t.Fatalf("跳过日志里出现了配置值 %s:%s", value, skipped) t.Fatalf("跳过日志里出现了配置值 %s:%s", strings.TrimPrefix(value, "="), skipped)
} }
} }
Loaded 100 of 399 files, more files were not shown because too many files have changed in this diff. Show more