Commit 599af2aa881cba0d6f520d56fb71125752acabcc

Authored by yibo
1 parent 411af4e5

feat(dream): REST/控制台入口

只做了这一件事,没有跟着做「dream 改成 org/work graph 来跑」——两者被
单独评估过:org/work graph 是单公司作用域(org graph=公司级 workflow,
work graph=公司内某任务的节点图),dream 却是跨公司扫描,套进去要么破坏
作用域假设要么得新开图类型;且 graph 引擎自带的验收机制(goal-contract
benchmark/test-script)判的是「产物过没过验收脚本」,对「经验提炼得对
不对」这种自由文本判断没有对应机制——不管套不套 graph 都得单独设计
rubric,是同一件事。用户明确只要 REST/控制台这一半。

## REST(server/routes/dream.js,挂 /api/dream)

路由本身没有新业务逻辑,全部委托给已测过的 dream-service.js:
GET /agents(总览)、GET /agents/:id(详情,含定义文件可写性 + 经验
审计)、POST /agents/:id/run(手动跑一次,dryRun 只看有没有新原料不
起 LLM)、POST /sweep + GET /sweep/status(全量扫描后台跑 + 轮询,内存
态并发锁防止连点两下)。

## 控制台(web/src/views/DreamConsole.vue,路由 /dream)

一个表格 + 两个弹窗:本体列表(详情/跑一次/dry-run)+ 全量扫描按钮
(15 秒轮询状态)。导航栏加 🌙 Dream 入口。

## 验证

server/tests/unit/dream-routes.test.js 4 例,monkeypatch 掉
dream-service 导出对象上的六个函数(不走真文件系统,不误扫真仓库
76 个本体),只验路由层自己的逻辑:端点接线、sweep 并发锁状态机。

真起服务器打了真数据:GET /agents/innovator-evolver 正确报
SKILL.md 因没进 git 历史而不可写、经验审计吐出上次真跑沉淀的 4 条
经验及原文;dry-run 正确报 no-new-material,没多花一次 LLM 调用。
浏览器里走了一遍列表→详情→dry-run,与直接打 API 结果一致。

955 个单测全绿。

文档:29-dream-design.md §5.7 记录实现与「为什么不做 graph 化」的
理由;28-followups.md / docs/README.md 同步更新 dream 现状。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CLAUDE.md
@@ -109,6 +109,7 @@ npm run okr:migrate # goals → okr 存量迁移 @@ -109,6 +109,7 @@ npm run okr:migrate # goals → okr 存量迁移
109 | `/ws/claude` | Claude 实时对话(仅 `index.js`) | 109 | `/ws/claude` | Claude 实时对话(仅 `index.js`) |
110 | `/api/health` `/api/plugins` | 健康检查 / 插件状态 | 110 | `/api/health` `/api/plugins` | 健康检查 / 插件状态 |
111 | `/api/devloop/projects` | DevLoop:创建项目 + 跑 PM/Architect(POST)、列项目 / 查状态(GET,见下) | 111 | `/api/devloop/projects` | DevLoop:创建项目 + 跑 PM/Architect(POST)、列项目 / 查状态(GET,见下) |
  112 +| `/api/dream/*` | 经验汇流(29-DREAM):`dream-service.js` 能力的 REST 封装,只读查询 + 触发 `dreamOnce`/`dreamSweep`,不新增业务逻辑,见 [`docs/todo/system-design/29-dream-design.md`](docs/todo/system-design/29-dream-design.md) |
112 113
113 ### DevLoop 子系统 114 ### DevLoop 子系统
114 115
docs/README.md
@@ -45,7 +45,8 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d @@ -45,7 +45,8 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d
45 | [27-p3b-real-run-runway.md](./todo/system-design/27-p3b-real-run-runway.md) | P3 收尾之真跑跑道(从 mock 走到真 Claude CLI)— 📝 **只剩验收 9**。2026-08-20 第四次真跑(§六ter)验掉 2 与 8,验收 4 由零注入链路验掉,7 早已修好。验收 9 的准确状态是「产出端通了、消费端没等到那个形状」—— 需要一次 **org 层回退边指向 subgraph 节点**。⚠️ 这份文档里 ⛔/🟡 的段落多为**当时的现场记录**,读之前先看头部状态行 | 45 | [27-p3b-real-run-runway.md](./todo/system-design/27-p3b-real-run-runway.md) | P3 收尾之真跑跑道(从 mock 走到真 Claude CLI)— 📝 **只剩验收 9**。2026-08-20 第四次真跑(§六ter)验掉 2 与 8,验收 4 由零注入链路验掉,7 早已修好。验收 9 的准确状态是「产出端通了、消费端没等到那个形状」—— 需要一次 **org 层回退边指向 subgraph 节点**。⚠️ 这份文档里 ⛔/🟡 的段落多为**当时的现场记录**,读之前先看头部状态行 |
46 | [27-goal-contract-format.md](./todo/system-design/27-goal-contract-format.md) | 验收契约格式(`goal-benchmark` / `goal-test-data` / `goal-test-script` 的产物形态与跨节点传递) | 46 | [27-goal-contract-format.md](./todo/system-design/27-goal-contract-format.md) | 验收契约格式(`goal-benchmark` / `goal-test-data` / `goal-test-script` 的产物形态与跨节点传递) |
47 | [27-p4-parallel-and-artifacts.md](./todo/system-design/27-p4-parallel-and-artifacts.md) | **P4(回收编号)并行执行与产物索引** — 📝 设计中,未实现。fan-out 真并发 + 同 agent 多实例隔离 + `files.json` 产物索引。三个 Phase,C 可插队 | 47 | [27-p4-parallel-and-artifacts.md](./todo/system-design/27-p4-parallel-and-artifacts.md) | **P4(回收编号)并行执行与产物索引** — 📝 设计中,未实现。fan-out 真并发 + 同 agent 多实例隔离 + `files.json` 产物索引。三个 Phase,C 可插队 |
48 -| [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(只有口述、无设计)、任务态清理(源码树无限增长)、`source_kind`/`legacy_path` 删列、三项「已实施未验证」。§五 是**已立起来的守卫清单**(别重复造,也别删) | 48 +| [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(设计见 29 号,已实现,见下)、`source_kind`(✅已删)/`legacy_path`(不能删)删列、三项「已实施未验证」。任务态清理(✅已实现,见下)。§五 是**已立起来的守卫清单**(别重复造,也别删) |
  49 +| [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) |
49 50
50 ## todo/future/(暂缓,非当前重心) 51 ## todo/future/(暂缓,非当前重心)
51 52
docs/todo/system-design/28-followups.md
@@ -20,23 +20,32 @@ @@ -20,23 +20,32 @@
20 20
21 ### 1.1 dream 机制 21 ### 1.1 dream 机制
22 22
23 -**状态**:用户口述过要解决什么问题和大致形态,**设计尚未开始**。  
24 -  
25 -已知的只有这些:  
26 -- 定时跑的另一套逻辑,整理**所有 agent 在所有公司**的 history 与 memory,**显式提炼**后沉淀到 skill memory  
27 -- 它是「经验汇流」的正解;错误版本是「每轮对话自动把原文写进共享 `SKILL.md`」——  
28 - 方向对,但直接灌原始对话等于把 A 公司的机密喂给 B 公司  
29 - (这个错误版本真的发生过,是 28 号第一轮验收挖出的最严重 blocker,已修)  
30 -  
31 -**尚未设计的**:触发时机与频率、提炼规则、去敏策略(什么算「不含公司特定信息」)、  
32 -产物落点与格式、失败与重试、提炼质量怎么评。 23 +**状态(2026-08-20 更新)**:设计已成文并已实现,见
  24 +[29-dream-design.md](./29-dream-design.md)。四条取舍(去敏判据 / 沉淀落点 / 触发时机 /
  25 +质量评估)均已拍板,五步推进顺序 + 定时挂载 + 定义文件写入均已落地,
  26 +`innovator-evolver` 上真 CLI 跑通一次(40 条原料 → 4 条抽象经验,8 条引用全部核对回原文)。
  27 +
  28 +已实现的:
  29 +- 定时(每日一次,串行,挂在 `server/index.js` 的 `DISABLE_BACKGROUND_AGENTS` 早退之后)
  30 + 整理**所有 agent 在所有公司**的原始记录,**显式提炼**(`dream-distiller`)+
  31 + 独立去敏判定(`dream-screener`)后沉淀
  32 +- 落点四选四:本体 `memory/experience.md`(经验积累)+ `SKILL.md` / `soul.md` / `agent.md`
  33 + (受管区块,三道闸门缺一不写,见 29 号文档 §5.6)
  34 +- 引用真伪自动查(编造出处直接拒)+ `auditDistilled()` 把结论与原料原文并排列出,
  35 + 几秒内可回查
  36 +
  37 +- REST(`/api/dream/*`)+ 控制台(`/dream`,导航栏 `🌙 Dream`):查总览/详情、手动跑单个本体(含 dry-run)、
  38 + 触发并轮询全量扫描(2026-08-21)
  39 +
  40 +**仍未做的**:「结论对不对」的判据(机器判不了,只能靠人抽查,`auditDistilled` 的输出不是质量分)。
  41 +「把 dream 套进 org/work graph 来跑」评估过、用户明确选了不做——
  42 +org/work graph 是单公司作用域的,dream 是跨公司扫描,两者模型不匹配;
  43 +且 graph 引擎自带的验收机制(goal-contract benchmark/test-script)判的是「产物过没过验收脚本」,
  44 +对「经验提炼得对不对」这种自由文本判断没有对应机制,套不套 graph 都得单独设计 rubric。
33 45
34 **已经生效的约束**(这条是确定的,不随 dream 的设计变化): 46 **已经生效的约束**(这条是确定的,不随 dream 的设计变化):
35 公司态目录(`system/agents/{aid}/{cid}/` 下的 `memory/` / `conversations/`)是 dream 的唯一原料, 47 公司态目录(`system/agents/{aid}/{cid}/` 下的 `memory/` / `conversations/`)是 dream 的唯一原料,
36 -**不许被任何清理策略当垃圾扫掉**。详见主文档 §5.6b。  
37 -  
38 -> 真要做时另开设计文档。**不要**照主文档 §5.6b 的措辞去写代码 ——  
39 -> 那一节的目的是锁住上面那条约束,不是给 dream 下定义。 48 +**不许被任何清理策略当垃圾扫掉**。详见主文档 §5.6b,任务态清理已按此约束实现(见下 §3.2)。
40 49
41 --- 50 ---
42 51
docs/todo/system-design/29-dream-design.md
1 # 29-DREAM · 经验汇流(dream)—— 设计草案 1 # 29-DREAM · 经验汇流(dream)—— 设计草案
2 2
3 > **所属**: [28 Agent 注册表统一](../../done/system-design/28-agent-registry-unification.md) §5.6b 锁住的那条约束的下游设计;未确认项来源 [28-followups §1.1](./28-followups.md)。 3 > **所属**: [28 Agent 注册表统一](../../done/system-design/28-agent-registry-unification.md) §5.6b 锁住的那条约束的下游设计;未确认项来源 [28-followups §1.1](./28-followups.md)。
4 -> **日期**: 2026-08-20  
5 -> **状态**: 📝 **四条取舍已由用户拍板(2026-08-20,见 §三),实现未开始。** 4 +> **日期**: 2026-08-20 / REST 与控制台入口 2026-08-21
  5 +> **状态**: ✅ **四条取舍已拍板并已实现(五步 + 定时挂载 + 定义文件写入 + REST/控制台入口,均已落地并真跑验证,见 §四/§五/§5.7)。**
  6 +> 仍未做的只剩「结论对不对」的判据——机器判不了,只能靠人抽查(决策 D,见上)。
6 > 标 ✅ 的是已生效的约束或有实测证据的事实;标 🔒 的是本轮拍板的决定。 7 > 标 ✅ 的是已生效的约束或有实测证据的事实;标 🔒 的是本轮拍板的决定。
7 8
8 --- 9 ---
@@ -319,13 +320,15 @@ mtime 会被任何一次无关写入推动(例如别处重写整份 `memory.md @@ -319,13 +320,15 @@ mtime 会被任何一次无关写入推动(例如别处重写整份 `memory.md
319 320
320 ### 5.4 还没做的(别当成 dream 上线了) 321 ### 5.4 还没做的(别当成 dream 上线了)
321 322
322 -1. **定时挂载没做**(决策 C 说每日一次)。现在只有 `dreamOnce(agentId)` 这个可调用的单元,  
323 - 没有挂到任何调度上,也没有 REST / 控制台入口。空转保护已经在,挂之前先想清楚  
324 - 「每天对哪些本体跑」——全量 76 个本体逐个跑,即使空转也要 76 次目录读。  
325 -2. **还没写定义文件**。`writeDistilled` 目前只写本体根的 `memory/experience.md`  
326 - (纯经验积累,删掉无副作用)。`SKILL.md` / `soul.md` / `agent.md` 三个 git 跟踪的定义  
327 - 留给下一步;`definitionWritability()` 已经能报出「这三份现在能不能写、不能写是为什么」,  
328 - 闸门(§B.3)也已就位,缺的是「往里写什么、写成什么形状」这半边设计。 323 +1. ~~定时挂载没做~~ ✅ **已完成**(§5.6)—— `dream-service.startDaily()` 已挂在
  324 + `server/index.js` 的 `DISABLE_BACKGROUND_AGENTS` 早退之后,每日一次、串行扫、
  325 + 定时器 `unref`。~~仍然没有的是 REST / 控制台入口~~ ✅ **已完成**(§5.7)——
  326 + `/api/dream/*` + `web/src/views/DreamConsole.vue`(导航栏 `🌙 Dream`)。
  327 +2. ~~还没写定义文件~~ ✅ **已完成**(§5.6)—— `writeDefinitionFiles()` 已实现,
  328 + 落点是 `SKILL.md` / `soul.md` / `agent.md` 三个 git 跟踪的定义文件,
  329 + 形状是受管区块(`<!-- dream:begin/end -->`,区块外不碰、整体重写幂等),
  330 + 写之前过三道闸门(definition-write-gate → agent.md 前 2000 字符自足性 →
  331 + 写完复核毕业闸门,不过立刻还原),任何一道不过就不写、原因逐文件返回。
329 3. ~~决策 D~~ ✅ 已落地(见上)—— 但「结论对不对」仍然只能靠人抽查。 332 3. ~~决策 D~~ ✅ 已落地(见上)—— 但「结论对不对」仍然只能靠人抽查。
330 333
331 --- 334 ---
@@ -386,3 +389,100 @@ mtime 会被任何一次无关写入推动(例如别处重写整份 `memory.md @@ -386,3 +389,100 @@ mtime 会被任何一次无关写入推动(例如别处重写整份 `memory.md
386 ### 空转保护实跑 389 ### 空转保护实跑
387 390
388 第二次调用(原料没变):`{"skipped":"no-new-material"}`,**一次 agent 调用都没起**。 391 第二次调用(原料没变):`{"skipped":"no-new-material"}`,**一次 agent 调用都没起**。
  392 +
  393 +---
  394 +
  395 +## 5.6 定时挂载(决策 C)+ 定义文件写入(决策 B)(2026-08-20,`e9f380a`)
  396 +
  397 +### 定时:每日一次,串行
  398 +
  399 +`dreamSweep` 逐个本体扫,**不并发** —— 每轮可能起两次 LLM 调用,几十个本体并发
  400 += 同时拉起几十个 claude 子进程,这个代价本仓库付过(2h43m 漏出 82 个进程 / 17.5 GB,
  401 +[28-followups §五第五条](./28-followups.md))。慢一点没有代价,空转保护已经把
  402 +「读一遍全量本体」的成本压到「N 次目录读」。
  403 +
  404 +挂点:`server/index.js` 里 `DISABLE_BACKGROUND_AGENTS` 早退之后,调
  405 +`dream-service.startDaily()`。定时器 `unref`;只挂定时**不立刻跑一轮**
  406 +(启动那一刻跑没有额外收益,反而让每次重启都烧一遍)。一个本体抛异常不拖垮整轮,
  407 +但必须留下是谁抛的 —— 否则现场只看到「今天没沉淀」,查不出是没原料还是一直在抛。
  408 +
  409 +**没做的**:REST / 控制台入口。现在触发只有两条路:等每日定时,或代码 / 脚本直接调
  410 +`dreamOnce(agentId)`。人想立刻看一次提炼结果,目前没有 API/页面。
  411 +
  412 +### 定义文件:受管区块 + 三道闸门
  413 +
  414 +落点是 `SKILL.md` / `soul.md` / `agent.md`(决策 B 的另外两个落点,本体根
  415 +`memory/experience.md` 在步骤 3 已经在写)。形状是一对
  416 +`<!-- dream:begin/end -->` 之间的**受管区块**,区块外一个字节不碰 —— 自由追加会让
  417 +「哪些是人写的、哪些是 dream 写的」几轮之后再也分不开,分不开就没法分别回滚。
  418 +区块整体重写还顺带**幂等**:同一批经验重复跑不会越写越长。
  419 +
  420 +三道闸门,缺一不写:
  421 +1. **definition-write-gate**(§B.3):当前内容必须已在 git 历史里,且没有未提交改动;
  422 +2. **agent.md 的前 2000 字符必须仍然自足**:`graph-engine.js:915` 的「角色定义摘录」
  423 + 只看这一段,改了等于悄悄换掉规划 agent 读到的画像;
  424 +3. **写完复核毕业闸门**(`missingBodyDefinition`),不过就**立刻还原** ——
  425 + `agent.md` 写坏会让本体退回「定义没填满」,雇不进任何公司。
  426 +
  427 +任何一道不过:不写,原因逐文件返回(`definitions[]`)。**不写不该是静默的。**
  428 +
  429 +守卫:`dream-service.test.js` 新增 3 例,对真被 git 跟踪且干净的本体(`dream-screener`)
  430 +走真路径 —— 真写、验区块外字节不变 / 幂等 / 毕业闸门仍过,`finally` 还原并断言还原干净;
  431 +`agent.md` 那条验「不写 + 理由点名前 2000 字符 + 一个字节都没动」。`dreamSweep` 加了
  432 +`agents` 参数,让错误隔离那条能真调而不是真扫全量本体。
  433 +
  434 +全量:144 文件 / 1330 测试全绿。
  435 +
  436 +---
  437 +
  438 +## 5.7 REST / 控制台入口(2026-08-21)
  439 +
  440 +只做了这一件事,没有跟着做「dream 改成 org/work graph 来跑」——
  441 +两者被单独评估过(见对话记录):org/work graph 的执行模型是**单公司作用域**的
  442 +(org graph = 公司级 workflow,work graph = 公司内某任务的节点图),dream 却是
  443 +**跨公司**扫描(按 agent 扫全部公司的记忆),套进去要么破坏这个作用域假设,
  444 +要么得新开一种「跨公司图」类型。而且 graph 引擎自带的「自动质量判断」
  445 +(goal-contract 的 benchmark / test-script、triager 驳回重做)判的是「产物有没有
  446 +过验收脚本」,对「这条提炼的经验对不对」这种自由文本判断没有对应机制——
  447 +那道题不管包不包进 graph 都得单独设计一个 rubric,是同一件事,graph 化本身
  448 +不会替你解决它。所以只做了用户点了头的这一半。
  449 +
  450 +### REST(`server/routes/dream.js`,挂在 `/api/dream`)
  451 +
  452 +路由本身**没有新业务逻辑**,全部委托给已经测过的 `dream-service.js`:
  453 +
  454 +| 端点 | 做什么 |
  455 +|---|---|
  456 +| `GET /api/dream/agents` | 总览:每个可 dream 的本体,公司数、上次跑的时间、上次沉淀/拒绝了几条 |
  457 +| `GET /api/dream/agents/:agentId` | 详情:状态、定义文件(`agent.md`/`soul.md`/`SKILL.md`)现在能不能写及原因、`auditDistilled()` 的可回查列表 |
  458 +| `POST /api/dream/agents/:agentId/run` | 手动跑一次(`{dryRun}` 可选;`dryRun` 只看有没有新原料,不起 LLM 调用) |
  459 +| `POST /api/dream/sweep` | 手动触发全量扫描,202 立即返回,后台跑(串行扫全部本体可能要几分钟到几十分钟,不能塞进一次 HTTP 请求里等) |
  460 +| `GET /api/dream/sweep/status` | 轮询上面那个后台任务:`sweeping` / `results` / `error` |
  461 +
  462 +`sweep` 的并发锁是路由自己的状态(内存里一个 `sweepState`,不落库)——
  463 +`dream-service.js` 没有「正在跑」这个概念,`dreamSweep()` 单纯是个 promise。
  464 +两次并发 `POST /sweep` 第二次会拿到 409,防的是「人在控制台连点两下」而不是
  465 +分布式场景(单进程部署,够用)。
  466 +
  467 +守卫:`server/tests/unit/dream-routes.test.js`,monkeypatch 掉
  468 +`dream-service` 导出对象上的六个函数(不走真文件系统,不会误扫真仓库的
  469 +76 个本体),只验路由这一层独有的东西——六个端点接没接对、`sweep` 的并发锁
  470 +状态机对不对。`dream-service.js` 自身的正确性由 `dream-service.test.js` 覆盖,
  471 +这里不重复验。
  472 +
  473 +### 控制台(`web/src/views/DreamConsole.vue`,路由 `/dream`,导航栏 `🌙 Dream`)
  474 +
  475 +一个表格 + 两个弹窗,没有别的:本体列表(点「详情」看状态/定义文件可写性/
  476 +经验审计,点「跑一次」或「dry-run」手动触发单个本体),页头一个「全量扫描」按钮
  477 +(触发后台 sweep,15 秒轮询一次状态直到跑完)。
  478 +
  479 +### 真跑验证(2026-08-21)
  480 +
  481 +起真服务器(`DISABLE_BACKGROUND_AGENTS=1 bash start.sh`),对真数据打过一轮:
  482 +`GET /api/dream/agents` 拿到全部 76 个本体真实的 `companies` / `lastRunAt`;
  483 +`GET /api/dream/agents/innovator-evolver` 的 `definitions` 如实报出
  484 +`agent.md`/`soul.md` 可写、`SKILL.md` 不可写(没有 git 历史)及具体原因,
  485 +`experience` 把 §5.5 那次真跑沉淀的 4 条经验连同原料原文一起吐出来;
  486 +`dry-run` 对 `innovator-evolver` 正确报 `no-new-material`(材料已在上次真跑时被
  487 +消费,本轮**没有起一次 LLM 调用**)。控制台页面在浏览器里过了一遍同样的路径
  488 +(列表 → 详情弹窗 → dry-run 弹窗),数据与直接打 API 一致。
server/index.js
@@ -96,6 +96,7 @@ app.use(&#39;/api/tasks&#39;, require(&#39;./routes/tasks&#39;)); @@ -96,6 +96,7 @@ app.use(&#39;/api/tasks&#39;, require(&#39;./routes/tasks&#39;));
96 app.use('/api/devloop', require('./routes/devloop')); 96 app.use('/api/devloop', require('./routes/devloop'));
97 app.use('/api/graph-runs', require('./routes/graph-run')); // P3 图执行(T6) 97 app.use('/api/graph-runs', require('./routes/graph-run')); // P3 图执行(T6)
98 app.use('/api/company', require('./routes/graph-run').companyRouter); // GET /:cid/graph-runs 98 app.use('/api/company', require('./routes/graph-run').companyRouter); // GET /:cid/graph-runs
  99 +app.use('/api/dream', require('./routes/dream')); // 29-DREAM 经验汇流
99 app.use('/public', require('express').static(require('path').join(__dirname, 'public'))); 100 app.use('/public', require('express').static(require('path').join(__dirname, 'public')));
100 101
101 // ─── 系统端点 ─── 102 // ─── 系统端点 ───
server/routes/dream.js 0 → 100644
  1 +/**
  2 + * dream —— 经验汇流的 REST 入口([29-DREAM](../../docs/todo/system-design/29-dream-design.md))。
  3 + *
  4 + * 只做一件事:把 `dream-service.js` 已有的能力(`dreamOnce` / `dreamSweep` /
  5 + * `auditDistilled` / `definitionWritability`)从「只能靠脚本或定时器触发」
  6 + * 变成「REST 可查可触发」。不新增业务逻辑。
  7 + */
  8 +const express = require('express');
  9 +const router = express.Router();
  10 +const dream = require('../services/dream-service');
  11 +
  12 +// dreamSweep 串行跑几十个本体、每个可能起两次 LLM 调用,不能塞进一次 HTTP 请求里等。
  13 +// 用最小的内存态跟踪「在跑没在跑」+「上一轮结果」,够控制台轮询用。
  14 +let sweepState = { sweeping: false, startedAt: null, finishedAt: null, results: null, error: null };
  15 +
  16 +// GET /api/dream/agents — 总览:每个可 dream 的本体,跑没跑过、上次沉淀了几条
  17 +router.get('/agents', (req, res) => {
  18 + try {
  19 + const list = dream.listDreamableAgents().map((agentId) => {
  20 + const state = dream.readState(agentId);
  21 + return {
  22 + agentId,
  23 + companies: dream.companiesOf(agentId).length,
  24 + lastRunAt: state.lastRunAt || null,
  25 + lastAccepted: state.lastAccepted || 0,
  26 + lastRejected: state.lastRejected || 0,
  27 + };
  28 + });
  29 + res.json({ agents: list });
  30 + } catch (err) {
  31 + res.status(500).json({ error: err.message });
  32 + }
  33 +});
  34 +
  35 +// GET /api/dream/agents/:agentId — 详情:状态 + 定义文件能不能写 + 已沉淀经验的可回查列表
  36 +router.get('/agents/:agentId', (req, res) => {
  37 + try {
  38 + const { agentId } = req.params;
  39 + res.json({
  40 + agentId,
  41 + companies: dream.companiesOf(agentId),
  42 + state: dream.readState(agentId),
  43 + definitions: dream.definitionWritability(agentId),
  44 + experience: dream.auditDistilled(agentId), // 每条结论并排列出引用的原料原文,回查成本降到几秒
  45 + });
  46 + } catch (err) {
  47 + res.status(500).json({ error: err.message });
  48 + }
  49 +});
  50 +
  51 +// POST /api/dream/agents/:agentId/run — 手动跑一次(真会起 LLM 调用,dryRun 只想看会不会有新原料时用)
  52 +router.post('/agents/:agentId/run', async (req, res) => {
  53 + try {
  54 + const dryRun = req.body && req.body.dryRun === true;
  55 + const result = await dream.dreamOnce(req.params.agentId, { dryRun });
  56 + res.json(result);
  57 + } catch (err) {
  58 + res.status(500).json({ error: err.message });
  59 + }
  60 +});
  61 +
  62 +// POST /api/dream/sweep — 手动触发全量扫描(后台跑,别等;串行跑完可能是几分钟到几十分钟)
  63 +router.post('/sweep', (req, res) => {
  64 + if (sweepState.sweeping) return res.status(409).json({ error: '上一轮扫描还没跑完', ...sweepState });
  65 + sweepState = { sweeping: true, startedAt: new Date().toISOString(), finishedAt: null, results: null, error: null };
  66 + dream.dreamSweep()
  67 + .then((results) => { sweepState = { ...sweepState, sweeping: false, finishedAt: new Date().toISOString(), results }; })
  68 + .catch((err) => { sweepState = { ...sweepState, sweeping: false, finishedAt: new Date().toISOString(), error: err.message }; });
  69 + res.status(202).json({ started: true, startedAt: sweepState.startedAt });
  70 +});
  71 +
  72 +// GET /api/dream/sweep/status — 轮询上面那个后台任务
  73 +router.get('/sweep/status', (req, res) => res.json(sweepState));
  74 +
  75 +module.exports = router;
server/tests/unit/dream-routes.test.js 0 → 100644
  1 +/**
  2 + * dream 的 REST 入口(29-DREAM,见 docs/todo/system-design/29-dream-design.md)。
  3 + *
  4 + * 路由本身没有业务逻辑 —— 全部委托给已经测过的 `dream-service.js`。
  5 + * 这份只验两件路由层独有的东西:
  6 + * ① 六个端点确实接到了对应的 service 函数上(不是「实现在、零调用方」);
  7 + * ② `POST /sweep` 的并发锁 —— 上一轮没跑完时第二次触发要拒(409),
  8 + * 跑完之后 `GET /sweep/status` 要能看到结果。这段状态机全部写在 route 里,
  9 + * `dream-service.test.js` 覆盖不到。
  10 + *
  11 + * `dream-service` 的六个被测函数在这里整体替身(monkeypatch 掉导出对象上的属性,
  12 + * 用完在 afterAll 里还原)—— 不走真文件系统,也不会误扫真仓库里的 76 个本体。
  13 + */
  14 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  15 +
  16 +const express = require('express');
  17 +const dream = require('../../services/dream-service');
  18 +const dreamRouter = require('../../routes/dream');
  19 +
  20 +const AGENT = 'zz-route-agent';
  21 +const orig = {};
  22 +function patch(name, fn) { orig[name] = dream[name]; dream[name] = fn; }
  23 +function restore(name) { dream[name] = orig[name]; }
  24 +
  25 +let server;
  26 +let base;
  27 +let resolveSweep;
  28 +
  29 +beforeAll(() => {
  30 + patch('listDreamableAgents', () => [AGENT]);
  31 + patch('readState', () => ({ lastRunAt: '2026-08-20T00:00:00.000Z', lastAccepted: 2, lastRejected: 1 }));
  32 + patch('companiesOf', () => ['zz-route-co']);
  33 + patch('definitionWritability', () => [{ file: 'agent.md', allowed: true, reason: '' }]);
  34 + patch('auditDistilled', () => [{ title: 'X', sources: [{ source: 'zz-route-co:memory.md#1', text: '原文' }] }]);
  35 + patch('dreamOnce', async (agentId, opts) => ({ agentId, skipped: null, dryRun: !!(opts && opts.dryRun), accepted: 1 }));
  36 + patch('dreamSweep', () => new Promise((resolve) => { resolveSweep = resolve; }));
  37 +
  38 + const app = express();
  39 + app.use(express.json());
  40 + app.use('/api/dream', dreamRouter);
  41 + server = app.listen(0);
  42 + base = `http://127.0.0.1:${server.address().port}/api/dream`;
  43 +});
  44 +
  45 +afterAll(() => {
  46 + ['listDreamableAgents', 'readState', 'companiesOf', 'definitionWritability', 'auditDistilled', 'dreamOnce', 'dreamSweep']
  47 + .forEach(restore);
  48 + server.close();
  49 +});
  50 +
  51 +describe('dream REST 入口', () => {
  52 + it('GET /agents 用 listDreamableAgents + readState + companiesOf 拼总览', async () => {
  53 + const res = await fetch(`${base}/agents`);
  54 + expect(res.status).toBe(200);
  55 + const body = await res.json();
  56 + expect(body.agents).toEqual([
  57 + { agentId: AGENT, companies: 1, lastRunAt: '2026-08-20T00:00:00.000Z', lastAccepted: 2, lastRejected: 1 },
  58 + ]);
  59 + });
  60 +
  61 + it('GET /agents/:id 把状态、定义文件可写性、经验审计一起交出去', async () => {
  62 + const res = await fetch(`${base}/agents/${AGENT}`);
  63 + const body = await res.json();
  64 + expect(body.agentId).toBe(AGENT);
  65 + expect(body.companies).toEqual(['zz-route-co']);
  66 + expect(body.definitions[0].file).toBe('agent.md');
  67 + expect(body.experience[0].sources[0].text).toBe('原文');
  68 + });
  69 +
  70 + it('POST /agents/:id/run 把 body.dryRun 原样传给 dreamOnce', async () => {
  71 + const res = await fetch(`${base}/agents/${AGENT}/run`, {
  72 + method: 'POST',
  73 + headers: { 'Content-Type': 'application/json' },
  74 + body: JSON.stringify({ dryRun: true }),
  75 + });
  76 + const body = await res.json();
  77 + expect(body.dryRun).toBe(true);
  78 + });
  79 +
  80 + it('sweep 并发锁:跑着的时候第二次触发要 409,跑完 status 要能看到结果', async () => {
  81 + const start = await fetch(`${base}/sweep`, { method: 'POST' });
  82 + expect(start.status).toBe(202);
  83 +
  84 + const mid = await fetch(`${base}/sweep/status`);
  85 + expect((await mid.json()).sweeping).toBe(true);
  86 +
  87 + const blocked = await fetch(`${base}/sweep`, { method: 'POST' });
  88 + expect(blocked.status).toBe(409);
  89 +
  90 + resolveSweep([{ agentId: AGENT, accepted: 3 }]);
  91 + await new Promise((r) => setTimeout(r, 20)); // 让 .then() 回调跑完
  92 +
  93 + const done = await fetch(`${base}/sweep/status`);
  94 + const doneBody = await done.json();
  95 + expect(doneBody.sweeping).toBe(false);
  96 + expect(doneBody.results).toEqual([{ agentId: AGENT, accepted: 3 }]);
  97 + });
  98 +});
web/src/components/AppNav.vue
@@ -21,6 +21,10 @@ @@ -21,6 +21,10 @@
21 <span>📊</span> Quota 21 <span>📊</span> Quota
22 </a> 22 </a>
23 23
  24 + <a href="/dream" target="_blank" class="nav-link" :class="{ active: $route.path === '/dream' }">
  25 + <span>🌙</span> Dream
  26 + </a>
  27 +
24 <div class="nav-divider"></div> 28 <div class="nav-divider"></div>
25 29
26 <!-- Company Switcher --> 30 <!-- Company Switcher -->
web/src/router/index.js
@@ -80,6 +80,11 @@ const routes = [ @@ -80,6 +80,11 @@ const routes = [
80 name: 'QuotaMonitor', 80 name: 'QuotaMonitor',
81 component: () => import('@/views/QuotaMonitor.vue'), 81 component: () => import('@/views/QuotaMonitor.vue'),
82 }, 82 },
  83 + {
  84 + path: '/dream',
  85 + name: 'DreamConsole',
  86 + component: () => import('@/views/DreamConsole.vue'),
  87 + },
83 // P5 · DevLoop Projects 列表 + Dashboard + Ops 88 // P5 · DevLoop Projects 列表 + Dashboard + Ops
84 { 89 {
85 path: '/devloop', 90 path: '/devloop',
web/src/views/DreamConsole.vue 0 → 100644
  1 +<template>
  2 + <div class="dream-page">
  3 + <AppNav />
  4 + <div class="dream-layout">
  5 + <header class="page-header">
  6 + <div>
  7 + <h1>🌙 Dream 经验汇流</h1>
  8 + <p class="subtitle">定时把所有 agent 在所有公司的记忆提炼成不含公司信息的经验,跨公司沉淀</p>
  9 + </div>
  10 + <div class="header-actions">
  11 + <button class="btn-refresh" @click="loadAgents">🔄 刷新</button>
  12 + <button class="btn-sweep" :disabled="sweepStatus.sweeping" @click="triggerSweep">
  13 + {{ sweepStatus.sweeping ? '⏳ 全量扫描中…' : '🌙 全量扫描' }}
  14 + </button>
  15 + </div>
  16 + </header>
  17 +
  18 + <div v-if="sweepStatus.startedAt" class="sweep-banner" :class="{ running: sweepStatus.sweeping }">
  19 + <span v-if="sweepStatus.sweeping">上一轮 {{ formatTime(sweepStatus.startedAt) }} 起跑,串行扫描中,可能要几分钟到几十分钟——刷新本页不会打断它。</span>
  20 + <span v-else-if="sweepStatus.error">上一轮 {{ formatTime(sweepStatus.startedAt) }} 扫描失败:{{ sweepStatus.error }}</span>
  21 + <span v-else-if="sweepStatus.results">
  22 + 上一轮 {{ formatTime(sweepStatus.finishedAt) }} 完成:{{ sweepStatus.results.length }} 个本体,
  23 + {{ sweepStatus.results.filter(r => !r.skipped && !r.error).length }} 个有新原料,
  24 + 沉淀 {{ sweepStatus.results.reduce((n, r) => n + (r.accepted || 0), 0) }} 条。
  25 + </span>
  26 + </div>
  27 +
  28 + <div v-if="loading" class="loading-state">⏳ 加载中…</div>
  29 + <div v-else-if="agents.length === 0" class="empty-state">没有可 dream 的本体</div>
  30 +
  31 + <div v-else class="agents-table">
  32 + <div class="table-header">
  33 + <span>本体</span>
  34 + <span>公司数</span>
  35 + <span>上次跑</span>
  36 + <span>沉淀 / 拒绝</span>
  37 + <span>操作</span>
  38 + </div>
  39 + <div v-for="a in agents" :key="a.agentId" class="table-row">
  40 + <span class="col-agent">{{ a.agentId }}</span>
  41 + <span>{{ a.companies }}</span>
  42 + <span class="col-time">{{ a.lastRunAt ? formatTime(a.lastRunAt) : '从未' }}</span>
  43 + <span>
  44 + <span class="badge accepted">{{ a.lastAccepted }}</span>
  45 + <span class="badge rejected">{{ a.lastRejected }}</span>
  46 + </span>
  47 + <span class="col-actions">
  48 + <button class="btn-action" @click="viewDetail(a.agentId)">👁 详情</button>
  49 + <button class="btn-action" :disabled="runningAgent === a.agentId" @click="runOne(a.agentId, false)">
  50 + {{ runningAgent === a.agentId ? '跑中…' : '▶ 跑一次' }}
  51 + </button>
  52 + <button class="btn-action" :disabled="runningAgent === a.agentId" @click="runOne(a.agentId, true)" title="只看有没有新原料,不起 LLM 调用">
  53 + dry-run
  54 + </button>
  55 + </span>
  56 + </div>
  57 + </div>
  58 +
  59 + <!-- 详情弹窗 -->
  60 + <div v-if="detail" class="modal-overlay" @click="detail = null">
  61 + <div class="modal-content" @click.stop>
  62 + <div class="modal-header">
  63 + <h2>{{ detail.agentId }}</h2>
  64 + <button class="btn-close" @click="detail = null">✕</button>
  65 + </div>
  66 + <div class="modal-body">
  67 + <section class="detail-section">
  68 + <h4>状态</h4>
  69 + <pre class="state-pre">{{ JSON.stringify(detail.state, null, 2) }}</pre>
  70 + </section>
  71 +
  72 + <section class="detail-section">
  73 + <h4>定义文件能不能写</h4>
  74 + <div class="def-list">
  75 + <div v-for="d in detail.definitions" :key="d.file" class="def-row">
  76 + <span class="def-file">{{ d.file }}</span>
  77 + <span class="badge" :class="d.allowed ? 'accepted' : 'rejected'">
  78 + {{ d.allowed ? '可写' : '不可写' }}
  79 + </span>
  80 + <span class="def-reason">{{ d.reason || '' }}</span>
  81 + </div>
  82 + </div>
  83 + </section>
  84 +
  85 + <section class="detail-section">
  86 + <h4>已沉淀经验(结论与原料原文并排,抽查用)</h4>
  87 + <div v-if="detail.experience.length === 0" class="empty-inline">还没有沉淀过</div>
  88 + <div v-for="(e, i) in detail.experience" :key="i" class="exp-card">
  89 + <div class="exp-title">{{ e.title }}</div>
  90 + <div v-for="(s, j) in e.sources" :key="j" class="exp-source">
  91 + <span class="exp-source-id">{{ s.source }}</span>
  92 + <span v-if="s.text !== null" class="exp-source-text">{{ s.text }}</span>
  93 + <span v-else class="exp-source-dangling">⚠️ 指针悬空(原料被清理过 / 出处编造)</span>
  94 + </div>
  95 + </div>
  96 + </section>
  97 + </div>
  98 + </div>
  99 + </div>
  100 +
  101 + <!-- 手动跑结果弹窗 -->
  102 + <div v-if="runResult" class="modal-overlay" @click="runResult = null">
  103 + <div class="modal-content" @click.stop>
  104 + <div class="modal-header">
  105 + <h2>{{ runResult.agentId }} 跑一次的结果</h2>
  106 + <button class="btn-close" @click="runResult = null">✕</button>
  107 + </div>
  108 + <div class="modal-body">
  109 + <pre class="state-pre">{{ JSON.stringify(runResult, null, 2) }}</pre>
  110 + </div>
  111 + </div>
  112 + </div>
  113 + </div>
  114 + </div>
  115 +</template>
  116 +
  117 +<script setup>
  118 +import { ref, onMounted, onUnmounted } from 'vue'
  119 +import AppNav from '@/components/AppNav.vue'
  120 +
  121 +const agents = ref([])
  122 +const loading = ref(true)
  123 +const detail = ref(null)
  124 +const runResult = ref(null)
  125 +const runningAgent = ref(null)
  126 +const sweepStatus = ref({ sweeping: false, startedAt: null, finishedAt: null, results: null, error: null })
  127 +let pollTimer = null
  128 +
  129 +async function loadAgents() {
  130 + loading.value = true
  131 + try {
  132 + const res = await fetch('/api/dream/agents')
  133 + const data = await res.json()
  134 + agents.value = data.agents || []
  135 + } catch (e) {
  136 + console.error('加载 dream 本体列表失败', e)
  137 + } finally {
  138 + loading.value = false
  139 + }
  140 +}
  141 +
  142 +async function loadSweepStatus() {
  143 + try {
  144 + const res = await fetch('/api/dream/sweep/status')
  145 + sweepStatus.value = await res.json()
  146 + } catch (e) {
  147 + console.error('加载 sweep 状态失败', e)
  148 + }
  149 +}
  150 +
  151 +async function triggerSweep() {
  152 + try {
  153 + const res = await fetch('/api/dream/sweep', { method: 'POST' })
  154 + if (res.status === 409) { alert('上一轮扫描还没跑完'); return }
  155 + await loadSweepStatus()
  156 + } catch (e) {
  157 + alert('触发失败: ' + e.message)
  158 + }
  159 +}
  160 +
  161 +async function viewDetail(agentId) {
  162 + try {
  163 + const res = await fetch(`/api/dream/agents/${agentId}`)
  164 + detail.value = await res.json()
  165 + } catch (e) {
  166 + alert('加载详情失败: ' + e.message)
  167 + }
  168 +}
  169 +
  170 +async function runOne(agentId, dryRun) {
  171 + runningAgent.value = agentId
  172 + try {
  173 + const res = await fetch(`/api/dream/agents/${agentId}/run`, {
  174 + method: 'POST',
  175 + headers: { 'Content-Type': 'application/json' },
  176 + body: JSON.stringify({ dryRun }),
  177 + })
  178 + runResult.value = await res.json()
  179 + await loadAgents()
  180 + } catch (e) {
  181 + alert('跑失败: ' + e.message)
  182 + } finally {
  183 + runningAgent.value = null
  184 + }
  185 +}
  186 +
  187 +function formatTime(iso) {
  188 + if (!iso) return '-'
  189 + return new Date(iso).toLocaleString('zh-CN')
  190 +}
  191 +
  192 +onMounted(() => {
  193 + loadAgents()
  194 + loadSweepStatus()
  195 + // 全量扫描可能跑几十分钟,轮询一下让「还在跑」不需要手动刷新才能看到
  196 + pollTimer = setInterval(() => { if (sweepStatus.value.sweeping) loadSweepStatus() }, 15000)
  197 +})
  198 +onUnmounted(() => { if (pollTimer) clearInterval(pollTimer) })
  199 +</script>
  200 +
  201 +<style scoped>
  202 +.dream-page { min-height: 100vh; background: var(--bg-primary); color: var(--text-primary); }
  203 +.dream-layout { max-width: 1100px; margin: 0 auto; padding: 32px 40px; }
  204 +
  205 +.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; }
  206 +.page-header h1 { font-size: 24px; font-weight: 700; margin: 0 0 6px; }
  207 +.subtitle { color: var(--text-secondary); font-size: 13px; margin: 0; }
  208 +.header-actions { display: flex; gap: 10px; }
  209 +
  210 +.btn-refresh, .btn-sweep {
  211 + padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600;
  212 + border: 1px solid var(--border-primary); background: var(--bg-surface); color: var(--text-secondary);
  213 +}
  214 +.btn-sweep { background: var(--accent); border-color: transparent; color: white; }
  215 +.btn-sweep:disabled { opacity: 0.6; cursor: not-allowed; }
  216 +.btn-refresh:hover { background: var(--bg-surface-hover); }
  217 +
  218 +.sweep-banner {
  219 + padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 13px;
  220 + background: var(--accent-bg); color: var(--text-secondary); border: 1px solid var(--border-active);
  221 +}
  222 +.sweep-banner.running { color: var(--accent-light); }
  223 +
  224 +.loading-state, .empty-state, .empty-inline { text-align: center; padding: 40px; color: var(--text-muted); }
  225 +.empty-inline { padding: 12px; text-align: left; font-style: italic; }
  226 +
  227 +.agents-table {
  228 + background: var(--bg-surface); border: 1px solid var(--border-primary); border-radius: 12px; overflow: hidden;
  229 +}
  230 +.table-header, .table-row {
  231 + display: grid; grid-template-columns: 1.4fr 0.6fr 1fr 1fr 1.6fr;
  232 + gap: 12px; padding: 12px 16px; align-items: center;
  233 +}
  234 +.table-header { font-size: 12px; font-weight: 600; color: var(--text-muted); border-bottom: 1px solid var(--border-primary); }
  235 +.table-row { border-bottom: 1px solid var(--border-primary); font-size: 13px; }
  236 +.table-row:last-child { border-bottom: none; }
  237 +.col-agent { font-weight: 600; }
  238 +.col-time { color: var(--text-secondary); }
  239 +.col-actions { display: flex; gap: 6px; flex-wrap: wrap; }
  240 +
  241 +.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; margin-right: 6px; }
  242 +.badge.accepted { background: var(--accent-bg); color: var(--accent-light); }
  243 +.badge.rejected { background: rgba(248, 81, 73, 0.12); color: #f85149; }
  244 +
  245 +.btn-action {
  246 + padding: 5px 10px; border-radius: 6px; border: 1px solid var(--border-primary);
  247 + background: var(--bg-surface); color: var(--text-primary); font-size: 12px; cursor: pointer;
  248 +}
  249 +.btn-action:hover { background: var(--bg-surface-hover); }
  250 +.btn-action:disabled { opacity: 0.5; cursor: not-allowed; }
  251 +
  252 +/* Modal */
  253 +.modal-overlay {
  254 + position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center;
  255 + justify-content: center; z-index: 1000; backdrop-filter: blur(4px);
  256 +}
  257 +.modal-content {
  258 + background: var(--bg-secondary); border: 1px solid var(--border-primary); border-radius: 12px;
  259 + width: 640px; max-width: 92vw; max-height: 84vh; overflow-y: auto;
  260 +}
  261 +.modal-header {
  262 + display: flex; justify-content: space-between; align-items: center; padding: 16px 20px;
  263 + border-bottom: 1px solid var(--border-primary); position: sticky; top: 0; background: var(--bg-secondary);
  264 +}
  265 +.modal-header h2 { margin: 0; font-size: 16px; font-family: monospace; }
  266 +.btn-close { background: none; border: none; font-size: 16px; color: var(--text-secondary); cursor: pointer; }
  267 +.modal-body { padding: 20px; }
  268 +
  269 +.detail-section { margin-bottom: 20px; }
  270 +.detail-section h4 { font-size: 13px; color: var(--text-secondary); margin: 0 0 8px; }
  271 +.state-pre {
  272 + background: var(--bg-primary); padding: 12px; border-radius: 8px; font-size: 12px;
  273 + overflow-x: auto; max-height: 200px; overflow-y: auto;
  274 +}
  275 +
  276 +.def-list { display: flex; flex-direction: column; gap: 6px; }
  277 +.def-row { display: flex; align-items: center; gap: 10px; font-size: 12px; }
  278 +.def-file { font-family: monospace; min-width: 80px; }
  279 +.def-reason { color: var(--text-muted); }
  280 +
  281 +.exp-card {
  282 + background: var(--bg-primary); border: 1px solid var(--border-primary); border-radius: 8px;
  283 + padding: 12px; margin-bottom: 10px;
  284 +}
  285 +.exp-title { font-weight: 700; font-size: 13px; margin-bottom: 6px; }
  286 +.exp-source { font-size: 12px; margin-bottom: 4px; padding-left: 8px; border-left: 2px solid var(--border-active); }
  287 +.exp-source-id { color: var(--text-muted); font-family: monospace; margin-right: 8px; }
  288 +.exp-source-text { color: var(--text-secondary); }
  289 +.exp-source-dangling { color: #f85149; }
  290 +</style>